v0.11.0 后台 CRM 化:媒体上传/单页面 Elementor/修订/分类标签/菜单/用户/审计/设置/Dashboard v2 + dusuniot-cms MCP 服务器

This commit is contained in:
bigcode
2026-11-04 06:10:04 +08:00
parent 2251a25e12
commit b16c33c08b
15 changed files with 2053 additions and 152 deletions
+197 -32
View File
@@ -1,12 +1,17 @@
/**
* # 路由 /admin/contents/$id 内容编辑页
* id 为 new 时新建;否则加载正本(默认语言)编辑。HTML 使用 TextArea 明文编辑。
* # 路由 /admin/contents/$id 内容编辑页(自定义单页面/文章/落地页)
* id 为 new 时新建;否则加载正本编辑。支持:
* - 分类/标签绑定(content_terms,对应 WP 分类目录与标签)
* - Elementor JSON 编辑(meta.elementor,前台 catch-all 用 ElementorRenderer 渲染)
* - 修订历史弹窗(保存自动留档,可查看/恢复)
* - 已发布内容直达前台链接(预览)
*/
import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router";
import { App, Button, Card, Form, Input, Select, Space } from "antd";
import { ArrowLeftOutlined, SaveOutlined } from "@ant-design/icons";
import { adminGetContent, adminSaveContent } from "~/server/admin";
import { CONTENT_STATUS_OPTIONS, Forbidden, guardAdmin } from "~/components/admin/common";
import { App, Button, Card, Form, Input, Modal, Select, Space, Table, TreeSelect } from "antd";
import { ArrowLeft, ExternalLink, History, Save } from "lucide-react";
import { useEffect, useState } from "react";
import { adminGetContent, adminGetRevision, adminListRevisions, adminRestoreRevision, adminSaveContent } from "~/server/admin";
import { CONTENT_STATUS_OPTIONS, Forbidden, fmtDateTime, guardAdmin } from "~/components/admin/common";
/** # 类型 编辑表单数据形状(新建时给默认值) **/
type ContentForm = {
@@ -20,15 +25,25 @@ type ContentForm = {
template: string;
seoTitle: string;
seoDescription: string;
elementorJson: string;
};
/** # 类型 修订列表行(弹窗表格) **/
type RevisionRow = { id: number; title: string; authorId: number | null; createdAt: string | Date };
/** # 类型 分类/标签选项(编辑页回显用) **/
type TermOption = { id: number; name: string; taxonomy: string };
/** # 类型 编辑页 loader 数据(新建默认值或正本 + 术语选项) **/
type EditorData = (ContentForm & { selectedCategoryIds: number[]; termOptions: TermOption[] }) | null;
export const Route = createFileRoute("/admin/contents/$id")({
loader: ({ params }) =>
guardAdmin(async () => {
guardAdmin(async (): Promise<EditorData> => {
if (params.id === "new") {
// 权限探测 + 返回新建默认值
await adminGetContent({ data: { id: 0 } });
const fresh: ContentForm = {
return {
id: 0,
type: "page",
slug: "",
@@ -39,12 +54,15 @@ export const Route = createFileRoute("/admin/contents/$id")({
template: "default",
seoTitle: "",
seoDescription: "",
elementorJson: "",
selectedCategoryIds: [],
termOptions: [],
};
return fresh;
}
const c = await adminGetContent({ data: { id: Number(params.id) } });
if (!c) return null;
const form: ContentForm = {
const meta = (c.meta ?? {}) as Record<string, unknown>;
return {
id: c.id,
type: c.type,
slug: c.slug,
@@ -55,8 +73,10 @@ export const Route = createFileRoute("/admin/contents/$id")({
template: c.template,
seoTitle: c.seoTitle ?? "",
seoDescription: c.seoDescription ?? "",
elementorJson: meta.elementor ? JSON.stringify(meta.elementor, null, 2) : "",
selectedCategoryIds: (c as { selectedCategoryIds?: number[] }).selectedCategoryIds ?? [],
termOptions: (c as { termOptions?: TermOption[] }).termOptions ?? [],
};
return form;
}),
component: ContentEditPage,
});
@@ -66,8 +86,31 @@ function ContentEditPage() {
const data = Route.useLoaderData();
const navigate = useNavigate();
const router = useRouter();
const { message } = App.useApp();
const { message, modal } = App.useApp();
const [form] = Form.useForm();
/** # 成员变量 修订历史弹窗开关与数据 **/
const [revOpen, setRevOpen] = useState(false);
const [revisions, setRevisions] = useState<RevisionRow[]>([]);
/** # 成员变量 已选分类/标签(仅编辑态展示,新建暂不关联) **/
const [selectedTermIds, setSelectedTermIds] = useState<number[]>(data?.selectedCategoryIds ?? []);
// 表单初值与术语回显随 loader 数据同步(hooks 须在 early return 之前调用)
useEffect(() => {
if (!data) return;
setSelectedTermIds(data.selectedCategoryIds ?? []);
form.setFieldsValue({
title: data.title,
slug: data.slug,
type: data.type,
status: data.status,
template: data.template,
excerpt: data.excerpt,
html: data.html,
seoTitle: data.seoTitle,
seoDescription: data.seoDescription,
elementorJson: data.elementorJson,
});
}, [data, form]);
if (!data) return <Forbidden />;
// 收窄后的非空引用(闭包内使用)
@@ -76,8 +119,14 @@ function ContentEditPage() {
/** # 函数 保存内容:提交到服务端 → 提示 → 返回列表 */
async function handleSave(values: Omit<ContentForm, "id">) {
try {
await adminSaveContent({ data: { ...values, id: content.id || undefined } });
message.success("保存成功");
await adminSaveContent({
data: {
...values,
id: content.id || undefined,
categoryIds: selectedTermIds,
},
});
message.success("保存成功(已留修订快照)");
await router.invalidate();
void navigate({ to: "/admin/contents/" });
} catch (e) {
@@ -85,33 +134,90 @@ function ContentEditPage() {
}
}
/** # 函数 打开修订历史弹窗并加载列表 */
async function openRevisions() {
const rows = await adminListRevisions({ data: { contentId: content.id } });
setRevisions(rows as RevisionRow[]);
setRevOpen(true);
}
/** # 函数 查看修订正文并确认恢复 */
async function viewRevision(row: RevisionRow) {
const rev = await adminGetRevision({ data: { id: row.id } });
if (!rev) return;
modal.confirm({
title: `恢复修订 #${row.id}(${fmtDateTime(row.createdAt)})`,
width: 760,
content: (
<div style={{ maxHeight: 420, overflow: "auto" }}>
<p style={{ fontWeight: 600 }}>{rev.title}</p>
<pre style={{ whiteSpace: "pre-wrap", fontSize: 12, maxHeight: 340, overflow: "auto", background: "#fafafa", padding: 8 }}>
{rev.html.slice(0, 5000) || "(空正文)"}
{rev.html.length > 5000 ? "\n…(截断预览)" : ""}
</pre>
</div>
),
okText: "恢复此版本",
cancelText: "取消",
onOk: async () => {
await adminRestoreRevision({ data: { id: row.id } });
message.success("已恢复(恢复前版本已再留档)");
setRevOpen(false);
await router.invalidate();
void navigate({ to: "/admin/contents/$id/", params: { id: String(content.id) } }).then(() => window.location.reload());
},
});
}
/** # 成员变量 分类/标签树数据源(taxonomy 分组) **/
const termTreeData = [
{
title: "分类目录",
value: "__cat__",
selectable: false,
children: content.termOptions.filter((t) => t.taxonomy === "category").map((t) => ({ title: t.name, value: t.id })),
},
{
title: "标签",
value: "__tag__",
selectable: false,
children: content.termOptions.filter((t) => t.taxonomy === "post_tag").map((t) => ({ title: t.name, value: t.id })),
},
];
return (
<Card size="small">
<Space style={{ marginBottom: 16 }}>
<Button icon={<ArrowLeftOutlined aria-hidden />} onClick={() => void navigate({ to: "/admin/contents/" })}>
<Button icon={<ArrowLeft size={14} aria-hidden />} onClick={() => void navigate({ to: "/admin/contents/" })}>
返回列表
</Button>
<h2 style={{ margin: 0, fontSize: 18 }}>{content.id ? `编辑内容 #${content.id}` : "新建内容"}</h2>
{content.id ? (
<>
<Button icon={<History size={14} aria-hidden />} onClick={() => void openRevisions()}>
修订历史
</Button>
{content.status === "published" && content.slug ? (
<Button icon={<ExternalLink size={14} aria-hidden />} onClick={() => window.open(`/${content.slug}/`, "_blank")}>
前台预览
</Button>
) : null}
</>
) : null}
</Space>
<Form
form={form}
layout="vertical"
initialValues={content}
style={{ maxWidth: 860 }}
onFinish={(v) => void handleSave(v)}
>
<Form form={form} layout="vertical" style={{ maxWidth: 960 }} onFinish={(v) => void handleSave(v as Omit<ContentForm, "id">)}>
<Space size={16} style={{ display: "flex" }} align="start">
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]} style={{ minWidth: 420, flex: 1 }}>
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]} style={{ minWidth: 380, flex: 1 }}>
<Input placeholder="内容标题" />
</Form.Item>
<Form.Item name="slug" label="别名(留空自动生成)">
<Input placeholder="url-slug" style={{ width: 260 }} />
<Form.Item name="slug" label="别名(留空自动生成;即前台 URL /slug/)">
<Input placeholder="url-slug" style={{ width: 280 }} />
</Form.Item>
</Space>
<Space size={16} style={{ display: "flex" }}>
<Form.Item name="type" label="类型">
<Select
style={{ width: 140 }}
style={{ width: 150 }}
options={[
{ value: "page", label: "页面 page" },
{ value: "post", label: "文章 post" },
@@ -123,25 +229,84 @@ function ContentEditPage() {
<Select style={{ width: 140 }} options={CONTENT_STATUS_OPTIONS} />
</Form.Item>
<Form.Item name="template" label="模板">
<Input style={{ width: 200 }} placeholder="default / home / product …" />
<Input style={{ width: 200 }} placeholder="default / home / blank …" />
</Form.Item>
{content.id ? (
<Form.Item label="分类 / 标签">
<TreeSelect
treeData={termTreeData}
value={selectedTermIds}
onChange={(v) => setSelectedTermIds((v as number[]) ?? [])}
treeCheckable
showCheckedStrategy={TreeSelect.SHOW_CHILD}
maxTagCount={4}
placeholder="选择分类与标签"
style={{ width: 320 }}
/>
</Form.Item>
) : null}
</Space>
<Form.Item name="excerpt" label="摘要">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name="html" label="正文 HTML">
<Input.TextArea rows={18} style={{ fontFamily: "monospace", fontSize: 12 }} />
<Form.Item
name="html"
label="正文 HTML(无 Elementor JSON 时前台按此渲染)"
tooltip="自定义单页面推荐:留空 HTML、填写 Elementor JSON,前台通栏渲染与原站一致"
>
<Input.TextArea rows={12} style={{ fontFamily: "monospace", fontSize: 12 }} />
</Form.Item>
<Form.Item name="seoTitle" label="SEO 标题">
<Input />
<Form.Item
name="elementorJson"
label="Elementor JSON(meta.elementor,优先于 HTML 渲染)"
tooltip='结构:{ "elements": [ ... ] } 或元素数组;可从原站 _elementor_data 复制'
>
<Input.TextArea rows={10} style={{ fontFamily: "monospace", fontSize: 12 }} placeholder='{"elements": [...]}' />
</Form.Item>
<Space size={16} style={{ display: "flex" }}>
<Form.Item name="seoTitle" label="SEO 标题" style={{ minWidth: 420, flex: 1 }}>
<Input />
</Form.Item>
</Space>
<Form.Item name="seoDescription" label="SEO 描述">
<Input.TextArea rows={2} />
</Form.Item>
<Button type="primary" htmlType="submit" icon={<SaveOutlined aria-hidden />}>
<Button type="primary" htmlType="submit" icon={<Save size={14} aria-hidden />}>
保存
</Button>
</Form>
<Modal
title="修订历史(每次保存自动留档)"
open={revOpen}
onCancel={() => setRevOpen(false)}
footer={null}
width={720}
>
<Table
rowKey="id"
size="small"
dataSource={revisions}
pagination={{ pageSize: 8 }}
locale={{ emptyText: "暂无修订" }}
columns={[
{ title: "修订 ID", dataIndex: "id", width: 90 },
{ title: "标题快照", dataIndex: "title", ellipsis: true },
{ title: "修订人", dataIndex: "authorId", width: 90 },
{ title: "时间", dataIndex: "createdAt", width: 160, render: (v: string) => fmtDateTime(v) },
{
title: "操作",
key: "act",
width: 100,
render: (_, r) => (
<Button size="small" onClick={() => void viewRevision(r)}>
查看 / 恢复
</Button>
),
},
]}
/>
</Modal>
</Card>
);
}