313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
/**
|
||
* # 路由 /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, 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 = {
|
||
id: number;
|
||
type: string;
|
||
slug: string;
|
||
title: string;
|
||
excerpt: string;
|
||
html: string;
|
||
status: string;
|
||
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 (): Promise<EditorData> => {
|
||
if (params.id === "new") {
|
||
// 权限探测 + 返回新建默认值
|
||
await adminGetContent({ data: { id: 0 } });
|
||
return {
|
||
id: 0,
|
||
type: "page",
|
||
slug: "",
|
||
title: "",
|
||
excerpt: "",
|
||
html: "",
|
||
status: "draft",
|
||
template: "default",
|
||
seoTitle: "",
|
||
seoDescription: "",
|
||
elementorJson: "",
|
||
selectedCategoryIds: [],
|
||
termOptions: [],
|
||
};
|
||
}
|
||
const c = await adminGetContent({ data: { id: Number(params.id) } });
|
||
if (!c) return null;
|
||
const meta = (c.meta ?? {}) as Record<string, unknown>;
|
||
return {
|
||
id: c.id,
|
||
type: c.type,
|
||
slug: c.slug,
|
||
title: c.title,
|
||
excerpt: c.excerpt,
|
||
html: c.html,
|
||
status: c.status,
|
||
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 ?? [],
|
||
};
|
||
}),
|
||
component: ContentEditPage,
|
||
});
|
||
|
||
/** # 函数 内容编辑页面组件 */
|
||
function ContentEditPage() {
|
||
const data = Route.useLoaderData();
|
||
const navigate = useNavigate();
|
||
const router = useRouter();
|
||
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 />;
|
||
// 收窄后的非空引用(闭包内使用)
|
||
const content = data;
|
||
|
||
/** # 函数 保存内容:提交到服务端 → 提示 → 返回列表 */
|
||
async function handleSave(values: Omit<ContentForm, "id">) {
|
||
try {
|
||
await adminSaveContent({
|
||
data: {
|
||
...values,
|
||
id: content.id || undefined,
|
||
categoryIds: selectedTermIds,
|
||
},
|
||
});
|
||
message.success("保存成功(已留修订快照)");
|
||
await router.invalidate();
|
||
void navigate({ to: "/admin/contents/" });
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : "保存失败");
|
||
}
|
||
}
|
||
|
||
/** # 函数 打开修订历史弹窗并加载列表 */
|
||
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={<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" 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: 380, flex: 1 }}>
|
||
<Input placeholder="内容标题" />
|
||
</Form.Item>
|
||
<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: 150 }}
|
||
options={[
|
||
{ value: "page", label: "页面 page" },
|
||
{ value: "post", label: "文章 post" },
|
||
{ value: "landing", label: "落地页 landing" },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="status" label="状态">
|
||
<Select style={{ width: 140 }} options={CONTENT_STATUS_OPTIONS} />
|
||
</Form.Item>
|
||
<Form.Item name="template" label="模板">
|
||
<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(无 Elementor JSON 时前台按此渲染)"
|
||
tooltip="自定义单页面推荐:留空 HTML、填写 Elementor JSON,前台通栏渲染与原站一致"
|
||
>
|
||
<Input.TextArea rows={12} style={{ fontFamily: "monospace", fontSize: 12 }} />
|
||
</Form.Item>
|
||
<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={<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>
|
||
);
|
||
}
|