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:
1 parent
f543dd1d15
commit
368a89262c
4 files changed
+138
-53
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
</>}
|
||||
|
||||
|
||||
@@ -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' \? '知道了'/);
|
||||
});
|
||||
Reference in new issue
Block a user