v0.10.5 首页 H2 产品线入口 ×6:ImageBoxWidget 重构为 Elementor 忠实 DOM + slim 白名单补齐 + H2 scoped CSS(post-18 对齐,桌面/移动/hover 实测一致)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user