v0.11.0 后台 CRM 化:媒体上传/单页面 Elementor/修订/分类标签/菜单/用户/审计/设置/Dashboard v2 + dusuniot-cms MCP 服务器
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user