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