mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-17 17:52:40 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 194d98895a |
@@ -956,7 +956,7 @@ func (a *AdminServer) validateCrawlerUploadDrive(ctx context.Context, driveID st
|
||||
|
||||
func isCrawlerUploadTargetKind(kind string) bool {
|
||||
switch strings.TrimSpace(kind) {
|
||||
case "p115", "pikpak", "p123", "googledrive", "onedrive", "wopan":
|
||||
case "p115", "pikpak", "p123", "googledrive", "onedrive", "wopan", "guangyapan":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -1271,6 +1271,7 @@ func TestHandleUpsertCrawlerPersistsAndValidatesUploadDrive(t *testing.T) {
|
||||
for _, d := range []*catalog.Drive{
|
||||
{ID: "p115-target", Kind: "p115", Name: "115", RootID: "0", Credentials: map[string]string{"cookie": "x"}},
|
||||
{ID: "wopan-target", Kind: "wopan", Name: "沃盘", RootID: "0", Credentials: map[string]string{"access_token": "a", "refresh_token": "r"}},
|
||||
{ID: "guangyapan-target", Kind: "guangyapan", Name: "光鸭", RootID: "", Credentials: map[string]string{"access_token": "a", "refresh_token": "r"}},
|
||||
{ID: "local-target", Kind: "localstorage", Name: "Local", RootID: "/", Credentials: map[string]string{"path": tmp}},
|
||||
} {
|
||||
if err := cat.UpsertDrive(ctx, d); err != nil {
|
||||
@@ -1336,6 +1337,24 @@ func TestHandleUpsertCrawlerPersistsAndValidatesUploadDrive(t *testing.T) {
|
||||
t.Fatalf("teaser callback after preserved edit = %q, want none", teaserCallbackID)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/admin/api/crawlers", strings.NewReader(`{
|
||||
"id": "crawler-upload",
|
||||
"scriptPath": "`+scriptPath+`",
|
||||
"uploadDriveId": "guangyapan-target"
|
||||
}`))
|
||||
rr = httptest.NewRecorder()
|
||||
srv.handleUpsertCrawler(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("guangyapan target status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
got, err = cat.GetDrive(ctx, "crawler-upload")
|
||||
if err != nil {
|
||||
t.Fatalf("get crawler after guangyapan target: %v", err)
|
||||
}
|
||||
if got.Credentials["upload_drive_id"] != "guangyapan-target" {
|
||||
t.Fatalf("upload_drive_id = %q, want guangyapan-target", got.Credentials["upload_drive_id"])
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/admin/api/crawlers", strings.NewReader(`{
|
||||
"id": "crawler-upload",
|
||||
"scriptPath": "`+scriptPath+`",
|
||||
|
||||
+93
-19
@@ -7,6 +7,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
type ToastKind = "info" | "success" | "error";
|
||||
type Toast = { id: number; kind: ToastKind; text: string };
|
||||
@@ -16,28 +17,85 @@ type Ctx = {
|
||||
};
|
||||
|
||||
const ToastCtx = createContext<Ctx | null>(null);
|
||||
const TOAST_DISMISS_MS = 2600;
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [items, setItems] = useState<Toast[]>([]);
|
||||
const timers = useRef<Record<string, ReturnType<typeof window.setTimeout>>>({});
|
||||
const timers = useRef(new Map<number, ReturnType<typeof window.setTimeout>>());
|
||||
const idsByText = useRef(new Map<string, number>());
|
||||
const pinnedToastIDs = useRef(new Set<number>());
|
||||
|
||||
const isDismissPaused = useCallback((id: number) => {
|
||||
return pinnedToastIDs.current.has(id);
|
||||
}, []);
|
||||
|
||||
const clearDismissTimer = useCallback((id: number) => {
|
||||
const timer = timers.current.get(id);
|
||||
if (!timer) return;
|
||||
window.clearTimeout(timer);
|
||||
timers.current.delete(id);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback(
|
||||
(id: number, text: string) => {
|
||||
clearDismissTimer(id);
|
||||
pinnedToastIDs.current.delete(id);
|
||||
if (idsByText.current.get(text) === id) {
|
||||
idsByText.current.delete(text);
|
||||
}
|
||||
setItems((list) => list.filter((t) => t.id !== id));
|
||||
},
|
||||
[clearDismissTimer]
|
||||
);
|
||||
|
||||
const scheduleDismiss = useCallback(
|
||||
(id: number, text: string) => {
|
||||
clearDismissTimer(id);
|
||||
if (isDismissPaused(id)) return;
|
||||
timers.current.set(
|
||||
id,
|
||||
window.setTimeout(() => removeToast(id, text), TOAST_DISMISS_MS)
|
||||
);
|
||||
},
|
||||
[clearDismissTimer, isDismissPaused, removeToast]
|
||||
);
|
||||
|
||||
const pinDismiss = useCallback(
|
||||
(id: number) => {
|
||||
pinnedToastIDs.current.add(id);
|
||||
clearDismissTimer(id);
|
||||
},
|
||||
[clearDismissTimer]
|
||||
);
|
||||
|
||||
// Deduplicate: same text won't stack, just resets the dismiss timer
|
||||
const show = useCallback((text: string, kind: ToastKind = "info") => {
|
||||
// Reset timer if duplicate
|
||||
if (timers.current[text]) {
|
||||
window.clearTimeout(timers.current[text]);
|
||||
timers.current[text] = window.setTimeout(() => {
|
||||
setItems((list) => list.filter((t) => t.text !== text));
|
||||
delete timers.current[text];
|
||||
}, 2600);
|
||||
return;
|
||||
}
|
||||
const id = Date.now() + Math.random();
|
||||
timers.current[text] = window.setTimeout(() => {
|
||||
setItems((list) => list.filter((t) => t.id !== id));
|
||||
delete timers.current[text];
|
||||
}, 2600);
|
||||
setItems((list) => [...list, { id, kind, text }]);
|
||||
const show = useCallback(
|
||||
(text: string, kind: ToastKind = "info") => {
|
||||
const existingID = idsByText.current.get(text);
|
||||
if (existingID !== undefined) {
|
||||
setItems((list) =>
|
||||
list.map((t) => (t.id === existingID ? { ...t, kind } : t))
|
||||
);
|
||||
scheduleDismiss(existingID, text);
|
||||
return;
|
||||
}
|
||||
const id = Date.now() + Math.random();
|
||||
idsByText.current.set(text, id);
|
||||
setItems((list) => [...list, { id, kind, text }]);
|
||||
scheduleDismiss(id, text);
|
||||
},
|
||||
[scheduleDismiss]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const timer of timers.current.values()) {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
timers.current.clear();
|
||||
idsByText.current.clear();
|
||||
pinnedToastIDs.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -45,8 +103,24 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
{children}
|
||||
<div className="admin-toast-stack" role="status" aria-live="polite">
|
||||
{items.map((t) => (
|
||||
<div key={t.id} className={`admin-toast is-${t.kind}`}>
|
||||
{t.text}
|
||||
<div
|
||||
key={t.id}
|
||||
className={`admin-toast is-${t.kind}`}
|
||||
onClick={() => pinDismiss(t.id)}
|
||||
>
|
||||
<span className="admin-toast__text">{t.text}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-toast__close"
|
||||
aria-label="关闭提示"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
removeToast(t.id, t.text);
|
||||
}}
|
||||
>
|
||||
<X size={16} strokeWidth={2.4} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+52
-2
@@ -2194,18 +2194,54 @@
|
||||
}
|
||||
|
||||
.admin-toast {
|
||||
padding: 12px 18px;
|
||||
max-width: min(520px, calc(100vw - 48px));
|
||||
padding: 14px 54px 14px 18px;
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-strong);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-sm);
|
||||
position: relative;
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
touch-action: manipulation;
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: toast-in var(--duration-normal) var(--ease-out);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.admin-toast__text {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.admin-toast__close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: currentColor;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.68;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.admin-toast__close:hover,
|
||||
.admin-toast__close:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
opacity: 1;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
@@ -2883,7 +2919,21 @@
|
||||
}
|
||||
|
||||
.admin-toast {
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
max-height: min(42vh, 220px);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin-toast__text {
|
||||
max-height: min(32vh, 168px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.admin-toast__close {
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const toastSource = readFileSync(
|
||||
new URL("../src/admin/ToastContext.tsx", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const adminCss = readFileSync(
|
||||
new URL("../src/styles/admin.css", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
function ruleBody(css: string, selector: string): string {
|
||||
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`));
|
||||
assert.ok(match, `Expected CSS rule for ${selector}`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function mobileCss(): string {
|
||||
const marker = "@media (max-width: 768px)";
|
||||
const start = adminCss.indexOf(marker);
|
||||
assert.notEqual(start, -1, "Expected mobile admin media query");
|
||||
return adminCss.slice(start);
|
||||
}
|
||||
|
||||
test("admin toasts auto-dismiss unless the toast body is clicked", () => {
|
||||
assert.match(toastSource, /const TOAST_DISMISS_MS = 2600/);
|
||||
assert.match(toastSource, /pinnedToastIDs\.current\.has\(id\)/);
|
||||
assert.match(toastSource, /if \(isDismissPaused\(id\)\) return/);
|
||||
assert.match(toastSource, /onClick=\{\(\) => pinDismiss\(t\.id\)\}/);
|
||||
assert.match(toastSource, /className="admin-toast__close"/);
|
||||
assert.match(toastSource, /aria-label="关闭提示"/);
|
||||
assert.match(toastSource, /<X size=\{16\} strokeWidth=\{2\.4\} \/>/);
|
||||
assert.match(toastSource, /event\.stopPropagation\(\)/);
|
||||
assert.match(toastSource, /removeToast\(t\.id, t\.text\)/);
|
||||
assert.doesNotMatch(toastSource, /onPointerEnter/);
|
||||
assert.doesNotMatch(toastSource, /onPointerLeave/);
|
||||
});
|
||||
|
||||
test("admin toasts fit long messages on mobile", () => {
|
||||
const baseToast = ruleBody(adminCss, ".admin-toast");
|
||||
const baseText = ruleBody(adminCss, ".admin-toast__text");
|
||||
const closeButton = ruleBody(adminCss, ".admin-toast__close");
|
||||
const mobileToast = ruleBody(mobileCss(), ".admin-toast");
|
||||
const mobileText = ruleBody(mobileCss(), ".admin-toast__text");
|
||||
const mobileCloseButton = ruleBody(mobileCss(), ".admin-toast__close");
|
||||
|
||||
assert.match(baseToast, /max-width\s*:\s*min\(520px,\s*calc\(100vw - 48px\)\)/);
|
||||
assert.match(baseToast, /padding\s*:\s*14px\s+54px\s+14px\s+18px/);
|
||||
assert.match(baseToast, /position\s*:\s*relative/);
|
||||
assert.match(baseToast, /overflow-wrap\s*:\s*anywhere/);
|
||||
assert.match(baseToast, /touch-action\s*:\s*manipulation/);
|
||||
assert.match(baseText, /padding-right\s*:\s*2px/);
|
||||
assert.match(closeButton, /position\s*:\s*absolute/);
|
||||
assert.match(closeButton, /top\s*:\s*10px/);
|
||||
assert.match(closeButton, /right\s*:\s*10px/);
|
||||
assert.match(closeButton, /width\s*:\s*30px/);
|
||||
assert.match(closeButton, /border\s*:\s*1px\s+solid\s+currentColor/);
|
||||
assert.match(closeButton, /cursor\s*:\s*pointer/);
|
||||
assert.match(mobileToast, /max-width\s*:\s*100%/);
|
||||
assert.match(mobileToast, /max-height\s*:\s*min\(42vh,\s*220px\)/);
|
||||
assert.match(mobileToast, /text-align\s*:\s*left/);
|
||||
assert.match(mobileText, /max-height\s*:\s*min\(32vh,\s*168px\)/);
|
||||
assert.match(mobileText, /overflow-y\s*:\s*auto/);
|
||||
assert.match(mobileCloseButton, /width\s*:\s*34px/);
|
||||
assert.match(mobileCloseButton, /height\s*:\s*34px/);
|
||||
});
|
||||
Reference in New Issue
Block a user