商品模板功能上传

This commit is contained in:
2026-07-06 15:29:27 +08:00
parent 80d967f2cf
commit f46904b47c
19 changed files with 1148 additions and 8 deletions
@@ -0,0 +1,49 @@
import { Button, Form, Space, Typography } from 'antd';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import OssUpload from './OssUpload';
type Props = {
name?: string;
label: string;
bizType?: string;
/** 最多可添加张数;不传则不限制 */
maxCount?: number;
};
export default function DetailImageUrlList({
name = 'detailImageUrls',
label,
bizType = 'DETAIL',
maxCount,
}: Props) {
return (
<>
{maxCount != null && (
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
{maxCount} {label}
</Typography.Text>
)}
<Form.List name={name}>
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}>
<OssUpload bizType={bizType} mediaType="IMAGE" />
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
)}
</Space>
))}
{(!maxCount || fields.length < maxCount) && (
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
{label}
</Button>
)}
</>
)}
</Form.List>
</>
);
}
@@ -0,0 +1,185 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Card, Popconfirm, Select, Space, Spin, Typography, message } from 'antd';
import type { FormInstance } from 'antd/es/form';
import { request, type Paginated } from '../lib/api';
import {
getProductDetailTemplate,
mapDtoToProductDetailTemplate,
type ProductDetailTemplate,
type ProductDetailTemplateDto,
} from '../lib/product-detail-templates';
type Props = {
form: FormInstance;
/** 当前商品香型,用于推荐匹配模板 */
aromaType?: string;
};
function hasDetailContent(form: FormInstance) {
const storyTitle = form.getFieldValue('storyTitle') as string | undefined;
const storyText = form.getFieldValue('storyText') as string | undefined;
const features = form.getFieldValue('features') as Array<{ title?: string; desc?: string }> | undefined;
const detailImageUrls = form.getFieldValue('detailImageUrls') as string[] | undefined;
const hasFeatures = (features ?? []).some((f) => f?.title?.trim() || f?.desc?.trim());
const hasImages = (detailImageUrls ?? []).some((u) => u?.trim());
return Boolean(storyTitle?.trim() || storyText?.trim() || hasFeatures || hasImages);
}
function applyTemplate(form: FormInstance, template: ProductDetailTemplate) {
const { content } = template;
const detailImageUrls = content.detailImageUrls?.length
? [...content.detailImageUrls]
: [''];
form.setFieldsValue({
storyTitle: content.storyTitle ?? '',
storyText: content.storyText ?? '',
detailImageUrls,
features:
content.features && content.features.length > 0
? content.features.map((f) => ({ ...f }))
: [{ icon: 'star', title: '', desc: '' }],
});
const imageHint = detailImageUrls.filter(Boolean).length;
message.success(
imageHint > 0
? `已应用模板「${template.label}」(含 ${imageHint} 张详情图,可逐张修改)`
: `已应用模板「${template.label}`,
);
}
export default function ProductDetailTemplatePicker({ form, aromaType }: Props) {
const [templates, setTemplates] = useState<ProductDetailTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [selectedId, setSelectedId] = useState<string | undefined>();
useEffect(() => {
let cancelled = false;
setLoading(true);
request<Paginated<ProductDetailTemplateDto>>(
'/admin/product-detail-templates?status=ACTIVE&pageSize=100',
)
.then((res) => {
if (cancelled) return;
const mapped = res.items.map(mapDtoToProductDetailTemplate);
setTemplates(mapped);
const defaultId =
(aromaType && mapped.find((t) => t.aromaType === aromaType)?.id) ||
mapped.find((t) => t.code === 'dukang-classic')?.id ||
mapped[0]?.id;
setSelectedId(defaultId);
})
.catch(() => {
if (!cancelled) message.error('加载详情模板失败');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [aromaType]);
const selected = getProductDetailTemplate(templates, selectedId ?? '');
function doApply() {
if (!selected) return;
applyTemplate(form, selected);
}
function handleApply() {
if (!selected) return;
if (hasDetailContent(form)) {
return;
}
doApply();
}
if (loading) {
return (
<Card size="small" title="详情模板" style={{ marginBottom: 16 }}>
<Spin size="small" />
</Card>
);
}
if (templates.length === 0) {
return (
<Card size="small" title="详情模板" style={{ marginBottom: 16 }}>
<Typography.Text type="secondary"></Typography.Text>
</Card>
);
}
return (
<Card size="small" title="详情模板" style={{ marginBottom: 16 }}>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
</Typography.Paragraph>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Select
style={{ width: '100%' }}
placeholder="选择模板"
value={selectedId}
onChange={setSelectedId}
options={templates.map((t) => ({
value: t.id,
label: t.label,
}))}
/>
{selected && (
<Alert
type="info"
showIcon
message={selected.description}
description={
<div style={{ fontSize: 12 }}>
{selected.content.storyTitle && (
<div><strong></strong>{selected.content.storyTitle}</div>
)}
{(selected.content.detailImageUrls?.length ?? 0) > 0 && (
<div style={{ marginTop: 4 }}>
<strong>{selected.content.detailImageUrls?.length}</strong>
</div>
)}
{(selected.content.features?.length ?? 0) > 0 && (
<div style={{ marginTop: 4 }}>
{selected.content.features?.map((f) => f.title).join('、')}
</div>
)}
</div>
}
/>
)}
<Space>
{hasDetailContent(form) ? (
<Popconfirm
title="将覆盖当前详情图、故事与卖点,是否继续?"
onConfirm={doApply}
okText="覆盖应用"
cancelText="取消"
>
<Button type="primary"></Button>
</Popconfirm>
) : (
<Button type="primary" onClick={handleApply} disabled={!selected}>
</Button>
)}
{aromaType && (
<Button
onClick={() => {
const matched = templates.find((t) => t.aromaType === aromaType);
if (matched) {
setSelectedId(matched.id);
if (!hasDetailContent(form)) {
applyTemplate(form, matched);
}
}
}}
>
</Button>
)}
</Space>
</Space>
</Card>
);
}