512 lines
17 KiB
TypeScript
512 lines
17 KiB
TypeScript
|
|
'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>
|
|||
|
|
);
|
|||
|
|
}
|