From 368a89262c34c5b64525018a26d2fc59709cd1ae Mon Sep 17 00:00:00 2001 From: hicocos Date: Tue, 25 Aug 2026 13:47:29 +0800 Subject: [PATCH] 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 --- frontend/src/components/layout/AppLayout.tsx | 45 ++++++-- .../src/components/pages/SettingsPage.tsx | 102 ++++++++++-------- frontend/src/components/pages/TasksPage.tsx | 8 +- .../pages/frontendInteractionPolish.test.ts | 36 +++++++ 4 files changed, 138 insertions(+), 53 deletions(-) create mode 100644 frontend/src/components/pages/frontendInteractionPolish.test.ts diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 25f528b..97d4320 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -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 ( +
+ {options.map(option => { + const Icon = option.icon; + const selected = theme === option.value; + return ( + + ); + })} +
+ ); +}; interface SidebarItemProps { icon: React.ElementType; @@ -243,11 +274,13 @@ export const AppLayout = ({ children, activeCategory, onCategoryChange, storageS - {/* Mobile Menu Toggle */} -
- +
+ +
+ +
diff --git a/frontend/src/components/pages/SettingsPage.tsx b/frontend/src/components/pages/SettingsPage.tsx index 5d801ea..649585d 100644 --- a/frontend/src/components/pages/SettingsPage.tsx +++ b/frontend/src/components/pages/SettingsPage.tsx @@ -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 }) => ( + +
+ {state.tone === 'success' ? : state.tone === 'error' ? : } +
+

{state.title}

+

{state.message}

+
+ +
+
+); + const ActionDialog = ({ state, input, onInput, onCancel, onConfirm }: { state: ActionDialogState; input: string; @@ -125,8 +157,8 @@ const ActionDialog = ({ state, input, onInput, onCancel, onConfirm }: { /> )}
- {state.mode !== 'notice' && } - + +
@@ -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(null); useRuntimeUiLocalization(pageRef); const [actionDialog, setActionDialog] = useState(null); + const [actionNotice, setActionNotice] = useState(null); const [actionDialogInput, setActionDialogInput] = useState(''); - const showNotice = (message: string, title = '操作结果') => new Promise(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(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" > + + {actionNotice && } + {actionDialog && ( } /> - - - - - - } - /> + {/* Theme controls live in the global header so appearance is reachable from every page. */} } diff --git a/frontend/src/components/pages/TasksPage.tsx b/frontend/src/components/pages/TasksPage.tsx index ad8f42f..53a5f33 100644 --- a/frontend/src/components/pages/TasksPage.tsx +++ b/frontend/src/components/pages/TasksPage.tsx @@ -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 &&
已选择 {selected.length} 条
} - {notice &&
{notice}
} + {notice &&
{notice}
} {error &&
{error}
} {loading ?
: tasks.length === 0 ?

没有符合条件的任务

调整来源或状态筛选后再查看。

: ( diff --git a/frontend/src/components/pages/frontendInteractionPolish.test.ts b/frontend/src/components/pages/frontendInteractionPolish.test.ts new file mode 100644 index 0000000..176c238 --- /dev/null +++ b/frontend/src/components/pages/frontendInteractionPolish.test.ts @@ -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' \? '知道了'/); +});