'use client'; import { useEffect, useMemo, useState } from 'react'; import useSWR from 'swr'; import { Plus, Pencil, Trash2, Folder, FileText, Loader2 } from 'lucide-react'; import { AdminHeader } from '@/components/admin/AdminHeader'; import { TableToolbar } from '@/components/admin/TableToolbar'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Badge } from '@/components/ui/badge'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { RichEditor } from '@/components/admin/RichEditor'; import { MarkdownImport } from '@/components/admin/MarkdownImport'; import { adminApi } from '@/lib/admin-services'; import { useAdminStore } from '@/store/adminStore'; import { cn } from '@/lib/utils'; import { isHTMLContent } from '@/lib/content'; import type { Manual, ManualContentFormat, ManualNodeType, ManualTreeNode } from '@/lib/types'; type ContentMode = 'rich' | 'markdown'; interface FormState { parentId: number | null; title: string; type: ManualNodeType; content: string; sort: number; isShow: number; } const FORM_DEFAULT: FormState = { parentId: null, title: '', type: 1, content: '', sort: 0, isShow: 1, }; /** 将树形结构展平为带缩进层级的列表,便于在表格中展示 */ interface FlatRow extends Manual { _depth: number; } function flattenTree(nodes: ManualTreeNode[], depth = 0, acc: FlatRow[] = []): FlatRow[] { for (const n of nodes) { acc.push({ id: n.id, parentId: n.parentId, title: n.title, type: n.type, content: null, contentFormat: 'html', sort: n.sort, isShow: n.isShow, createdAt: '', updatedAt: '', _depth: depth, }); if (n.children.length > 0) { flattenTree(n.children, depth + 1, acc); } } return acc; } /** 生成父节点下拉的缩进选项(排除 selfId 及其子孙) */ function buildParentOptions( nodes: ManualTreeNode[], selfId: number | null, ): { id: number; label: string; depth: number }[] { const options: { id: number; label: string; depth: number }[] = []; const walk = (list: ManualTreeNode[], depth: number) => { for (const n of list) { if (n.id === selfId) continue; options.push({ id: n.id, label: n.title, depth }); if (n.children.length > 0) walk(n.children, depth + 1); } }; walk(nodes, 0); return options; } export default function ManualAdminPage() { const [hydrated, setHydrated] = useState(false); const [keyword, setKeyword] = useState(''); const [searchKw, setSearchKw] = useState(''); const [open, setOpen] = useState(false); const [editId, setEditId] = useState(null); const [saving, setSaving] = useState(false); const [contentMode, setContentMode] = useState('rich'); const [form, setForm] = useState(FORM_DEFAULT); const isSuperAdmin = useAdminStore((s) => s.admin?.role) === 'super_admin'; useEffect(() => setHydrated(true), []); const { data: tree, isLoading, mutate } = useSWR( hydrated ? '/admin/manual/tree' : null, () => adminApi.manualTree(), ); const rows = useMemo(() => { const flat = flattenTree(tree ?? []); if (!searchKw) return flat; return flat.filter((r) => r.title.toLowerCase().includes(searchKw.toLowerCase())); }, [tree, searchKw]); const parentOptions = useMemo( () => buildParentOptions(tree ?? [], editId), [tree, editId], ); const openCreate = (presetParentId?: number | null) => { setEditId(null); setContentMode('rich'); setForm({ ...FORM_DEFAULT, parentId: presetParentId ?? null }); setOpen(true); }; const openEdit = async (id: number) => { const detail = await adminApi.manualDetail(id); setEditId(id); // 优先使用持久化的 contentFormat;旧数据无该字段时回退到内容探测 const detected = detail.contentFormat === 'markdown' ? 'markdown' : detail.content && isHTMLContent(detail.content) ? 'rich' : 'markdown'; setContentMode(detected); setForm({ parentId: detail.parentId, title: detail.title, type: detail.type, content: detail.content ?? '', sort: detail.sort, isShow: detail.isShow, }); setOpen(true); }; const onSave = async () => { if (!form.title.trim()) { alert('请填写标题'); return; } setSaving(true); try { const contentFormat: ManualContentFormat = form.type === 0 || contentMode !== 'markdown' ? 'html' : 'markdown'; const payload: Partial = { parentId: form.parentId, title: form.title.trim(), type: form.type, content: form.type === 0 ? null : form.content, contentFormat, sort: form.sort, isShow: form.isShow, }; if (editId) { await adminApi.manualUpdate(editId, payload); } else { await adminApi.manualCreate(payload); } setOpen(false); await mutate(); } catch (e) { alert((e as Error).message); } finally { setSaving(false); } }; const onDelete = async (id: number) => { if (!confirm('确认删除该节点?若其下有子节点需先删除子节点。')) return; try { await adminApi.manualDelete(id); await mutate(); } catch (e) { alert((e as Error).message); } }; return (
setKeyword(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') setSearchKw(keyword); }} className="max-w-xs" /> } right={ } />
ID 标题 / 层级 类型 状态 操作 {isLoading && (
加载中…
)} {!isLoading && rows.length === 0 && ( 暂无数据,请新增节点 )} {rows.map((r) => ( {r.id}
{/* 缩进引导线:每层级一条垂直线 */} {Array.from({ length: r._depth }).map((_, i) => ( ))} {/* 节点图标:目录=琥珀色底,文档=灰色底 */} {r.type === 0 ? ( ) : ( )} {r.title}
{r.type === 0 ? ( 目录 ) : ( 文档 )} {r.isShow === 1 ? ( 显示 ) : ( 隐藏 )}
{r.type === 0 && ( )} {isSuperAdmin && ( )}
))}
{/* 新增 / 编辑节点 */} {editId ? '编辑节点' : '新增节点'}
setForm({ ...form, title: e.target.value })} placeholder={form.type === 0 ? '目录名称' : '文档标题'} />
{form.type === 1 && (
{contentMode === 'markdown' && ( setForm({ ...form, content: md })} onError={(msg) => alert(msg)} /> )}
{contentMode === 'rich' ? ( setForm({ ...form, content: html })} /> ) : (