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
+237
View File
@@ -0,0 +1,237 @@
/**
* # 路由 /admin/products/$id 产品编辑页
* 基础字段 + 特色图/图集媒体选择(远程搜索媒体库)+ 属性值多选(保存时重建
* product_attribute_map)+ 产品分类多选(product_terms)。id 为 new 时新建。
*/
import { createFileRoute, useNavigate, useRouter } from "@tanstack/react-router";
import { useState } from "react";
import { App, Button, Card, Form, Input, InputNumber, Select, Space, Switch } from "antd";
import { ArrowLeftOutlined, SaveOutlined } from "@ant-design/icons";
import { adminGetProduct, adminSaveProduct, adminSearchMedia } from "~/server/admin";
import { Forbidden, guardAdmin } from "~/components/admin/common";
export const Route = createFileRoute("/admin/products/$id")({
loader: ({ params }) =>
guardAdmin(async () => {
// new → id 0(同时作为权限探测,未登录抛 FORBIDDEN)
const id = params.id === "new" ? 0 : Number(params.id);
const [data, mediaOptions] = await Promise.all([adminGetProduct({ data: { id } }), adminSearchMedia({ data: { limit: 30 } })]);
return { ...data, mediaOptions };
}),
component: ProductEditPage,
});
/** # 类型 媒体选择器选项 **/
type MediaOption = { id: number; path: string; url: string };
/** # 函数 文件路径转显示名(取 basename) */
function mediaLabel(path: string): string {
return path.split("/").pop() || path;
}
/** # 组件 媒体选择器(AntD Select 远程搜索媒体库,支持单选/多选)
* 参数 options:初始选项;multiple:是否多选;value/onChange 受控由 Form.Item 接管
*/
function MediaSelect(props: { options: MediaOption[]; multiple?: boolean; placeholder?: string }) {
const [opts, setOpts] = useState<MediaOption[]>(props.options);
/** # 函数 远程搜索媒体(防抖 300ms) */
function handleSearch(q: string) {
window.clearTimeout((handleSearch as unknown as { _t?: number })._t);
(handleSearch as unknown as { _t?: number })._t = window.setTimeout(() => {
adminSearchMedia({ data: { q: q || undefined, limit: 30 } })
.then((rows) => setOpts(rows))
.catch(() => {});
}, 300);
}
return (
<Select
mode={props.multiple ? "multiple" : undefined}
allowClear
showSearch
placeholder={props.placeholder || "搜索媒体文件名"}
onSearch={handleSearch}
optionFilterProp="label"
style={{ maxWidth: 640, width: "100%" }}
options={opts.map((m) => ({ value: m.id, label: mediaLabel(m.path) }))}
/>
);
}
/** # 函数 产品编辑页面组件 */
function ProductEditPage() {
const data = Route.useLoaderData();
const navigate = useNavigate();
const router = useRouter();
const { message } = App.useApp();
const [form] = Form.useForm();
if (!data) return <Forbidden />;
const { product, attributes, categories, selectedValueIds, selectedCategoryIds, selectedGalleryIds, initialGalleryOptions, mediaOptions } = data;
/** # 成员变量 表单初始值:基础字段 + 每个属性的已选值数组 **/
const initialValues = {
title: product?.title ?? "",
slug: product?.slug ?? "",
sku: product?.sku ?? "",
price: product?.price ?? "",
regularPrice: product?.regularPrice ?? "",
stock: product?.stock ?? null,
stockStatus: product?.stockStatus ?? "instock",
status: product?.status ?? "draft",
featured: product?.featured ?? false,
shortDescription: product?.shortDescription ?? "",
description: product?.description ?? "",
specUrl: product?.specUrl ?? "",
seoTitle: product?.seoTitle ?? "",
seoDescription: product?.seoDescription ?? "",
featuredMediaId: product?.featuredMediaId ?? null,
galleryIds: selectedGalleryIds,
categoryIds: selectedCategoryIds,
...Object.fromEntries(
attributes.map((a) => [`attr_${a.id}`, selectedValueIds.filter((v) => a.values.some((val) => val.id === v))]),
),
};
/** # 函数 保存产品:提交字段 + 属性值/分类,重建关联表 */
async function handleSave(values: Record<string, unknown>) {
const valueIds = attributes.flatMap((a) => ((values[`attr_${a.id}`] as number[] | undefined) ?? []));
try {
await adminSaveProduct({
data: {
id: product?.id,
sku: (values.sku as string) || null,
slug: values.slug as string,
title: values.title as string,
shortDescription: (values.shortDescription as string) ?? "",
description: (values.description as string) ?? "",
price: (values.price as string) || null,
regularPrice: (values.regularPrice as string) || null,
stock: (values.stock as number | null) ?? null,
stockStatus: (values.stockStatus as string) ?? "instock",
status: (values.status as string) ?? "draft",
featured: (values.featured as boolean) ?? false,
specUrl: (values.specUrl as string) || null,
seoTitle: (values.seoTitle as string) || null,
seoDescription: (values.seoDescription as string) || null,
featuredMediaId: (values.featuredMediaId as number | null) ?? null,
galleryIds: (values.galleryIds as number[]) ?? [],
valueIds,
categoryIds: (values.categoryIds as number[]) ?? [],
},
});
message.success("保存成功");
await router.invalidate();
void navigate({ to: "/admin/products/" });
} 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/products/" })}>
返回列表
</Button>
<h2 style={{ margin: 0, fontSize: 18 }}>{product ? `编辑产品 #${product.id}` : "新建产品"}</h2>
</Space>
<Form form={form} layout="vertical" style={{ maxWidth: 900 }} initialValues={initialValues} 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 />
</Form.Item>
<Form.Item name="slug" label="别名(留空自动生成)">
<Input style={{ width: 240 }} />
</Form.Item>
<Form.Item name="sku" label="SKU">
<Input style={{ width: 160 }} />
</Form.Item>
</Space>
<Space size={16} style={{ display: "flex" }} wrap>
<Form.Item name="price" label="现价(USD)">
<Input style={{ width: 120 }} placeholder="0.00" />
</Form.Item>
<Form.Item name="regularPrice" label="原价(USD)">
<Input style={{ width: 120 }} placeholder="0.00" />
</Form.Item>
<Form.Item name="stock" label="库存数量">
<InputNumber min={0} style={{ width: 120 }} placeholder="留空不限" />
</Form.Item>
<Form.Item name="stockStatus" label="库存状态">
<Select
style={{ width: 130 }}
options={[
{ value: "instock", label: "有货" },
{ value: "outofstock", label: "缺货" },
{ value: "onbackorder", label: "预订" },
]}
/>
</Form.Item>
<Form.Item name="status" label="状态">
<Select
style={{ width: 130 }}
options={[
{ value: "draft", label: "草稿" },
{ value: "published", label: "已发布" },
]}
/>
</Form.Item>
<Form.Item name="featured" label="主推产品" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="specUrl" label="规格书地址">
<Input style={{ width: 240 }} />
</Form.Item>
</Space>
<Space size={16} style={{ display: "flex" }} wrap>
<Form.Item name="featuredMediaId" label="特色图" style={{ minWidth: 300 }}>
<MediaSelect options={mediaOptions} placeholder="搜索媒体文件名选择特色图" />
</Form.Item>
<Form.Item name="galleryIds" label="图集(可多选)" style={{ minWidth: 300 }}>
<MediaSelect options={[...initialGalleryOptions, ...mediaOptions.filter((m) => !initialGalleryOptions.some((g) => g.id === m.id))]} multiple />
</Form.Item>
</Space>
<Form.Item name="shortDescription" label="短描述">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name="description" label="详细描述 HTML">
<Input.TextArea rows={14} style={{ fontFamily: "monospace", fontSize: 12 }} />
</Form.Item>
{attributes.length > 0 && (
<Card type="inner" title="产品属性" size="small" style={{ marginBottom: 16 }}>
{attributes.map((a) => (
<Form.Item
key={a.id}
name={`attr_${a.id}`}
label={a.name}
style={{ marginBottom: 12 }}
>
<Select
mode="multiple"
allowClear
placeholder={`选择 ${a.name} 属性值`}
style={{ maxWidth: 640 }}
options={a.values.map((v) => ({ value: v.id, label: v.value }))}
/>
</Form.Item>
))}
</Card>
)}
<Form.Item name="categoryIds" label="产品分类">
<Select mode="multiple" allowClear placeholder="选择分类" style={{ maxWidth: 640 }} options={categories.map((c) => ({ value: c.id, label: c.name }))} />
</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>
);
}