diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index abb2be5..e459ccd 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,7 @@ import { Link, Outlet } from 'react-router-dom' import { useAuthStore } from '../stores/auth' import { useThemeStore } from '../stores/theme' +import ToastContainer from './Toast' export default function Layout() { const { user, logout } = useAuthStore() @@ -59,6 +60,7 @@ export default function Layout() {
+ ) } diff --git a/frontend/src/components/Toast.module.css b/frontend/src/components/Toast.module.css new file mode 100644 index 0000000..64b89bd --- /dev/null +++ b/frontend/src/components/Toast.module.css @@ -0,0 +1,70 @@ +.container { + position: fixed; + top: 72px; + right: 24px; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 8px; + pointer-events: none; +} + +.toast { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + border-radius: var(--radius); + background: var(--toast-bg); + border: 1px solid var(--border); + box-shadow: var(--shadow-lg); + color: var(--text-primary); + font-size: 13px; + min-width: 240px; + max-width: 380px; + pointer-events: auto; + animation: slideIn 0.3s ease; +} + +.toastSuccess { + composes: toast; + border-left: 4px solid var(--success); +} + +.toastError { + composes: toast; + border-left: 4px solid var(--error); +} + +.toastInfo { + composes: toast; + border-left: 4px solid var(--accent); +} + +.closeBtn { + background: none; + border: none; + cursor: pointer; + color: var(--text-muted); + font-size: 16px; + padding: 2px 4px; + line-height: 1; + transition: color 0.15s; + flex-shrink: 0; +} + +.closeBtn:hover { + color: var(--text-primary); +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(24px); + } + to { + opacity: 1; + transform: translateX(0); + } +} diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx new file mode 100644 index 0000000..3c40665 --- /dev/null +++ b/frontend/src/components/Toast.tsx @@ -0,0 +1,32 @@ +import { useToastStore } from '../stores/toast' +import type { ToastType } from '../stores/toast' +import styles from './Toast.module.css' + +const TYPE_CLASS: Record = { + success: styles.toastSuccess, + error: styles.toastError, + info: styles.toastInfo, +} + +export default function ToastContainer() { + const { toasts, removeToast } = useToastStore() + + if (toasts.length === 0) return null + + return ( +
+ {toasts.map((t) => ( +
+ {t.message} + +
+ ))} +
+ ) +} diff --git a/frontend/src/pages/GeneratePage.tsx b/frontend/src/pages/GeneratePage.tsx index 9eee0aa..bb17ec7 100644 --- a/frontend/src/pages/GeneratePage.tsx +++ b/frontend/src/pages/GeneratePage.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { useTaskStore } from '../stores/task' import { useGenerationStore } from '../stores/generation' +import { useToastStore } from '../stores/toast' import GenerateForm from '../components/GenerateForm' import ProgressBar from '../components/ProgressBar' @@ -9,6 +10,7 @@ export default function GeneratePage() { const { projectId = 'proj-default' } = useParams() const navigate = useNavigate() const { assetType, reset: resetTask } = useTaskStore() + const addToast = useToastStore(s => s.addToast) const { status, stage, @@ -25,6 +27,13 @@ export default function GeneratePage() { return () => resetGeneration() }, [resetGeneration]) + // 失败时显示 toast + useEffect(() => { + if (status === 'failed') { + addToast({ type: 'error', message: '素材生成失败,请重试' }) + } + }, [status, addToast]) + // 完成后自动跳转 useEffect(() => { if (status === 'completed' && taskId) { diff --git a/frontend/src/pages/ProjectPage.tsx b/frontend/src/pages/ProjectPage.tsx index 70c21e6..971c503 100644 --- a/frontend/src/pages/ProjectPage.tsx +++ b/frontend/src/pages/ProjectPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { useProjectStore } from '../stores/project' +import { useToastStore } from '../stores/toast' import { getTasks } from '../api/project' import type { Task } from '../api/types' import StyleSelector from '../components/StyleSelector' @@ -35,10 +36,15 @@ export default function ProjectPage() { getTasks(projectId).then(setTasks) }, [projectId, loadProject, loadStyle]) + const addToast = useToastStore(s => s.addToast) + const handleSave = async () => { setSaving(true) try { await saveStyle() + addToast({ type: 'success', message: '风格已保存' }) + } catch { + addToast({ type: 'error', message: '保存失败,请重试' }) } finally { setSaving(false) } diff --git a/frontend/src/stores/toast.ts b/frontend/src/stores/toast.ts new file mode 100644 index 0000000..8a717b0 --- /dev/null +++ b/frontend/src/stores/toast.ts @@ -0,0 +1,34 @@ +import { create } from 'zustand' + +export type ToastType = 'success' | 'error' | 'info' + +export interface Toast { + id: string + type: ToastType + message: string + duration?: number +} + +interface ToastState { + toasts: Toast[] + addToast: (t: Omit) => void + removeToast: (id: string) => void +} + +export const useToastStore = create((set) => ({ + toasts: [], + + addToast: (t) => { + const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6) + const toast: Toast = { ...t, id } + set((state) => ({ toasts: [...state.toasts, toast] })) + + const duration = t.duration ?? 3000 + setTimeout(() => { + set((state) => ({ toasts: state.toasts.filter((x) => x.id !== id) })) + }, duration) + }, + + removeToast: (id) => + set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })), +}))