fix: polish frontend feedback and theme controls

- auto-dismiss task cleanup feedback while keeping a manual close action
- move theme controls into the global header
- replace routine settings result dialogs with non-blocking notices
- preserve modal confirmation and input flows with regression coverage
This commit is contained in:
hicocos committed 2026-08-25 13:47:29 +08:00
1 parent f543dd1d15
commit 368a89262c
4 files changed
+138 -53

No files matched your search

+39 -6
View File
@@ -1,12 +1,43 @@
import React, { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Folder, Settings, Menu, Image as ImageIcon, Video, Music, FileText, ChevronRight, X, Star, Download, LogOut, ListChecks } from "lucide-react";
import { Folder, Settings, Menu, Image as ImageIcon, Video, Music, FileText, ChevronRight, X, Star, Download, LogOut, ListChecks, Monitor, Moon, Sun } from "lucide-react";
import { Button } from "../ui/Button";
import { cn } from "../../lib/utils";
import { useTranslation } from "react-i18next";
import { StorageWidget } from "../ui/StorageWidget";
import { LanguageToggle } from "../ui/LanguageToggle";
import type { StorageStats } from "../../services/api";
import { useTheme } from "../../hooks/useTheme";
const HeaderThemeSwitch = () => {
const { theme, setTheme } = useTheme();
const options = [
{ value: "light" as const, label: "浅色主题", icon: Sun },
{ value: "dark" as const, label: "深色主题", icon: Moon },
{ value: "system" as const, label: "跟随系统主题", icon: Monitor },
];
return (
<div data-testid="header-theme-switch" className="flex items-center gap-0.5 rounded-lg border border-border/60 bg-muted/50 p-1" role="group" aria-label="主题模式">
{options.map(option => {
const Icon = option.icon;
const selected = theme === option.value;
return (
<button
key={option.value}
type="button"
className={cn("flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-background hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40", selected && "bg-background text-foreground shadow-sm")}
onClick={() => setTheme(option.value)}
aria-label={option.label}
title={option.label}
aria-pressed={selected}
>
<Icon className="h-4 w-4" />
</button>
);
})}
</div>
);
};
interface SidebarItemProps {
icon: React.ElementType;
@@ -243,11 +274,13 @@ export const AppLayout = ({ children, activeCategory, onCategoryChange, storageS
</div>
</div>
{/* Mobile Menu Toggle */}
<div className="md:hidden">
<Button size="icon" variant="ghost" onClick={() => setIsMobileMenuOpen(true)} aria-label="打开导航菜单" title="打开导航菜单">
<Menu className="h-6 w-6" />
</Button>
<div className="ml-auto flex items-center gap-2">
<HeaderThemeSwitch />
<div className="md:hidden">
<Button size="icon" variant="ghost" onClick={() => setIsMobileMenuOpen(true)} aria-label="打开导航菜单" title="打开导航菜单">
<Menu className="h-6 w-6" />
</Button>
</div>
</div>
</header>
<div className="flex-1 overflow-auto p-4 sm:p-8 scroll-smooth will-change-transform">
+56 -46
View File
@@ -1,10 +1,9 @@
import React, { useState, useEffect, useRef } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { motion, AnimatePresence } from "framer-motion";
import { HardDrive, ChevronRight, Moon, Sun, Monitor, Palette, Globe, Cloud, Server, Database, CheckCircle, Trash2, Network, Shield, ShieldAlert, ShieldCheck, ExternalLink, BookOpen, KeyRound, LogOut, UserX, CircleHelp, XCircle, RefreshCw, Gauge, Copy } from "lucide-react";
import { HardDrive, ChevronRight, Palette, Globe, Cloud, Server, Database, CheckCircle, Trash2, Network, Shield, ShieldAlert, ShieldCheck, ExternalLink, BookOpen, KeyRound, LogOut, UserX, CircleHelp, XCircle, RefreshCw, Gauge, Copy, X } from "lucide-react";
import { Button } from "../ui/Button";
import { LanguageToggle } from "../ui/LanguageToggle";
import { useTheme } from "../../hooks/useTheme";
import { cn } from "../../lib/utils";
import { fileApi, type AdvancedTaskSettings, type StorageAccount, type StorageConfig, type StorageStats } from "../../services/api";
import { isTrustedOAuthPopupMessage } from "../../services/oauthPopupMessage";
@@ -95,13 +94,46 @@ const StorageProbeStatus = ({ account, busy, onProbe }: { account: StorageAccoun
};
interface ActionDialogState {
mode: 'notice' | 'confirm' | 'prompt';
mode: 'confirm' | 'prompt';
title: string;
message: string;
inputType?: 'text' | 'password';
resolve?: (value: boolean | string | null) => void;
}
interface ActionNoticeState {
title: string;
message: string;
tone: 'success' | 'error' | 'info';
}
const ActionNotice = ({ state, onClose }: { state: ActionNoticeState; onClose: () => void }) => (
<motion.div
initial={{ opacity: 0, y: -12, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -12, scale: 0.98 }}
className="pointer-events-none fixed inset-x-0 top-20 z-[120] flex justify-center px-4"
>
<div
className={cn(
"pointer-events-auto flex w-full max-w-md items-start gap-3 rounded-xl border bg-background/95 px-4 py-3 shadow-xl backdrop-blur",
state.tone === 'success' && "border-emerald-200",
state.tone === 'error' && "border-red-200",
state.tone === 'info' && "border-border",
)}
role="status"
aria-live="polite"
>
{state.tone === 'success' ? <CheckCircle className="mt-0.5 h-5 w-5 shrink-0 text-emerald-600" /> : state.tone === 'error' ? <XCircle className="mt-0.5 h-5 w-5 shrink-0 text-red-600" /> : <CircleHelp className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />}
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">{state.title}</p>
<p className="mt-0.5 whitespace-pre-line break-words text-sm text-muted-foreground">{state.message}</p>
</div>
<button type="button" className="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40" onClick={onClose} aria-label="关闭提示" title="关闭提示"><X className="h-4 w-4" /></button>
</div>
</motion.div>
);
const ActionDialog = ({ state, input, onInput, onCancel, onConfirm }: {
state: ActionDialogState;
input: string;
@@ -125,8 +157,8 @@ const ActionDialog = ({ state, input, onInput, onCancel, onConfirm }: {
/>
)}
<div className="mt-6 flex justify-end gap-2">
{state.mode !== 'notice' && <Button variant="outline" onClick={onCancel}>取消</Button>}
<Button onClick={onConfirm}>{state.mode === 'notice' ? '知道了' : '确认'}</Button>
<Button variant="outline" onClick={onCancel}>取消</Button>
<Button onClick={onConfirm}>确认</Button>
</div>
</div>
</div>
@@ -135,16 +167,26 @@ const ActionDialog = ({ state, input, onInput, onCancel, onConfirm }: {
export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount, activeSection, onSectionChange }: SettingsPageProps) => {
const { t } = useTranslation();
const { theme, setTheme } = useTheme();
const pageRef = useRef<HTMLDivElement>(null);
useRuntimeUiLocalization(pageRef);
const [actionDialog, setActionDialog] = useState<ActionDialogState | null>(null);
const [actionNotice, setActionNotice] = useState<ActionNoticeState | null>(null);
const [actionDialogInput, setActionDialogInput] = useState('');
const showNotice = (message: string, title = '操作结果') => new Promise<void>(resolve => {
setActionDialog({ mode: 'notice', title, message, resolve: () => resolve() });
});
const closeActionNotice = useCallback(() => {
setActionNotice(null);
}, []);
useEffect(() => {
if (!actionNotice) return;
const timer = window.setTimeout(() => closeActionNotice(), 4_000);
return () => window.clearTimeout(timer);
}, [actionNotice, closeActionNotice]);
const showNotice = (message: string, title = '操作结果') => {
const errorTone = /失败|错误|不完整|被引用|阻止/.test(title);
setActionNotice({ title, message, tone: errorTone ? 'error' : 'success' });
return Promise.resolve();
};
const requestConfirmation = (message: string, title = '请确认') => new Promise<boolean>(resolve => {
setActionDialog({ mode: 'confirm', title, message, resolve: value => resolve(value === true) });
});
@@ -372,7 +414,6 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount,
const data = await fileApi.getStorageConfig();
setConfig(data);
await showNotice(`已成功切换到 ${providerName}`);
window.location.reload();
} catch (error: any) {
await reloadStorageConfig().catch(() => undefined);
await showNotice(error.message, '操作失败');
@@ -691,6 +732,9 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount,
animate={{ opacity: 1, y: 0 }}
className="mx-auto mt-6 w-full min-w-0 max-w-5xl space-y-8 pb-10"
>
<AnimatePresence>
{actionNotice && <ActionNotice state={actionNotice} onClose={closeActionNotice} />}
</AnimatePresence>
{actionDialog && (
<ActionDialog
state={actionDialog}
@@ -737,41 +781,7 @@ export const SettingsPage = ({ storageStats, onSignedOut, onOpenTasksForAccount,
label={t("settings.general.language")}
action={<LanguageToggle />}
/>
<SettingsRow
icon={Sun}
label={t("settings.general.theme")}
action={
<div className="flex items-center gap-1 bg-muted/60 p-1 rounded-lg border border-border/30">
<Button
size="icon"
variant="ghost"
className={cn("h-7 w-7 transition-all", theme === "light" && "bg-background shadow-sm text-primary")}
onClick={() => setTheme("light")}
title={t("settings.general.themeLight")}
>
<Sun className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className={cn("h-7 w-7 transition-all", theme === "dark" && "bg-background shadow-sm text-primary")}
onClick={() => setTheme("dark")}
title={t("settings.general.themeDark")}
>
<Moon className="h-3.5 w-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className={cn("h-7 w-7 transition-all", theme === "system" && "bg-background shadow-sm text-primary")}
onClick={() => setTheme("system")}
title={t("settings.general.themeSystem")}
>
<Monitor className="h-3.5 w-3.5" />
</Button>
</div>
}
/>
{/* Theme controls live in the global header so appearance is reachable from every page. */}
</SettingsSection>
</>}
+7 -1
View File
@@ -108,6 +108,12 @@ export const TasksPage = ({ onUnauthorized, onOpenUploads, initialAccountId = nu
return () => window.clearInterval(timer);
}, [loadTasks]);
useEffect(() => {
if (!notice) return;
const timer = window.setTimeout(() => setNotice(null), 4_000);
return () => window.clearTimeout(timer);
}, [notice]);
const summary = useMemo(() => ({
active: tasks.filter(t => ['pending', 'running', 'paused', 'waiting'].includes(t.status)).length,
failed: tasks.filter(t => ['failed', 'interrupted', 'retry_required'].includes(t.status)).length,
@@ -203,7 +209,7 @@ export const TasksPage = ({ onUnauthorized, onOpenUploads, initialAccountId = nu
{selectionMode && <div className="mt-3 flex flex-wrap items-center gap-2 rounded-lg bg-muted/50 p-3 text-sm"><span>已选择 {selected.length} 条</span><Button size="sm" variant="outline" onClick={() => setSelected(dismissibleTasks.map(taskKey))}>全选可删除</Button><Button size="sm" variant="ghost" onClick={() => setSelected([])}>清空</Button><Button size="sm" variant="destructive" disabled={!selected.length || acting} onClick={() => void prepareDismissal({ tasks: selectedTasks })}>删除所选</Button></div>}
</div>
{notice && <div className="flex justify-between rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800"><span>{notice}</span><button onClick={() => setNotice(null)}>关闭</button></div>}
{notice && <div className="flex items-center justify-between gap-3 rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800" role="status" aria-live="polite"><span>{notice}</span><button type="button" className="rounded p-1 hover:bg-emerald-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-600" onClick={() => setNotice(null)} aria-label="关闭提示" title="关闭提示"><X className="h-4 w-4" /></button></div>}
{error && <div className="flex items-start justify-between gap-3 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800"><span>{error}</span><Button size="sm" variant="outline" onClick={() => void loadTasks(false)}>重试</Button></div>}
{loading ? <div className="flex min-h-48 items-center justify-center"><IndeterminateSpinner label="正在加载任务" size="md" /></div> : tasks.length === 0 ? <div className="flex min-h-48 flex-col items-center justify-center border-y text-center"><Clock3 className="mb-3 h-7 w-7 text-muted-foreground" /><p className="font-medium">没有符合条件的任务</p><p className="mt-1 text-sm text-muted-foreground">调整来源或状态筛选后再查看。</p></div> : (
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import fs from 'node:fs';
const tasks = fs.readFileSync(new URL('./TasksPage.tsx', import.meta.url), 'utf8');
const settings = fs.readFileSync(new URL('./SettingsPage.tsx', import.meta.url), 'utf8');
const layout = fs.readFileSync(new URL('../layout/AppLayout.tsx', import.meta.url), 'utf8');
test('task success feedback auto-dismisses while retaining a manual close control', () => {
assert.match(tasks, /useEffect\(\(\) => \{\s*if \(!notice\) return;[\s\S]*setTimeout\(\(\) => setNotice\(null\), 4_000\)/);
assert.match(tasks, /aria-live="polite"/);
assert.match(tasks, /aria-label="关闭提示"/);
});
test('the global header owns the theme switch on every page', () => {
assert.match(layout, /data-testid="header-theme-switch"/);
assert.match(layout, /useTheme\(\)/);
assert.match(layout, /value: "light" as const/);
assert.match(layout, /value: "dark" as const/);
assert.match(layout, /value: "system" as const/);
assert.match(layout, /setTheme\(option\.value\)/);
assert.doesNotMatch(settings, /label=\{t\("settings\.general\.theme"\)\}/);
});
test('settings uses non-modal transient feedback for notices but keeps confirmations and prompts modal', () => {
assert.match(settings, /interface ActionNoticeState/);
assert.match(settings, /role="status"/);
assert.match(settings, /aria-live="polite"/);
assert.match(settings, /window\.setTimeout\(\(\) => closeActionNotice\(\), 4_000\)/);
assert.match(settings, /setActionNotice\(\{ title, message, tone:[^\n]*\}\);/);
assert.match(settings, /return Promise\.resolve\(\);/);
assert.doesNotMatch(settings, /window\.location\.reload\(\)/);
assert.doesNotMatch(settings, /interface ActionNoticeState[\s\S]*resolve\?:/);
assert.doesNotMatch(settings, /mode:\s*'notice'\s*\|\s*'confirm'\s*\|\s*'prompt'/);
assert.doesNotMatch(settings, /state\.mode === 'notice' \? '知道了'/);
});