Files
dukang/apps/admin-web/src/components/MultiImageUpload.tsx
T
jacy fe1c6c8158 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>
2026-08-07 19:40:59 +08:00

144 lines
4.3 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 { 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>
);
}