1 Commits

Author SHA1 Message Date
nianzhibai 194d98895a fix: allow guangyapan crawler uploads and improve admin toasts 2026-06-17 17:33:58 +08:00
5 changed files with 234 additions and 22 deletions
+1 -1
View File
@@ -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
+19
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
+69
View File
@@ -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/);
});