238 lines
10 KiB
TypeScript
238 lines
10 KiB
TypeScript
/**
|
||
* # 路由 /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>
|
||
);
|
||
}
|