Files
dukang/apps/admin-web/src/pages/ProductsPage.tsx
T
jacy f9161368e1 feat(admin): show HQ product list as SPU with expandable SKUs
Group catalog rows by product, add paid bottle sales, and keep multi-SKU fulfillment on SKU rows only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 23:52:28 +08:00

655 lines
24 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.
import { useMemo, useState } from 'react';
import {
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
Switch, Table, Tabs, Tag, Typography, message,
} from 'antd';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
import { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
import MultiImageUpload from '../components/MultiImageUpload';
import DetailImageUrlList from '../components/DetailImageUrlList';
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
import ProductSpecsEditor from '../components/ProductSpecsEditor';
import type { FormInstance } from 'antd/es/form';
type ProductDetailContentDto = {
storyTitle?: string;
storyText?: string;
features?: Array<{ icon: string; title: string; desc: string }>;
};
type SkuListRow = {
id: string;
skuCode: string;
barcode69: string;
specText: string;
price: number;
benefitAmount: number;
status: string;
isDefault?: boolean;
allowOnSitePickup?: boolean;
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
soldBottles?: number;
virtual?: boolean;
};
type Row = {
id: string;
skuCode: string;
barcode69: string;
name: string;
subtitle?: string;
aromaType: string;
spec: string;
price: number;
priceMin?: number;
priceMax?: number;
soldBottles?: number;
benefitAmount: number;
status: string;
sortOrder: number;
skuCount?: number;
specEnabled?: boolean;
skus?: SkuListRow[];
allowOnSitePickup?: boolean;
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[];
mainImageUrl?: string | null;
carouselUrls?: string[];
detailImageUrls?: string[];
detailContent?: ProductDetailContentDto | null;
createdAt: string;
};
type ProductFormValues = {
skuCode?: string;
barcode69?: string;
name: string;
subtitle?: string;
aromaType?: string;
spec: string;
price: number;
benefitAmount?: number;
status?: string;
sortOrder?: number;
allowOnSitePickup?: boolean;
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[];
coverUrl?: string;
carouselUrls?: string[];
detailImageUrls?: string[];
storyTitle?: string;
storyText?: string;
features?: Array<{ icon?: string; title?: string; desc?: string }>;
};
function mapDetailToForm(d: Record<string, unknown>) {
const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
const row = d as Row;
return {
...d,
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
carouselUrls: (row.carouselUrls?.length ? row.carouselUrls : []) as string[],
detailImageUrls: (row.detailImageUrls?.length ? row.detailImageUrls : []) as string[],
allowOnlinePurchase: row.allowOnlinePurchase !== false,
allowCrossCityDelivery: row.allowOnlinePurchase === false ? false : row.allowCrossCityDelivery !== false,
allowOnSitePickup: !!row.allowOnSitePickup,
visibilityWhitelistEnabled: !!row.visibilityWhitelistEnabled,
visibilityPhones: (row.visibilityPhones ?? []) as string[],
storyTitle: detail.storyTitle ?? '',
storyText: detail.storyText ?? '',
features: detail.features?.length
? detail.features
: [{ icon: 'water_drop', title: '', desc: '' }],
};
}
function buildProductPayload(v: ProductFormValues) {
const carouselUrls = (v.carouselUrls ?? []).map((u) => u?.trim()).filter(Boolean);
const detailImageUrls = (v.detailImageUrls ?? []).map((u) => u?.trim()).filter(Boolean);
const features = (v.features ?? [])
.filter((f) => f?.title?.trim() || f?.desc?.trim())
.map((f) => ({
icon: f.icon?.trim() || 'star',
title: f.title?.trim() || '',
desc: f.desc?.trim() || '',
}));
const detailContent: ProductDetailContentDto = {
storyTitle: v.storyTitle?.trim() || undefined,
storyText: v.storyText?.trim() || undefined,
features: features.length ? features : undefined,
};
return {
barcode69: v.barcode69,
name: v.name,
subtitle: v.subtitle,
aromaType: v.aromaType,
spec: v.spec,
price: v.price,
benefitAmount: v.benefitAmount,
status: v.status,
sortOrder: v.sortOrder,
allowOnSitePickup: !!v.allowOnSitePickup,
allowOnlinePurchase: v.allowOnlinePurchase !== false,
allowCrossCityDelivery:
v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false,
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
coverUrl: v.coverUrl,
carouselUrls,
detailImageUrls,
detailContent,
};
}
function ImageUrlList({ name, label, bizType }: { name: string; label: string; bizType: string }) {
return (
<Form.Item name={name} style={{ marginBottom: 0 }}>
<MultiImageUpload
bizType={bizType}
mediaType="IMAGE"
tip={`${label}支持一次选择多张批量上传`}
/>
</Form.Item>
);
}
function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaType?: string }) {
return (
<>
<ProductDetailTemplatePicker form={form} aromaType={aromaType} />
<Divider />
<Typography.Text type="secondary">详情页轮播(CAROUSEL,支持批量上传,单张最大 10MB</Typography.Text>
<ImageUrlList name="carouselUrls" label="轮播图" bizType="CAROUSEL" />
<Divider />
<Typography.Text type="secondary">详情长图(DETAIL,支持批量上传,单张最大 10MB</Typography.Text>
<Form.Item name="detailImageUrls" style={{ marginBottom: 0 }}>
<DetailImageUrlList label="详情图" bizType="DETAIL" />
</Form.Item>
<Divider />
<Form.Item name="storyTitle" label="故事标题">
<Input placeholder="如:千年杜康 · 唯有此处" />
</Form.Item>
<Form.Item name="storyText" label="故事正文">
<Input.TextArea rows={4} placeholder="商品故事描述" />
</Form.Item>
<Typography.Text type="secondary">卖点特色</Typography.Text>
<Form.List name="features">
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Space key={field.key} direction="vertical" style={{ display: 'flex', marginBottom: 12, width: '100%' }}>
<Space align="start">
<Form.Item {...field} name={[field.name, 'icon']} label="图标" style={{ marginBottom: 0 }}>
<Input placeholder="material icon 名" style={{ width: 140 }} />
</Form.Item>
<Form.Item {...field} name={[field.name, 'title']} label="标题" style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="标题" />
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 30 }} />
)}
</Space>
<Form.Item {...field} name={[field.name, 'desc']} label="描述" style={{ marginBottom: 0 }}>
<Input placeholder="简短描述" />
</Form.Item>
</Space>
))}
<Button type="dashed" onClick={() => add({ icon: 'star', title: '', desc: '' })} block icon={<PlusOutlined />}>
添加卖点
</Button>
</>
)}
</Form.List>
</>
);
}
function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
return (
<>
<Form.Item
name="visibilityWhitelistEnabled"
label="可见白名单"
valuePropName="checked"
extra="开启后仅全局测试白名单内手机号在 C 端可见/可购,用于在线测试"
>
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
{enabled ? (
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
可见手机号见白名单管理
</Typography.Text>
) : null}
</>
);
}
function FulfillmentTags(row: {
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
allowOnSitePickup?: boolean;
}) {
return (
<Space size={[0, 4]} wrap>
{row.allowOnlinePurchase !== false ? <Tag color="blue">线上</Tag> : null}
{row.allowOnlinePurchase !== false && row.allowCrossCityDelivery !== false ? (
<Tag color="cyan">跨城</Tag>
) : null}
{row.allowOnSitePickup ? <Tag color="green">现场</Tag> : null}
{row.allowOnlinePurchase === false && !row.allowOnSitePickup ? <Tag></Tag> : null}
</Space>
);
}
function BaseInfoFields({
mode,
form,
hideFulfillment,
}: {
mode: 'create' | 'edit';
form: FormInstance;
/** 多规格商品:履约只在规格 SKU 上编辑 */
hideFulfillment?: boolean;
}) {
return (
<>
{mode === 'create' && (
<>
<Form.Item label="SKU">
<Input disabled placeholder="保存后自动生成" />
</Form.Item>
<Form.Item
name="barcode69"
label="69码"
rules={[{ required: true, message: '请填写默认规格 69 码' }]}
extra="默认规格的 69 码;若有多种规格,保存后请到「规格与 SKU」为每个规格分别填写不同 69 码"
>
<Input placeholder="默认规格 69 码" />
</Form.Item>
<Form.Item name="aromaType" label="香型" rules={[{ required: true }]}>
<Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
</>
)}
<Form.Item name="name" label="商品名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="subtitle" label="副标题">
<Input />
</Form.Item>
<Form.Item name="spec" label="规格" rules={[{ required: true }]}>
<Input placeholder="500ml | 52度" />
</Form.Item>
<Form.Item name="price" label="售价" rules={[{ required: true }]}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="benefitAmount" label="权益额">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="sortOrder" label="排序">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
{hideFulfillment ? (
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
该商品有多种规格,履约(线上 / 跨城 / 现场)请到「规格与 SKU」中按规格设置。
</Typography.Paragraph>
) : (
<>
<Form.Item
name="allowOnlinePurchase"
label="允许线上购买"
valuePropName="checked"
extra="配送到址(同城)"
>
<Switch
checkedChildren="开"
unCheckedChildren="关"
onChange={(checked) => {
if (!checked) form.setFieldsValue({ allowCrossCityDelivery: false });
}}
/>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.allowOnlinePurchase !== cur.allowOnlinePurchase}>
{() => (
<Form.Item
name="allowCrossCityDelivery"
label="允许跨城配送"
valuePropName="checked"
extra="须先开启线上购买"
>
<Switch
checkedChildren="开"
unCheckedChildren="关"
disabled={!form.getFieldValue('allowOnlinePurchase')}
/>
</Form.Item>
)}
</Form.Item>
<Form.Item name="allowOnSitePickup" label="允许现场取货" valuePropName="checked">
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
</>
)}
<VisibilityWhitelistFields form={form} />
<Form.Item name="coverUrl" label="封面">
<OssUpload bizType="COVER" mediaType="IMAGE" />
</Form.Item>
</>
);
}
export default function ProductsPage() {
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/products',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.status) qs.set('status', filters.status);
if (filters.aromaType) qs.set('aromaType', filters.aromaType);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
async function handleDelete(row: Row) {
try {
await request(`/admin/products/${row.id}`, { method: 'DELETE' });
message.success('已删除');
if (detail?.id === row.id) {
setDrawerOpen(false);
setDetail(null);
}
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
}
}
const columns: ColumnsType<Row> = useMemo(() => [
{ title: '香型', dataIndex: 'aromaType', width: 80, render: (v) => AROMA_TYPE_LABELS[v] || v },
{ title: '品名', dataIndex: 'name', width: 200, ellipsis: true },
{
title: '累计销售',
dataIndex: 'soldBottles',
width: 110,
render: (v: number | undefined) => `累计 ${v ?? 0} 瓶`,
},
{
title: '售价',
key: 'priceRange',
width: 120,
render: (_, row) => {
const min = row.priceMin ?? row.price;
const max = row.priceMax ?? row.price;
if (min === max) return ${min}`;
return ${min} ~ ¥${max}`;
},
},
{
title: '规格数',
dataIndex: 'skuCount',
width: 80,
render: (v: number | undefined, row) => {
const real = v ?? 0;
if (real > 0) return real;
return row.skus?.some((s) => s.virtual) ? 1 : 0;
},
},
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => (
<Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag>
) },
{
title: '白名单',
dataIndex: 'visibilityWhitelistEnabled',
width: 90,
render: (v: boolean) =>
v ? <Tag color="orange">限测</Tag> : <Tag>公开</Tag>,
},
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
{
title: '操作', width: 120,
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={async () => {
const d = await request<Record<string, unknown>>(`/admin/products/${row.id}`);
setDetail(d);
editForm.setFieldsValue(mapDetailToForm(d));
setDrawerOpen(true);
}}>编辑</Button>
<Popconfirm
title="确认删除该商品?"
description={`将永久删除「${row.name}」,此操作不可恢复。`}
okText="确认删除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => handleDelete(row)}
>
<Button type="link" size="small" danger>删除</Button>
</Popconfirm>
</Space>
),
},
], [detail, editForm]);
const skuColumns: ColumnsType<SkuListRow> = useMemo(() => [
{
title: '规格',
dataIndex: 'specText',
width: 140,
ellipsis: true,
render: (v: string, row) => (
<Space size={4}>
<span>{v || '默认'}</span>
{row.isDefault ? <Tag color="blue">默认</Tag> : null}
{row.virtual ? <Tag>未建SKU</Tag> : null}
</Space>
),
},
{ title: 'SKU', dataIndex: 'skuCode', width: 110 },
{ title: '69码', dataIndex: 'barcode69', width: 160, ellipsis: true },
{ title: '售价', dataIndex: 'price', width: 90, render: (v) => ${v}` },
{ title: '权益额', dataIndex: 'benefitAmount', width: 90, render: (v) => ${v}` },
{
title: '累计销售',
dataIndex: 'soldBottles',
width: 100,
render: (v: number | undefined) => `${v ?? 0} 瓶`,
},
{
title: '履约',
key: 'fulfillment',
width: 160,
render: (_, row) => <FulfillmentTags {...row} />,
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s) => (
<Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag>
),
},
], []);
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}>商品管理</Typography.Title>
<Button type="primary" onClick={() => setCreateOpen(true)}>新建商品</Button>
</Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="name" label="名称">
<Input allowClear placeholder="名称 / SKU / 69 码" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 110 }} options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="aromaType" label="香型">
<Select allowClear style={{ width: 110 }} options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1080 }}
expandable={{
rowExpandable: () => true,
expandedRowRender: (row) => (
<div style={{ margin: '-8px -8px -8px 24px', padding: 12, background: '#f5f5f5', borderRadius: 6 }}>
<Table
size="small"
rowKey="id"
pagination={false}
columns={skuColumns}
dataSource={row.skus ?? []}
scroll={{ x: 980 }}
style={{ background: 'transparent' }}
/>
</div>
),
}}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }}
/>
<Drawer title="编辑商品" width={1100} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && (
<Button type="primary" onClick={async () => {
const v = await editForm.validateFields();
const multiSku = Array.isArray(detail.skus) && (detail.skus as unknown[]).length > 1;
if (!multiSku && !v.allowOnlinePurchase && !v.allowOnSitePickup) {
message.error('请至少勾选「允许线上购买」或「允许现场取货」之一');
return;
}
const payload = buildProductPayload(v);
if (multiSku) {
delete (payload as { allowOnlinePurchase?: boolean }).allowOnlinePurchase;
delete (payload as { allowCrossCityDelivery?: boolean }).allowCrossCityDelivery;
delete (payload as { allowOnSitePickup?: boolean }).allowOnSitePickup;
}
await request(`/admin/products/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
message.success('已保存');
setDrawerOpen(false);
void reload();
}}>保存</Button>
)}>
{detail && (
<>
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="默认 SKU">{String(detail.skuCode)}(系统生成)</Descriptions.Item>
<Descriptions.Item label="默认 69 码">{String(detail.barcode69)}</Descriptions.Item>
<Descriptions.Item label="香型">{AROMA_TYPE_LABELS[String(detail.aromaType)] || String(detail.aromaType)}</Descriptions.Item>
<Descriptions.Item label="创建时间">{fmtTime(String(detail.createdAt ?? ''))}</Descriptions.Item>
</Descriptions>
<Form form={editForm} layout="vertical">
<Tabs items={[
{
key: 'base',
label: '基础信息',
children: (
<BaseInfoFields
mode="edit"
form={editForm}
hideFulfillment={Array.isArray(detail.skus) && (detail.skus as unknown[]).length > 1}
/>
),
},
{
key: 'detail',
label: '详情页',
children: (
<ProductDetailFields
form={editForm}
aromaType={String(detail.aromaType ?? '')}
/>
),
},
{
key: 'specs',
label: '规格与 SKU',
children: (
<ProductSpecsEditor
productId={String(detail.id)}
initialAttrs={(detail.specAttrs as never) ?? []}
initialSkus={(detail.skus as never) ?? []}
onSaved={async () => {
const d = await request<Record<string, unknown>>(`/admin/products/${detail.id}`);
setDetail(d);
editForm.setFieldsValue(mapDetailToForm(d));
void reload();
}}
/>
),
},
]} />
</Form>
</>
)}
</Drawer>
<Modal title="新建商品" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
const v = await createForm.validateFields();
if (!v.allowOnlinePurchase && !v.allowOnSitePickup) {
message.error('请至少勾选「允许线上购买」或「允许现场取货」之一');
return;
}
const payload = buildProductPayload(v);
await request('/admin/products', { method: 'POST', body: JSON.stringify(payload) });
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
}} width={720}>
<Form form={createForm} layout="vertical" initialValues={{
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0,
allowOnlinePurchase: true, allowCrossCityDelivery: true, allowOnSitePickup: false,
visibilityWhitelistEnabled: false, visibilityPhones: [],
carouselUrls: [], detailImageUrls: [],
features: [{ icon: 'water_drop', title: '', desc: '' }],
}}>
<Tabs items={[
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="create" form={createForm} /> },
{
key: 'detail',
label: '详情页',
children: (
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.aromaType !== cur.aromaType}>
{() => (
<ProductDetailFields
form={createForm}
aromaType={createForm.getFieldValue('aromaType') as string | undefined}
/>
)}
</Form.Item>
),
},
]} />
</Form>
</Modal>
</div>
);
}