Files
pure-note/web/src/pages/AdminList.tsx
T
wangairnan b94e9b1721 fix(web): 修复图片上传、自动保存竞态、登录竞态并统一失效公共缓存
FormData 不强设 Content-Type(P1-2),补 api 客户端回归测试; 自动保存成功仅失效列表与公共键,防详情 refetch 回滚输入(P1-4); login/logout 以请求序号丢弃迟到 /api/me 响应,401 统一回调跳登录(P2-15); 首页不硬编码 page_size,写操作后统一 invalidatePublic,TagPage 补 error 分支(P2-15)。
2026-09-08 17:32:57 +08:00

165 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Link, useNavigate } from 'react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { api } from '../lib/api'
import type { NoteItem } from '../lib/api'
import { invalidatePublic } from '../lib/invalidate'
import { useAuth } from '../lib/auth'
import { formatDateTime } from '../lib/utils'
function useAdminNotes() {
return useQuery({
queryKey: ['admin', 'notes'],
queryFn: () => api<{ items: NoteItem[]; total: number }>('/api/admin/notes'),
})
}
export default function AdminList() {
const { data, isLoading } = useAdminNotes()
const { logout } = useAuth()
const navigate = useNavigate()
const qc = useQueryClient()
const [confirmId, setConfirmId] = useState<number | null>(null)
const trashMutation = useMutation({
mutationFn: (id: number) => api(`/api/admin/notes/${id}`, { method: 'DELETE' }),
onSuccess: () => {
setConfirmId(null)
void qc.invalidateQueries({ queryKey: ['admin'] })
invalidatePublic(qc)
},
})
const toggleStatus = useMutation({
mutationFn: async (note: NoteItem) => {
// 状态切换:读全文 → 翻转 status → PUT
const full = await api<NoteItem & { content: string }>(`/api/admin/notes/${note.id}`)
return api(`/api/admin/notes/${note.id}`, {
method: 'PUT',
body: JSON.stringify({
title: full.title,
slug: full.slug,
summary: full.summary,
content: full.content,
status: full.status === 'public' ? 'private' : 'public',
tags: full.tags,
pinned: full.pinned,
}),
})
},
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['admin'] })
invalidatePublic(qc)
},
})
return (
<div>
<div className="mb-4 flex items-center justify-between">
<h1 className="text-lg font-semibold">笔记管理({data?.total ?? 0})</h1>
<div className="flex gap-2">
<Link
to="/admin/notes/new"
className="inline-flex items-center gap-1 rounded-lg bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
<Plus size={15} /> 新建
</Link>
<button
type="button"
onClick={() => void logout().then(() => navigate('/'))}
className="rounded-lg border border-zinc-300 px-3 py-1.5 text-sm text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800"
>
登出
</button>
</div>
</div>
{isLoading ? (
<p className="py-12 text-center text-zinc-400">加载中…</p>
) : !data || data.items.length === 0 ? (
<p className="py-12 text-center text-zinc-400">还没有笔记,点击「新建」开始</p>
) : (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-900 dark:border-zinc-800">
{data.items.map((n) => (
<li key={n.id} className="flex items-center gap-3 px-4 py-3">
<span
className={`rounded px-1.5 py-0.5 text-xs ${
n.status === 'public'
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400'
: 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400'
}`}
>
{n.status === 'public' ? '公开' : '私有'}
</span>
{n.pinned && <span className="text-xs text-amber-600">置顶</span>}
<div className="min-w-0 flex-1">
<Link to={`/admin/notes/${n.id}/edit`} className="block truncate font-medium hover:text-blue-600">
{n.title}
</Link>
<p className="truncate text-xs text-zinc-400">
/{n.slug} · {formatDateTime(n.updated_at)}
{n.tags.length > 0 && ' · ' + n.tags.map((t) => '#' + t).join(' ')}
</p>
</div>
<button
type="button"
onClick={() => void toggleStatus.mutate(n)}
disabled={toggleStatus.isPending}
className="rounded px-2 py-1 text-xs text-zinc-500 hover:bg-zinc-100 disabled:opacity-50 dark:hover:bg-zinc-800"
>
{n.status === 'public' ? '转私有' : '转公开'}
</button>
<Link
to={`/admin/notes/${n.id}/edit`}
className="rounded p-1.5 text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800"
title="编辑"
>
<Pencil size={15} />
</Link>
<button
type="button"
onClick={() => setConfirmId(n.id)}
className="rounded p-1.5 text-red-500 hover:bg-red-50 dark:hover:bg-red-950"
title="删除(进回收站)"
>
<Trash2 size={15} />
</button>
{confirmId === n.id && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4">
<div className="w-full max-w-sm rounded-xl bg-white p-6 dark:bg-zinc-900">
<h3 className="font-semibold">移入回收站?</h3>
<p className="mt-1 text-sm text-zinc-500">
「{n.title}」将被软删除,30 天内可在回收站恢复。
</p>
{trashMutation.isError && (
<p className="mt-2 text-sm text-red-600">操作失败,请重试</p>
)}
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
onClick={() => setConfirmId(null)}
className="rounded-lg border border-zinc-300 px-3 py-1.5 text-sm dark:border-zinc-700"
>
取消
</button>
<button
type="button"
onClick={() => trashMutation.mutate(n.id)}
disabled={trashMutation.isPending}
className="rounded-lg bg-red-600 px-3 py-1.5 text-sm text-white hover:bg-red-700 disabled:opacity-50"
>
{trashMutation.isPending ? '删除中…' : '确认删除'}
</button>
</div>
</div>
</div>
)}
</li>
))}
</ul>
)}
</div>
)
}