2026-06-22 14:43:46 +08:00

512 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

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