512 lines
17 KiB
TypeScript
Raw Permalink Normal View History

2026-06-22 14:43:46 +08:00
'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<number | null>(null);
const [saving, setSaving] = useState(false);
const [contentMode, setContentMode] = useState<ContentMode>('rich');
const [form, setForm] = useState<FormState>(FORM_DEFAULT);
const isSuperAdmin = useAdminStore((s) => s.admin?.role) === 'super_admin';
useEffect(() => setHydrated(true), []);
const { data: tree, isLoading, mutate } = useSWR<ManualTreeNode[]>(
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<Manual> = {
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 (
<div>
<AdminHeader
title="使用手册"
description="维护多级树形使用手册(目录 + 文档,文档支持富文本/Markdown"
/>
<TableToolbar
left={
<>
<Input
placeholder="搜索标题"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') setSearchKw(keyword);
}}
className="max-w-xs"
/>
<Button variant="outline" onClick={() => setSearchKw(keyword)}>
</Button>
</>
}
right={
<Button onClick={() => openCreate(null)}>
<Plus className="h-4 w-4" />
</Button>
}
/>
<div className="mt-4 rounded-md border border-gray-200">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead style={{ width: 60 }}>ID</TableHead>
<TableHead> / </TableHead>
<TableHead style={{ width: 100 }}></TableHead>
<TableHead style={{ width: 80 }}></TableHead>
<TableHead style={{ width: 260 }}></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading && (
<TableRow>
<TableCell colSpan={5} className="h-32">
<div className="flex items-center justify-center gap-2 text-gray-400">
<Loader2 className="h-5 w-5 animate-spin" />
<span className="text-sm"></span>
</div>
</TableCell>
</TableRow>
)}
{!isLoading && rows.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="h-32 text-center text-gray-400">
</TableCell>
</TableRow>
)}
{rows.map((r) => (
<TableRow key={r.id}>
<TableCell className="text-gray-500">{r.id}</TableCell>
<TableCell>
<div className="flex items-center">
{/* 缩进引导线:每层级一条垂直线 */}
{Array.from({ length: r._depth }).map((_, i) => (
<span key={i} className="flex w-6 justify-center">
<span className="h-6 w-px bg-gray-200" />
</span>
))}
{/* 节点图标:目录=琥珀色底,文档=灰色底 */}
<span
className={cn(
'flex h-7 w-7 shrink-0 items-center justify-center rounded-md',
r.type === 0 ? 'bg-amber-50' : 'bg-slate-50',
)}
>
{r.type === 0 ? (
<Folder className="h-4 w-4 text-amber-500" />
) : (
<FileText className="h-4 w-4 text-slate-400" />
)}
</span>
<span
className={cn(
'ml-2',
r.type === 0
? 'font-semibold text-gray-900'
: 'font-medium text-gray-600',
)}
>
{r.title}
</span>
</div>
</TableCell>
<TableCell>
{r.type === 0 ? (
<Badge variant="warning"></Badge>
) : (
<Badge variant="secondary"></Badge>
)}
</TableCell>
<TableCell>
{r.isShow === 1 ? (
<Badge variant="success"></Badge>
) : (
<Badge variant="outline"></Badge>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5 whitespace-nowrap">
{r.type === 0 && (
<Button
size="sm"
variant="outline"
onClick={() => openCreate(r.id)}
>
<Plus className="h-3 w-3" />
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => openEdit(r.id)}
>
<Pencil className="h-3 w-3" />
</Button>
{isSuperAdmin && (
<Button
size="sm"
variant="destructive"
onClick={() => onDelete(r.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* 新增 / 编辑节点 */}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{editId ? '编辑节点' : '新增节点'}</DialogTitle>
</DialogHeader>
<div className="max-h-[70vh] space-y-4 overflow-y-auto pr-2">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label> *</Label>
<div className="flex items-center gap-4 pt-1">
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="node-type"
checked={form.type === 0}
onChange={() => setForm({ ...form, type: 0 })}
/>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="node-type"
checked={form.type === 1}
onChange={() => setForm({ ...form, type: 1 })}
/>
</label>
</div>
</div>
<div className="space-y-1.5">
<Label></Label>
<select
className="h-10 w-full rounded-md border border-gray-300 px-3 text-sm"
value={form.parentId ?? ''}
onChange={(e) =>
setForm({
...form,
parentId: e.target.value ? Number(e.target.value) : null,
})
}
>
<option value=""></option>
{parentOptions.map((opt) => (
<option key={opt.id} value={opt.id}>
{`${' '.repeat(opt.depth)}${opt.depth > 0 ? '└ ' : ''}${opt.label}`}
</option>
))}
</select>
</div>
</div>
<div className="space-y-1.5">
<Label> *</Label>
<Input
value={form.title}
maxLength={200}
onChange={(e) => setForm({ ...form, title: e.target.value })}
placeholder={form.type === 0 ? '目录名称' : '文档标题'}
/>
</div>
{form.type === 1 && (
<div className="space-y-1.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label></Label>
<div className="flex items-center gap-2">
<div className="flex rounded-md border border-gray-200 p-0.5">
<button
type="button"
onClick={() => setContentMode('rich')}
className={cn(
'rounded px-2.5 py-0.5 text-xs transition-colors',
contentMode === 'rich'
? 'bg-brand-600 text-white'
: 'text-gray-600 hover:text-gray-900',
)}
>
</button>
<button
type="button"
onClick={() => setContentMode('markdown')}
className={cn(
'rounded px-2.5 py-0.5 text-xs transition-colors',
contentMode === 'markdown'
? 'bg-brand-600 text-white'
: 'text-gray-600 hover:text-gray-900',
)}
>
Markdown
</button>
</div>
{contentMode === 'markdown' && (
<MarkdownImport
onImport={(md) => setForm({ ...form, content: md })}
onError={(msg) => alert(msg)}
/>
)}
</div>
</div>
{contentMode === 'rich' ? (
<RichEditor
value={form.content}
onChange={(html) => setForm({ ...form, content: html })}
/>
) : (
<Textarea
rows={12}
placeholder="支持 Markdown 语法。可点击右上角上传 .md 文档"
className="font-mono text-sm"
value={form.content}
onChange={(e) =>
setForm({ ...form, content: e.target.value })
}
/>
)}
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label></Label>
<Input
type="number"
value={form.sort}
onChange={(e) =>
setForm({ ...form, sort: Number(e.target.value) })
}
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<select
className="h-10 w-full rounded-md border border-gray-300 px-3 text-sm"
value={form.isShow}
onChange={(e) =>
setForm({ ...form, isShow: Number(e.target.value) })
}
>
<option value={1}></option>
<option value={0}></option>
</select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
</Button>
<Button onClick={onSave} disabled={saving}>
{saving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
</>
) : (
'保存'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}