fe1c6c8158
Allow multi-select uploads in HQ, partner, and shop for the three image galleries. Co-authored-by: Cursor <cursoragent@cursor.com>
261 lines
11 KiB
TypeScript
261 lines
11 KiB
TypeScript
import { useState } from 'react';
|
|
import {
|
|
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Select, Space,
|
|
Table, 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, DETAIL_TEMPLATE_STATUS_LABELS, fmtTime,
|
|
} from '../lib/constants';
|
|
import { useAdminList } from '../lib/useAdminList';
|
|
import DetailImageUrlList from '../components/DetailImageUrlList';
|
|
import type { ProductDetailTemplateDto } from '../lib/product-detail-templates';
|
|
import { TEMPLATE_MAX_DETAIL_IMAGES } from '../lib/product-detail-templates';
|
|
|
|
type Row = ProductDetailTemplateDto;
|
|
|
|
type TemplateFormValues = {
|
|
code: string;
|
|
name: string;
|
|
description?: string;
|
|
aromaType?: string | null;
|
|
storyTitle?: string;
|
|
storyText?: string;
|
|
features?: Array<{ icon?: string; title?: string; desc?: string }>;
|
|
detailImageUrls?: string[];
|
|
suggestedDetailImageCount?: number;
|
|
sortOrder?: number;
|
|
status?: string;
|
|
};
|
|
|
|
function mapToForm(d: Record<string, unknown>) {
|
|
const row = d as Row;
|
|
return {
|
|
...row,
|
|
detailImageUrls: row.detailImageUrls?.length ? row.detailImageUrls : [],
|
|
features: row.features?.length
|
|
? row.features
|
|
: [{ icon: 'star', title: '', desc: '' }],
|
|
};
|
|
}
|
|
|
|
function buildPayload(v: TemplateFormValues) {
|
|
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() ?? '',
|
|
}));
|
|
return {
|
|
code: v.code.trim(),
|
|
name: v.name.trim(),
|
|
description: v.description?.trim() || undefined,
|
|
aromaType: v.aromaType || null,
|
|
storyTitle: v.storyTitle?.trim() || undefined,
|
|
storyText: v.storyText?.trim() || undefined,
|
|
features,
|
|
detailImageUrls,
|
|
suggestedDetailImageCount: detailImageUrls.length || (v.suggestedDetailImageCount ?? 1),
|
|
sortOrder: v.sortOrder ?? 0,
|
|
status: v.status ?? 'ACTIVE',
|
|
};
|
|
}
|
|
|
|
function TemplateContentFields() {
|
|
return (
|
|
<>
|
|
<Typography.Text type="secondary">模板详情长图(套用商品时可逐张修改,支持批量上传)</Typography.Text>
|
|
<Form.Item name="detailImageUrls" style={{ marginBottom: 0 }}>
|
|
<DetailImageUrlList
|
|
label="详情图"
|
|
bizType="DETAIL_TEMPLATE"
|
|
maxCount={TEMPLATE_MAX_DETAIL_IMAGES}
|
|
/>
|
|
</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>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function ProductDetailTemplatesPage() {
|
|
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/product-detail-templates',
|
|
() => {
|
|
const qs = new URLSearchParams();
|
|
if (filters.name) qs.set('name', filters.name);
|
|
if (filters.code) qs.set('code', filters.code);
|
|
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);
|
|
|
|
const columns: ColumnsType<Row> = [
|
|
{ title: '编码', dataIndex: 'code', width: 120 },
|
|
{ title: '名称', dataIndex: 'name', width: 120 },
|
|
{ title: '说明', dataIndex: 'description', width: 200, ellipsis: true },
|
|
{ title: '香型', dataIndex: 'aromaType', width: 90, render: (v) => (v ? AROMA_TYPE_LABELS[v] || v : '—') },
|
|
{ title: '详情图', dataIndex: 'detailImageUrls', width: 80, render: (v: string[] | undefined) => v?.length ?? 0 },
|
|
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
|
|
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{DETAIL_TEMPLATE_STATUS_LABELS[s] || s}</Tag> },
|
|
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
|
|
{
|
|
title: '操作', width: 80,
|
|
render: (_, row) => (
|
|
<Button type="link" size="small" onClick={async () => {
|
|
const d = await request<Record<string, unknown>>(`/admin/product-detail-templates/${row.id}`);
|
|
setDetail(d);
|
|
editForm.setFieldsValue(mapToForm(d));
|
|
setDrawerOpen(true);
|
|
}}>编辑</Button>
|
|
),
|
|
},
|
|
];
|
|
|
|
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 /></Form.Item>
|
|
<Form.Item name="code" label="编码"><Input allowClear /></Form.Item>
|
|
<Form.Item name="status" label="状态">
|
|
<Select allowClear style={{ width: 110 }} options={Object.entries(DETAIL_TEMPLATE_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: 1100 }}
|
|
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
|
<Drawer title="编辑详情模板" width={640} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
|
extra={detail && (
|
|
<Button type="primary" onClick={async () => {
|
|
const v = await editForm.validateFields();
|
|
const payload = buildPayload(v);
|
|
await request(`/admin/product-detail-templates/${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="ID">{String(detail.id)}</Descriptions.Item>
|
|
<Descriptions.Item label="创建">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
|
</Descriptions>
|
|
<Form form={editForm} layout="vertical">
|
|
<Form.Item name="code" label="编码" rules={[{ required: true }]}>
|
|
<Input />
|
|
</Form.Item>
|
|
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
|
<Input />
|
|
</Form.Item>
|
|
<Form.Item name="description" label="说明">
|
|
<Input.TextArea rows={2} />
|
|
</Form.Item>
|
|
<Form.Item name="aromaType" label="推荐香型">
|
|
<Select allowClear placeholder="不限香型" options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
|
</Form.Item>
|
|
<Form.Item name="sortOrder" label="排序">
|
|
<InputNumber min={0} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
<Form.Item name="status" label="状态">
|
|
<Select options={Object.entries(DETAIL_TEMPLATE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
|
</Form.Item>
|
|
<Divider />
|
|
<TemplateContentFields />
|
|
</Form>
|
|
</>
|
|
)}
|
|
</Drawer>
|
|
<Modal title="新建详情模板" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
|
const v = await createForm.validateFields();
|
|
const payload = buildPayload(v);
|
|
await request('/admin/product-detail-templates', { method: 'POST', body: JSON.stringify(payload) });
|
|
message.success('已创建');
|
|
setCreateOpen(false);
|
|
createForm.resetFields();
|
|
void reload();
|
|
}} width={640}>
|
|
<Form form={createForm} layout="vertical" initialValues={{
|
|
status: 'ACTIVE', sortOrder: 0,
|
|
detailImageUrls: [],
|
|
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
|
}}>
|
|
<Form.Item name="code" label="编码" rules={[{ required: true }]} extra="唯一标识,如 dukang-classic">
|
|
<Input />
|
|
</Form.Item>
|
|
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
|
<Input />
|
|
</Form.Item>
|
|
<Form.Item name="description" label="说明">
|
|
<Input.TextArea rows={2} />
|
|
</Form.Item>
|
|
<Form.Item name="aromaType" label="推荐香型">
|
|
<Select allowClear placeholder="不限香型" options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
|
</Form.Item>
|
|
<Form.Item name="sortOrder" label="排序">
|
|
<InputNumber min={0} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
<Form.Item name="status" label="状态">
|
|
<Select options={Object.entries(DETAIL_TEMPLATE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
|
</Form.Item>
|
|
<Divider />
|
|
<TemplateContentFields />
|
|
</Form>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|