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)} + /> ) }