Files
dusuniot/src/routes/admin.products.$id.tsx
T

238 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* # 路由 /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>
);
}