feat(admin): 交互打磨 — toast 提示 + 删除确认对话框

- 全局 toast:保存/删除成功失败即时反馈,替代静默保存
- 删除确认对话框替换 window.confirm,危险操作红色按钮
- 编辑保存成功 toast + 跳转列表

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-17 16:39:49 +08:00
parent eee51ddf0a
commit 66cd194572
5 changed files with 162 additions and 10 deletions
+63
View File
@@ -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 (
<div
className="fixed inset-0 z-[90] flex items-center justify-center bg-black/40 p-4"
onClick={onCancel}
>
<div
className="w-full max-w-sm rounded-xl border border-hairline bg-surface p-6"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-[16px] font-semibold">{title}</h3>
{description && (
<p className="mt-2 text-[13.5px] leading-relaxed text-ink-2">
{description}
</p>
)}
<div className="mt-5 flex justify-end gap-2.5">
<button
type="button"
onClick={onCancel}
className="rounded-lg border border-hairline-strong px-4 py-2 text-[13.5px] text-ink-2 transition-colors hover:bg-ground"
>
</button>
<button
type="button"
onClick={onConfirm}
disabled={loading}
className={cn(
'rounded-lg px-4 py-2 text-[13.5px] font-medium text-ground transition-opacity hover:opacity-90 disabled:opacity-60',
danger ? 'bg-red-600' : 'bg-accent',
)}
>
{loading ? '处理中…' : confirmText}
</button>
</div>
</div>
</div>
)
}
+61
View File
@@ -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<ToastItem[]>([])
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 (
<ToastCtx.Provider value={toast}>
{children}
<div className="fixed right-4 top-4 z-[100] flex flex-col gap-2">
{items.map((t) => (
<div
key={t.id}
role="status"
className={cn(
'flex items-center gap-2.5 rounded-lg border bg-surface px-4 py-2.5 text-[13.5px] shadow-md',
t.type === 'success' ? 'border-hairline' : 'border-red-500/40',
)}
>
{t.type === 'success' ? (
<CheckCircle2 className="size-[17px] text-accent" />
) : (
<XCircle className="size-[17px] text-red-500" />
)}
<span className="text-ink">{t.message}</span>
</div>
))}
</div>
</ToastCtx.Provider>
)
}
export function useToast() {
return useContext(ToastCtx)
}
+6 -3
View File
@@ -2,15 +2,18 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import { ThemeProvider } from '@/components/theme-provider' import { ThemeProvider } from '@/components/theme-provider'
import { ToastProvider } from '@/components/toast'
import App from '@/App' import App from '@/App'
import '@/index.css' import '@/index.css'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<ThemeProvider> <ThemeProvider>
<BrowserRouter basename="/admin"> <ToastProvider>
<App /> <BrowserRouter basename="/admin">
</BrowserRouter> <App />
</BrowserRouter>
</ToastProvider>
</ThemeProvider> </ThemeProvider>
</StrictMode>, </StrictMode>,
) )
+4 -2
View File
@@ -13,6 +13,7 @@ import {
Quote, Quote,
} from 'lucide-react' } from 'lucide-react'
import { createPost, fetchPost, updatePost, type PostForm } from '@/api/posts' import { createPost, fetchPost, updatePost, type PostForm } from '@/api/posts'
import { useToast } from '@/components/toast'
const EMPTY: PostForm = { const EMPTY: PostForm = {
slug: '', slug: '',
@@ -26,6 +27,7 @@ const EMPTY: PostForm = {
export default function PostEditPage() { export default function PostEditPage() {
const { id } = useParams() const { id } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const toast = useToast()
const isEdit = Boolean(id) const isEdit = Boolean(id)
const [form, setForm] = useState<PostForm>(EMPTY) const [form, setForm] = useState<PostForm>(EMPTY)
@@ -104,16 +106,16 @@ export default function PostEditPage() {
const onSubmit = async (e: FormEvent) => { const onSubmit = async (e: FormEvent) => {
e.preventDefault() e.preventDefault()
setSaving(true) setSaving(true)
setError('')
try { try {
if (isEdit && id) { if (isEdit && id) {
await updatePost(id, form) await updatePost(id, form)
} else { } else {
await createPost(form) await createPost(form)
} }
toast('已保存')
navigate('/posts') navigate('/posts')
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '保存失败') toast(err instanceof Error ? err.message : '保存失败', 'error')
} finally { } finally {
setSaving(false) setSaving(false)
} }
+28 -5
View File
@@ -7,15 +7,20 @@ import {
type PageResult, type PageResult,
type PostListItem, type PostListItem,
} from '@/api/posts' } from '@/api/posts'
import { useToast } from '@/components/toast'
import { ConfirmDialog } from '@/components/confirm-dialog'
const PAGE_SIZE = 10 const PAGE_SIZE = 10
export default function PostsListPage() { export default function PostsListPage() {
const toast = useToast()
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [keyword, setKeyword] = useState('') const [keyword, setKeyword] = useState('')
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [result, setResult] = useState<PageResult<PostListItem> | null>(null) const [result, setResult] = useState<PageResult<PostListItem> | null>(null)
const [error, setError] = useState('') const [error, setError] = useState('')
const [target, setTarget] = useState<PostListItem | null>(null)
const [deleting, setDeleting] = useState(false)
const load = useCallback(() => { const load = useCallback(() => {
fetchPosts(page, PAGE_SIZE, keyword) fetchPosts(page, PAGE_SIZE, keyword)
@@ -30,13 +35,18 @@ export default function PostsListPage() {
load() load()
}, [load]) }, [load])
const onDelete = async (post: PostListItem) => { const confirmDelete = async () => {
if (!window.confirm(`确认删除「${post.title}」?此操作不可恢复。`)) return if (!target) return
setDeleting(true)
try { try {
await deletePost(post.id) await deletePost(target.id)
toast('已删除')
setTarget(null)
load() load()
} catch (err) { } 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() {
<button <button
type="button" type="button"
aria-label={`删除 ${post.title}`} aria-label={`删除 ${post.title}`}
onClick={() => onDelete(post)} onClick={() => setTarget(post)}
className="rounded p-1.5 text-ink-3 transition-colors hover:text-red-600 dark:hover:text-red-400" className="rounded p-1.5 text-ink-3 transition-colors hover:text-red-600 dark:hover:text-red-400"
> >
<Trash2 className="size-4" /> <Trash2 className="size-4" />
@@ -174,6 +184,19 @@ export default function PostsListPage() {
</button> </button>
</div> </div>
)} )}
<ConfirmDialog
open={!!target}
title="删除文章"
description={
target ? `确认删除「${target.title}」?此操作不可恢复。` : ''
}
confirmText="删除"
danger
loading={deleting}
onConfirm={confirmDelete}
onCancel={() => setTarget(null)}
/>
</div> </div>
) )
} }