/** * # 路由 /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 => { 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; 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([]); /** # 成员变量 已选分类/标签(仅编辑态展示,新建暂不关联) **/ const [selectedTermIds, setSelectedTermIds] = useState(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 ; // 收窄后的非空引用(闭包内使用) const content = data; /** # 函数 保存内容:提交到服务端 → 提示 → 返回列表 */ async function handleSave(values: Omit) { 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: (

{rev.title}

            {rev.html.slice(0, 5000) || "(空正文)"}
            {rev.html.length > 5000 ? "\n…(截断预览)" : ""}
          
), 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 (

{content.id ? `编辑内容 #${content.id}` : "新建内容"}

{content.id ? ( <> {content.status === "published" && content.slug ? ( ) : null} ) : null}
void handleSave(v as Omit)}> {content.id ? ( setSelectedTermIds((v as number[]) ?? [])} treeCheckable showCheckedStrategy={TreeSelect.SHOW_CHILD} maxTagCount={4} placeholder="选择分类与标签" style={{ width: 320 }} /> ) : null} setRevOpen(false)} footer={null} width={720} > fmtDateTime(v) }, { title: "操作", key: "act", width: 100, render: (_, r) => ( ), }, ]} /> ); }