From 66cd1945724b1dcb007e48f1b039bbcf7dca3d5e Mon Sep 17 00:00:00 2001 From: Blizzard Date: Fri, 17 Jul 2026 16:39:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E4=BA=A4=E4=BA=92=E6=89=93?= =?UTF-8?q?=E7=A3=A8=20=E2=80=94=20toast=20=E6=8F=90=E7=A4=BA=20+=20?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E7=A1=AE=E8=AE=A4=E5=AF=B9=E8=AF=9D=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 全局 toast:保存/删除成功失败即时反馈,替代静默保存 - 删除确认对话框替换 window.confirm,危险操作红色按钮 - 编辑保存成功 toast + 跳转列表 Co-Authored-By: Claude Opus 4.8 --- admin/src/components/confirm-dialog.tsx | 63 +++++++++++++++++++++++++ admin/src/components/toast.tsx | 61 ++++++++++++++++++++++++ admin/src/main.tsx | 9 ++-- admin/src/pages/post-edit.tsx | 6 ++- admin/src/pages/posts-list.tsx | 33 +++++++++++-- 5 files changed, 162 insertions(+), 10 deletions(-) create mode 100644 admin/src/components/confirm-dialog.tsx create mode 100644 admin/src/components/toast.tsx diff --git a/admin/src/components/confirm-dialog.tsx b/admin/src/components/confirm-dialog.tsx new file mode 100644 index 0000000..a278c98 --- /dev/null +++ b/admin/src/components/confirm-dialog.tsx @@ -0,0 +1,63 @@ +import { cn } from '@/lib/utils' + +interface Props { + open: boolean + title: string + description?: string + confirmText?: string + danger?: boolean + loading?: boolean + onConfirm: () => void + onCancel: () => void +} + +export function ConfirmDialog({ + open, + title, + description, + confirmText = '确认', + danger, + loading, + onConfirm, + onCancel, +}: Props) { + if (!open) return null + return ( +
+
e.stopPropagation()} + > +

{title}

+ {description && ( +

+ {description} +

+ )} +
+ + +
+
+
+ ) +} diff --git a/admin/src/components/toast.tsx b/admin/src/components/toast.tsx new file mode 100644 index 0000000..93111be --- /dev/null +++ b/admin/src/components/toast.tsx @@ -0,0 +1,61 @@ +import { + createContext, + useCallback, + useContext, + useState, + type ReactNode, +} from 'react' +import { CheckCircle2, XCircle } from 'lucide-react' +import { cn } from '@/lib/utils' + +type ToastType = 'success' | 'error' +interface ToastItem { + id: number + message: string + type: ToastType +} + +const ToastCtx = createContext<(message: string, type?: ToastType) => void>( + () => {}, +) + +let seq = 0 + +export function ToastProvider({ children }: { children: ReactNode }) { + const [items, setItems] = useState([]) + + const toast = useCallback((message: string, type: ToastType = 'success') => { + const id = ++seq + setItems((l) => [...l, { id, message, type }]) + setTimeout(() => setItems((l) => l.filter((t) => t.id !== id)), 3000) + }, []) + + return ( + + {children} +
+ {items.map((t) => ( +
+ {t.type === 'success' ? ( + + ) : ( + + )} + {t.message} +
+ ))} +
+
+ ) +} + +export function useToast() { + return useContext(ToastCtx) +} diff --git a/admin/src/main.tsx b/admin/src/main.tsx index 9d0f590..5c628b7 100644 --- a/admin/src/main.tsx +++ b/admin/src/main.tsx @@ -2,15 +2,18 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import { ThemeProvider } from '@/components/theme-provider' +import { ToastProvider } from '@/components/toast' import App from '@/App' import '@/index.css' createRoot(document.getElementById('root')!).render( - - - + + + + + , ) diff --git a/admin/src/pages/post-edit.tsx b/admin/src/pages/post-edit.tsx index a0b5a6c..2addb9f 100644 --- a/admin/src/pages/post-edit.tsx +++ b/admin/src/pages/post-edit.tsx @@ -13,6 +13,7 @@ import { Quote, } from 'lucide-react' import { createPost, fetchPost, updatePost, type PostForm } from '@/api/posts' +import { useToast } from '@/components/toast' const EMPTY: PostForm = { slug: '', @@ -26,6 +27,7 @@ const EMPTY: PostForm = { export default function PostEditPage() { const { id } = useParams() const navigate = useNavigate() + const toast = useToast() const isEdit = Boolean(id) const [form, setForm] = useState(EMPTY) @@ -104,16 +106,16 @@ export default function PostEditPage() { const onSubmit = async (e: FormEvent) => { e.preventDefault() setSaving(true) - setError('') try { if (isEdit && id) { await updatePost(id, form) } else { await createPost(form) } + toast('已保存') navigate('/posts') } catch (err) { - setError(err instanceof Error ? err.message : '保存失败') + toast(err instanceof Error ? err.message : '保存失败', 'error') } finally { setSaving(false) } diff --git a/admin/src/pages/posts-list.tsx b/admin/src/pages/posts-list.tsx index b9a1d75..b9e1353 100644 --- a/admin/src/pages/posts-list.tsx +++ b/admin/src/pages/posts-list.tsx @@ -7,15 +7,20 @@ import { type PageResult, type PostListItem, } from '@/api/posts' +import { useToast } from '@/components/toast' +import { ConfirmDialog } from '@/components/confirm-dialog' const PAGE_SIZE = 10 export default function PostsListPage() { + const toast = useToast() const [page, setPage] = useState(1) const [keyword, setKeyword] = useState('') const [input, setInput] = useState('') const [result, setResult] = useState | null>(null) const [error, setError] = useState('') + const [target, setTarget] = useState(null) + const [deleting, setDeleting] = useState(false) const load = useCallback(() => { fetchPosts(page, PAGE_SIZE, keyword) @@ -30,13 +35,18 @@ export default function PostsListPage() { load() }, [load]) - const onDelete = async (post: PostListItem) => { - if (!window.confirm(`确认删除「${post.title}」?此操作不可恢复。`)) return + const confirmDelete = async () => { + if (!target) return + setDeleting(true) try { - await deletePost(post.id) + await deletePost(target.id) + toast('已删除') + setTarget(null) load() } catch (err) { - setError(err instanceof Error ? err.message : '删除失败') + toast(err instanceof Error ? err.message : '删除失败', 'error') + } finally { + setDeleting(false) } } @@ -130,7 +140,7 @@ export default function PostsListPage() { )} + + setTarget(null)} + /> ) }