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>
|
||
);
|
||
}
|