From 7b7b409c89dca75d581744870230df533a14ce99 Mon Sep 17 00:00:00 2001 From: tyanxie <494966054@qq.com> Date: Sat, 25 Jul 2026 12:18:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20Toast=20=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=88=86=E7=BA=A7=EF=BC=88info/success/warni?= =?UTF-8?q?ng/error=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/web/src/App.tsx | 4 +- .../src/components/InstanceView/Composer.tsx | 4 +- .../InstanceView/useConversation.ts | 2 +- .../src/components/InstanceView/useReEdit.ts | 2 +- src/web/src/components/ui/Toast.tsx | 100 +++++++++++++----- src/web/src/index.css | 18 ++++ 6 files changed, 99 insertions(+), 31 deletions(-) diff --git a/src/web/src/App.tsx b/src/web/src/App.tsx index dd033db..0f48f67 100644 --- a/src/web/src/App.tsx +++ b/src/web/src/App.tsx @@ -84,7 +84,7 @@ export default function App() { .getState() .instances.find((i) => i.id === id); if (instance && isBusy(instance.status)) { - showToast(t("sidebar.shutdownBusy")); + showToast(t("sidebar.shutdownBusy"), "warning"); return; } shuttingDownRef.current.add(id); @@ -112,7 +112,7 @@ export default function App() { if (shuttingDownRef.current.has(selectedInstanceId)) { shuttingDownRef.current.delete(selectedInstanceId); } else { - showToast(t("eventStream.instanceNotFound")); + showToast(t("eventStream.instanceNotFound"), "error"); } navigate("/"); } diff --git a/src/web/src/components/InstanceView/Composer.tsx b/src/web/src/components/InstanceView/Composer.tsx index 6d8a2f0..22181b9 100644 --- a/src/web/src/components/InstanceView/Composer.tsx +++ b/src/web/src/components/InstanceView/Composer.tsx @@ -142,7 +142,7 @@ export function Composer({ })); }) .catch((err) => { - showToast(t("eventStream.imageProcessFailed")); + showToast(t("eventStream.imageProcessFailed"), "error"); console.error("Image process failed:", err); }); }, @@ -162,7 +162,7 @@ export function Composer({ })); }) .catch((err) => { - showToast(t("eventStream.imageProcessFailed")); + showToast(t("eventStream.imageProcessFailed"), "error"); console.error("Image process failed:", err); }); e.target.value = ""; diff --git a/src/web/src/components/InstanceView/useConversation.ts b/src/web/src/components/InstanceView/useConversation.ts index b90beaf..17a4279 100644 --- a/src/web/src/components/InstanceView/useConversation.ts +++ b/src/web/src/components/InstanceView/useConversation.ts @@ -154,7 +154,7 @@ export function useConversation( defaultValue: msg.payload.message, }) : msg.payload.message; - showToast(localizedMsg); + showToast(localizedMsg, "error"); break; } } diff --git a/src/web/src/components/InstanceView/useReEdit.ts b/src/web/src/components/InstanceView/useReEdit.ts index 2feed4b..295769f 100644 --- a/src/web/src/components/InstanceView/useReEdit.ts +++ b/src/web/src/components/InstanceView/useReEdit.ts @@ -63,7 +63,7 @@ export function useReEdit( const currentDraft = useDrafts.getState().drafts.get(instanceId) ?? EMPTY_DRAFT; if (currentDraft.text.trim() || currentDraft.images.length > 0) { - showToast(t("reEdit.draftNotEmpty")); + showToast(t("reEdit.draftNotEmpty"), "warning"); return; } diff --git a/src/web/src/components/ui/Toast.tsx b/src/web/src/components/ui/Toast.tsx index f387045..231ccfa 100644 --- a/src/web/src/components/ui/Toast.tsx +++ b/src/web/src/components/ui/Toast.tsx @@ -1,11 +1,22 @@ -// 轻量 Toast 提示组件 +// 轻量 Toast 提示组件(支持分级) // macOS 26 Notification 风格:浮动毛玻璃面板 + 滑入/淡出动画 +// 级别通过左侧彩色图标区分,保持整体 Liquid Glass 材质统一 -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { + Info, + CheckCircle, + AlertTriangle, + AlertCircle, + type LucideIcon, +} from "lucide-react"; + +export type ToastLevel = "info" | "success" | "warning" | "error"; export interface ToastItem { id: number; message: string; + level: ToastLevel; /** 动画阶段:entering → visible → exiting */ phase: "entering" | "visible" | "exiting"; } @@ -13,47 +24,81 @@ export interface ToastItem { let toastId = 0; let listeners: Array<(toast: ToastItem) => void> = []; -/** 全局触发一条 toast */ -export function showToast(message: string) { - const item: ToastItem = { id: ++toastId, message, phase: "entering" }; +/** 全局触发一条 toast,level 默认 info */ +export function showToast(message: string, level: ToastLevel = "info") { + const item: ToastItem = { id: ++toastId, message, level, phase: "entering" }; listeners.forEach((fn) => fn(item)); } const TOAST_ENTER_MS = 16; // 一帧后切换到 visible 触发 CSS transition -const TOAST_DURATION_MS = 3000; const TOAST_EXIT_MS = 300; +// 不同级别的持续时间 +const DURATION_MAP: Record = { + info: 3000, + success: 3000, + warning: 4000, + error: 5000, +}; + +// 级别对应的图标组件 +const ICON_MAP: Record = { + info: Info, + success: CheckCircle, + warning: AlertTriangle, + error: AlertCircle, +}; + +// 级别对应的颜色 CSS 变量 +const COLOR_MAP: Record = { + info: "var(--toast-icon-info)", + success: "var(--toast-icon-success)", + warning: "var(--toast-icon-warning)", + error: "var(--toast-icon-error)", +}; + /** Toast 容器,放在 App 顶层即可 */ export function ToastContainer() { const [toasts, setToasts] = useState([]); + const timersRef = useRef>>(new Set()); useEffect(() => { const handler = (item: ToastItem) => { setToasts((prev) => [...prev, item]); // 入场:下一帧切到 visible 触发 transition - setTimeout(() => { + const enterTimer = setTimeout(() => { + timersRef.current.delete(enterTimer); setToasts((prev) => prev.map((t) => (t.id === item.id ? { ...t, phase: "visible" } : t)), ); }, TOAST_ENTER_MS); + timersRef.current.add(enterTimer); // 到时间后进入退出动画 - setTimeout(() => { + const duration = DURATION_MAP[item.level]; + const exitTimer = setTimeout(() => { + timersRef.current.delete(exitTimer); setToasts((prev) => prev.map((t) => (t.id === item.id ? { ...t, phase: "exiting" } : t)), ); // 动画结束后真正移除 - setTimeout(() => { + const removeTimer = setTimeout(() => { + timersRef.current.delete(removeTimer); setToasts((prev) => prev.filter((t) => t.id !== item.id)); }, TOAST_EXIT_MS); - }, TOAST_DURATION_MS); + timersRef.current.add(removeTimer); + }, duration); + timersRef.current.add(exitTimer); }; listeners.push(handler); return () => { listeners = listeners.filter((fn) => fn !== handler); + // 清理所有未完成的 timer,避免卸载后 setState + timersRef.current.forEach((t) => clearTimeout(t)); + timersRef.current.clear(); }; }, []); @@ -61,21 +106,26 @@ export function ToastContainer() { return (
- {toasts.map((item) => ( -
- {item.message} -
- ))} + {toasts.map((item) => { + const Icon = ICON_MAP[item.level]; + const color = COLOR_MAP[item.level]; + return ( +
+ + {item.message} +
+ ); + })}
); } diff --git a/src/web/src/index.css b/src/web/src/index.css index ee52edf..8ac3477 100644 --- a/src/web/src/index.css +++ b/src/web/src/index.css @@ -126,6 +126,12 @@ --badge-bg: rgba(0, 0, 0, 0.5); --badge-bg-hover: rgba(0, 0, 0, 0.7); --badge-text: #ffffff; + + /* Toast 分级图标色(macOS 26 Accent Colors - Opaque) */ + --toast-icon-info: #0091ff; + --toast-icon-success: #30d158; + --toast-icon-warning: #ff9230; + --toast-icon-error: #ff4245; } @media (prefers-color-scheme: dark) { @@ -161,6 +167,12 @@ #1e2e1e 75%, #1a1a2e 100% ); + + /* Toast 分级图标色(macOS 26 Accent Colors - Vibrant,暗色下更鲜亮) */ + --toast-icon-info: #0a99ff; + --toast-icon-success: #3bdb63; + --toast-icon-warning: #ff9e33; + --toast-icon-error: #ff4747; } } @@ -188,6 +200,12 @@ --diff-context-text: var(--label-secondary); --btn-solid: #282828; --btn-solid-hover: #3d3d3d; + + /* Toast 分级图标色(macOS 26 Accent Colors - Vibrant) */ + --toast-icon-info: #0a99ff; + --toast-icon-success: #3bdb63; + --toast-icon-warning: #ff9e33; + --toast-icon-error: #ff4747; } /* ========================================