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 { 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>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user