v0.10.5 首页 H2 产品线入口 ×6:ImageBoxWidget 重构为 Elementor 忠实 DOM + slim 白名单补齐 + H2 scoped CSS(post-18 对齐,桌面/移动/hover 实测一致)

This commit is contained in:
BigCode
2026-11-01 00:15:29 +08:00
commit 5b83e71ad9
128 changed files with 20008 additions and 0 deletions
+147
View File
@@ -0,0 +1,147 @@
/**
* # 路由 /admin/contents/$id 内容编辑页
* id 为 new 时新建;否则加载正本(默认语言)编辑。HTML 使用 TextArea 明文编辑。
*/
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";
/** # 类型 编辑表单数据形状(新建时给默认值) **/
type ContentForm = {
id: number;
type: string;
slug: string;
title: string;
excerpt: string;
html: string;
status: string;
template: string;
seoTitle: string;
seoDescription: string;
};
export const Route = createFileRoute("/admin/contents/$id")({
loader: ({ params }) =>
guardAdmin(async () => {
if (params.id === "new") {
// 权限探测 + 返回新建默认值
await adminGetContent({ data: { id: 0 } });
const fresh: ContentForm = {
id: 0,
type: "page",
slug: "",
title: "",
excerpt: "",
html: "",
status: "draft",
template: "default",
seoTitle: "",
seoDescription: "",
};
return fresh;
}
const c = await adminGetContent({ data: { id: Number(params.id) } });
if (!c) return null;
const form: ContentForm = {
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 ?? "",
};
return form;
}),
component: ContentEditPage,
});
/** # 函数 内容编辑页面组件 */
function ContentEditPage() {
const data = Route.useLoaderData();
const navigate = useNavigate();
const router = useRouter();
const { message } = App.useApp();
const [form] = Form.useForm();
if (!data) return <Forbidden />;
// 收窄后的非空引用(闭包内使用)
const content = data;
/** # 函数 保存内容:提交到服务端 → 提示 → 返回列表 */
async function handleSave(values: Omit<ContentForm, "id">) {
try {
await adminSaveContent({ data: { ...values, id: content.id || undefined } });
message.success("保存成功");
await router.invalidate();
void navigate({ to: "/admin/contents/" });
} catch (e) {
message.error(e instanceof Error ? e.message : "保存失败");
}
}
return (
<Card size="small">
<Space style={{ marginBottom: 16 }}>
<Button icon={<ArrowLeftOutlined aria-hidden />} onClick={() => void navigate({ to: "/admin/contents/" })}>
返回列表
</Button>
<h2 style={{ margin: 0, fontSize: 18 }}>{content.id ? `编辑内容 #${content.id}` : "新建内容"}</h2>
</Space>
<Form
form={form}
layout="vertical"
initialValues={content}
style={{ maxWidth: 860 }}
onFinish={(v) => void handleSave(v)}
>
<Space size={16} style={{ display: "flex" }} align="start">
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]} style={{ minWidth: 420, flex: 1 }}>
<Input placeholder="内容标题" />
</Form.Item>
<Form.Item name="slug" label="别名(留空自动生成)">
<Input placeholder="url-slug" style={{ width: 260 }} />
</Form.Item>
</Space>
<Space size={16} style={{ display: "flex" }}>
<Form.Item name="type" label="类型">
<Select
style={{ width: 140 }}
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 / product …" />
</Form.Item>
</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>
<Form.Item name="seoTitle" label="SEO 标题">
<Input />
</Form.Item>
<Form.Item name="seoDescription" label="SEO 描述">
<Input.TextArea rows={2} />
</Form.Item>
<Button type="primary" htmlType="submit" icon={<SaveOutlined aria-hidden />}>
保存
</Button>
</Form>
</Card>
);
}