Files
dusuniot/src/routes/admin.contents.$id.tsx
T

313 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
/**
* # 路由 /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>
);
}