feat(admin): 交互打磨 — toast 提示 + 删除确认对话框
- 全局 toast:保存/删除成功失败即时反馈,替代静默保存 - 删除确认对话框替换 window.confirm,危险操作红色按钮 - 编辑保存成功 toast + 跳转列表 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<BrowserRouter basename="/admin">
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
<ToastProvider>
|
||||
<BrowserRouter basename="/admin">
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -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<PostForm>(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)
|
||||
}
|
||||
|
||||
@@ -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<PageResult<PostListItem> | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [target, setTarget] = useState<PostListItem | null>(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() {
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
@@ -174,6 +184,19 @@ export default function PostsListPage() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!target}
|
||||
title="删除文章"
|
||||
description={
|
||||
target ? `确认删除「${target.title}」?此操作不可恢复。` : ''
|
||||
}
|
||||
confirmText="删除"
|
||||
danger
|
||||
loading={deleting}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user