feat(admin): batch upload for package, store env, and product images

Allow multi-select uploads in HQ, partner, and shop for the three image galleries.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-07 19:40:59 +08:00
parent 76375eff83
commit fe1c6c8158
11 changed files with 518 additions and 415 deletions
@@ -1,6 +1,5 @@
import { Button, Form, Space, Typography } from 'antd';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import OssUpload from './OssUpload';
import { Typography } from 'antd';
import MultiImageUpload from './MultiImageUpload';
type Props = {
name?: string;
@@ -8,42 +7,35 @@ type Props = {
bizType?: string;
/** 最多可添加张数;不传则不限制 */
maxCount?: number;
/** Form.Item 注入 */
value?: string[];
onChange?: (urls: string[]) => void;
};
/**
* 商品详情/轮播等多图列表。
* 可直接包在 Form.Item 下(value/onChange),也可用 Form.List 的 name 外层再包 Form.Item。
*/
export default function DetailImageUrlList({
name = 'detailImageUrls',
label,
bizType = 'DETAIL',
maxCount,
value,
onChange,
}: 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>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
{label}{maxCount != null ? `,最多 ${maxCount}` : ''}
</Typography.Text>
<MultiImageUpload
bizType={bizType}
mediaType="IMAGE"
maxCount={maxCount}
value={value}
onChange={onChange}
tip={`支持一次选择多张${label}`}
/>
</>
);
}
@@ -0,0 +1,143 @@
import { useRef, useState } from 'react';
import { Button, Image, Space, Typography, Upload, message } from 'antd';
import { UploadOutlined, DeleteOutlined } from '@ant-design/icons';
import type { UploadProps } from 'antd';
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
type Props = {
value?: string[];
onChange?: (urls: string[]) => void;
bizType: string;
mediaType?: OssMediaType;
/** 最多张数;不传则不限制 */
maxCount?: number;
tip?: string;
accept?: string;
};
function normalizeUrls(value?: string[]) {
return (value ?? []).map((u) => String(u || '').trim()).filter(Boolean);
}
/**
* 多图批量上传(一次可选多张),用于套餐图 / 环境照 / 商品详情图等。
* Form.Item 直接绑定 string[]。
*/
export default function MultiImageUpload({
value,
onChange,
bizType,
mediaType = 'IMAGE',
maxCount,
tip,
accept = 'image/*',
}: Props) {
const urls = normalizeUrls(value);
const [uploading, setUploading] = useState(false);
const batchBuf = useRef<File[]>([]);
const remaining = maxCount != null ? Math.max(0, maxCount - urls.length) : Number.POSITIVE_INFINITY;
const canAdd = remaining > 0;
async function uploadBatch(files: File[]) {
const room = maxCount != null ? Math.max(0, maxCount - urls.length) : files.length;
const picked = files.slice(0, room);
if (!picked.length) {
message.warning(maxCount != null ? `最多 ${maxCount}` : '无法上传');
return;
}
if (files.length > picked.length) {
message.warning(`已达上限,仅上传前 ${picked.length}`);
}
setUploading(true);
const appended: string[] = [];
let fail = 0;
try {
for (const file of picked) {
try {
const result = await uploadFileToOss(file, { bizType, mediaType });
appended.push(result.url);
} catch {
fail += 1;
}
}
if (appended.length) {
onChange?.([...urls, ...appended]);
message.success(`成功上传 ${appended.length}${fail ? `,失败 ${fail}` : ''}`);
} else if (fail) {
message.error('上传失败');
}
} finally {
setUploading(false);
}
}
const beforeUpload: UploadProps['beforeUpload'] = (file, fileList) => {
batchBuf.current.push(file as File);
if (batchBuf.current.length >= fileList.length) {
const files = [...batchBuf.current];
batchBuf.current = [];
void uploadBatch(files);
}
return false;
};
function removeAt(index: number) {
onChange?.(urls.filter((_, i) => i !== index));
}
return (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Typography.Text type="secondary">
{tip ??
(maxCount != null
? `最多 ${maxCount} 张,支持一次选择多张批量上传`
: '支持一次选择多张批量上传')}
{maxCount != null ? `(已选 ${urls.length}/${maxCount}` : urls.length ? `(已选 ${urls.length}` : ''}
</Typography.Text>
{urls.length > 0 ? (
<Image.PreviewGroup>
<Space wrap size={12}>
{urls.map((url, index) => (
<div key={`${url}-${index}`} style={{ position: 'relative', width: 96 }}>
<Image
src={url}
width={96}
height={96}
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #f0f0f0' }}
/>
<Button
type="text"
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => removeAt(index)}
style={{
position: 'absolute',
top: 0,
right: 0,
background: 'rgba(255,255,255,0.85)',
}}
/>
</div>
))}
</Space>
</Image.PreviewGroup>
) : null}
<Upload
accept={accept}
multiple
showUploadList={false}
beforeUpload={beforeUpload}
disabled={uploading || !canAdd}
>
<Button icon={<UploadOutlined />} loading={uploading} disabled={!canAdd}>
{canAdd ? '批量上传图片' : '已达上限'}
</Button>
</Upload>
</Space>
);
}
@@ -1,75 +1,21 @@
import { Button, Image, Space, Typography } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { STORE_PACKAGE_IMAGE_MAX_COUNT } from '@dukang/shared-types';
import OssUpload from './OssUpload';
import MultiImageUpload from './MultiImageUpload';
type Props = {
value?: string[];
onChange?: (urls: string[]) => void;
};
/** 套餐多图上传最多 STORE_PACKAGE_IMAGE_MAX_COUNT 张,支持替换与删除 */
/** 套餐多图:批量上传最多 STORE_PACKAGE_IMAGE_MAX_COUNT 张 */
export default function PackageImagesUpload({ value, onChange }: Props) {
const urls = (value ?? []).map((u) => String(u || '').trim()).filter(Boolean);
function updateAt(index: number, url: string) {
const next = [...urls];
const trimmed = url.trim();
if (!trimmed) {
next.splice(index, 1);
} else {
next[index] = trimmed;
}
onChange?.(next);
}
function removeAt(index: number) {
onChange?.(urls.filter((_, i) => i !== index));
}
function addSlot() {
if (urls.length >= STORE_PACKAGE_IMAGE_MAX_COUNT) return;
onChange?.([...urls, '']);
}
const slots = urls.length ? urls : [''];
return (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Typography.Text type="secondary">
{STORE_PACKAGE_IMAGE_MAX_COUNT}
</Typography.Text>
{slots.map((url, index) => (
<div key={`${index}-${url || 'empty'}`} style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<div style={{ flex: 1 }}>
<OssUpload
bizType="STORE_PACKAGE"
mediaType="IMAGE"
value={url || undefined}
onChange={(next) => updateAt(index, next)}
/>
</div>
{(urls.length > 0 || url) && (
<Button type="link" danger onClick={() => removeAt(index)} style={{ marginTop: 8 }}>
</Button>
)}
</div>
))}
{urls.length < STORE_PACKAGE_IMAGE_MAX_COUNT ? (
<Button type="dashed" icon={<PlusOutlined />} onClick={addSlot} block>
{urls.filter(Boolean).length}/{STORE_PACKAGE_IMAGE_MAX_COUNT}
</Button>
) : null}
{urls.length > 1 ? (
<Image.PreviewGroup>
<Space wrap size={8}>
{urls.map((u) => (
<Image key={u} src={u} width={64} height={64} style={{ objectFit: 'cover', borderRadius: 4 }} />
))}
</Space>
</Image.PreviewGroup>
) : null}
</Space>
<MultiImageUpload
bizType="STORE_PACKAGE"
mediaType="IMAGE"
maxCount={STORE_PACKAGE_IMAGE_MAX_COUNT}
value={value}
onChange={onChange}
tip={`套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT} 张,支持批量选择上传,可逐张删除`}
/>
);
}
@@ -34,7 +34,7 @@ function mapToForm(d: Record<string, unknown>) {
const row = d as Row;
return {
...row,
detailImageUrls: row.detailImageUrls?.length ? row.detailImageUrls : [''],
detailImageUrls: row.detailImageUrls?.length ? row.detailImageUrls : [],
features: row.features?.length
? row.features
: [{ icon: 'star', title: '', desc: '' }],
@@ -68,12 +68,14 @@ function buildPayload(v: TemplateFormValues) {
function TemplateContentFields() {
return (
<>
<Typography.Text type="secondary"></Typography.Text>
<DetailImageUrlList
label="详情图"
bizType="DETAIL_TEMPLATE"
maxCount={TEMPLATE_MAX_DETAIL_IMAGES}
/>
<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="如:千年杜康 · 唯有此处" />
@@ -228,7 +230,7 @@ export default function ProductDetailTemplatesPage() {
}} width={640}>
<Form form={createForm} layout="vertical" initialValues={{
status: 'ACTIVE', sortOrder: 0,
detailImageUrls: [''],
detailImageUrls: [],
features: [{ icon: 'water_drop', title: '', desc: '' }],
}}>
<Form.Item name="code" label="编码" rules={[{ required: true }]} extra="唯一标识,如 dukang-classic">
+16 -25
View File
@@ -9,6 +9,7 @@ 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 type { FormInstance } from 'antd/es/form';
@@ -73,8 +74,8 @@ function mapDetailToForm(d: Record<string, unknown>) {
return {
...d,
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
carouselUrls: (row.carouselUrls?.length ? row.carouselUrls : ['']) as string[],
detailImageUrls: (row.detailImageUrls?.length ? row.detailImageUrls : ['']) as string[],
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,
@@ -129,25 +130,13 @@ function buildProductPayload(v: ProductFormValues) {
function ImageUrlList({ name, label, bizType }: { name: string; label: string; bizType: string }) {
return (
<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>
))}
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
{label}
</Button>
</>
)}
</Form.List>
<Form.Item name={name} style={{ marginBottom: 0 }}>
<MultiImageUpload
bizType={bizType}
mediaType="IMAGE"
tip={`${label}支持一次选择多张批量上传`}
/>
</Form.Item>
);
}
@@ -156,11 +145,13 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp
<>
<ProductDetailTemplatePicker form={form} aromaType={aromaType} />
<Divider />
<Typography.Text type="secondary">CAROUSEL 10MB</Typography.Text>
<Typography.Text type="secondary">CAROUSEL 10MB</Typography.Text>
<ImageUrlList name="carouselUrls" label="轮播图" bizType="CAROUSEL" />
<Divider />
<Typography.Text type="secondary">DETAIL 10MB</Typography.Text>
<DetailImageUrlList label="详情图" bizType="DETAIL" />
<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="如:千年杜康 · 唯有此处" />
@@ -468,7 +459,7 @@ export default function ProductsPage() {
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0,
allowOnlinePurchase: true, allowCrossCityDelivery: true, allowOnSitePickup: false,
visibilityWhitelistEnabled: false, visibilityPhones: [],
carouselUrls: [''], detailImageUrls: [''],
carouselUrls: [], detailImageUrls: [],
features: [{ icon: 'water_drop', title: '', desc: '' }],
}}>
<Tabs items={[
+27 -65
View File
@@ -40,6 +40,7 @@ import { useAdminList } from '../lib/useAdminList';
import { resolveRegionBinding } from '../lib/china-region';
import ChinaRegionCascader from '../components/ChinaRegionCascader';
import OssUpload from '../components/OssUpload';
import MultiImageUpload from '../components/MultiImageUpload';
import TencentLocPickerModal from '../components/TencentLocPickerModal';
import AdminStorePackagesSection from '../components/AdminStorePackagesSection';
@@ -118,42 +119,18 @@ function StoreAuditMediaEditor() {
<Form.Item name="coverUrl" label="门头照">
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
</Form.Item>
<Typography.Text strong></Typography.Text>
<Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
3 20
</Typography.Paragraph>
<Form.List name="envPhotoUrls">
{(fields, { add, remove }) => (
<div style={{ marginTop: 8 }}>
{fields.map((field, index) => (
<div key={field.key} style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<Form.Item
name={field.name}
label={`环境图 ${index + 1}`}
style={{ flex: 1, marginBottom: 12 }}
>
<OssUpload bizType="STORE_ENV" mediaType="IMAGE" />
</Form.Item>
<Button
type="link"
danger
style={{ marginTop: 30 }}
onClick={() => remove(field.name)}
>
</Button>
</div>
))}
{fields.length < 20 ? (
<Button type="dashed" onClick={() => add('')} block>
</Button>
) : (
<Typography.Text type="secondary"> 20 </Typography.Text>
)}
</div>
)}
</Form.List>
<Form.Item
name="envPhotoUrls"
label="环境照片"
extra="建议至少 3 张;支持批量上传,最多 20 张。可逐张删除后保存。"
>
<MultiImageUpload
bizType="STORE_ENV"
mediaType="IMAGE"
maxCount={20}
tip="环境照支持一次选择多张批量上传"
/>
</Form.Item>
<Form.Item name="contractUrl" label="签约合同" style={{ marginTop: 16 }}>
<OssUpload bizType="STORE_CONTRACT" mediaType="FILE" accept="image/*,.pdf" />
</Form.Item>
@@ -404,7 +381,7 @@ export default function StoresPage() {
envPhotoUrls: (() => {
const { envs } = collectMediaUrls(d);
const urls = envs.map((item) => item.url).filter(Boolean);
return urls.length ? urls : [''];
return urls;
})(),
contractUrl: (() => {
const { contracts } = collectMediaUrls(d);
@@ -559,7 +536,7 @@ export default function StoresPage() {
function openCreateModal() {
void loadOptions();
createForm.setFieldsValue({
envPhotoUrls: ['', '', ''],
envPhotoUrls: [],
settlementRate: 60,
openTime: '10:00',
closeTime: '22:00',
@@ -1261,33 +1238,18 @@ export default function StoresPage() {
<Form.Item name="coverUrl" label="门头照">
<OssUpload bizType="STORE_TITLE" mediaType="IMAGE" />
</Form.Item>
<Typography.Text strong></Typography.Text>
<Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
3 20
</Typography.Paragraph>
<Form.List name="envPhotoUrls">
{(fields, { add, remove }) => (
<div style={{ marginTop: 8 }}>
{fields.map((field, index) => (
<div key={field.key} style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<Form.Item key={field.key} name={field.name} label={`环境图 ${index + 1}`} style={{ flex: 1 }}>
<OssUpload bizType="STORE_ENV" mediaType="IMAGE" />
</Form.Item>
<Button type="link" danger style={{ marginTop: 30 }} onClick={() => remove(field.name)}>
</Button>
</div>
))}
{fields.length < 20 ? (
<Button type="dashed" onClick={() => add('')} block>
</Button>
) : (
<Typography.Text type="secondary"> 20 </Typography.Text>
)}
</div>
)}
</Form.List>
<Form.Item
name="envPhotoUrls"
label="环境照片"
extra="选填;支持批量上传,最多 20 张"
>
<MultiImageUpload
bizType="STORE_ENV"
mediaType="IMAGE"
maxCount={20}
tip="环境照支持一次选择多张批量上传"
/>
</Form.Item>
<Form.Item name="contractUrl" label="签约合同">
<OssUpload bizType="STORE_CONTRACT" mediaType="FILE" accept="image/*,.pdf" />
</Form.Item>