fix(admin): keep all batch-uploaded images when saving packages

Serialize multi-file uploads and reload package list after save so imageUrls persist.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-07 21:13:31 +08:00
parent 8f83624ffe
commit df546ff39f
3 changed files with 73 additions and 16 deletions
@@ -101,11 +101,11 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
usableTime: item.usableTime?.trim() || null, usableTime: item.usableTime?.trim() || null,
otherNotes: item.otherNotes?.trim() || null, otherNotes: item.otherNotes?.trim() || null,
imageUrl: imageUrls[0] ?? null, imageUrl: imageUrls[0] ?? null,
imageUrls: imageUrls.length ? imageUrls : null, imageUrls,
sortOrder: index, sortOrder: index,
}; };
}) })
.filter((item) => item.name || item.dishes || item.price || (item.imageUrls?.length ?? 0) > 0); .filter((item) => item.name || item.dishes || item.price || item.imageUrls.length > 0);
for (let i = 0; i < filled.length; i++) { for (let i = 0; i < filled.length; i++) {
const item = filled[i]; const item = filled[i];
@@ -130,13 +130,27 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
setSaving(true); setSaving(true);
try { try {
await request(`/admin/stores/${storeId}/packages`, { const data = await request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })), packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })),
}), }),
}); });
message.success('套餐已保存并生效'); message.success('套餐已保存并生效');
setItems(
data.live?.length
? data.live.map((p, i) => {
const imageUrls = normalizeStorePackageImageUrls(p);
return {
...p,
price: String(p.price),
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: i,
};
})
: [emptyRow()],
);
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '保存失败'); message.error(e instanceof Error ? e.message : '保存失败');
} finally { } finally {
@@ -271,7 +285,7 @@ export default function AdminStorePackagesSection({ storeId }: { storeId: string
) : null} ) : null}
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}> <Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
C
</Typography.Paragraph> </Typography.Paragraph>
<Button type="primary" loading={saving} onClick={() => void save()}> <Button type="primary" loading={saving} onClick={() => void save()}>
@@ -1,4 +1,4 @@
import { useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Button, Image, Space, Typography, Upload, message } from 'antd'; import { Button, Image, Space, Typography, Upload, message } from 'antd';
import { UploadOutlined, DeleteOutlined } from '@ant-design/icons'; import { UploadOutlined, DeleteOutlined } from '@ant-design/icons';
import type { UploadProps } from 'antd'; import type { UploadProps } from 'antd';
@@ -33,14 +33,33 @@ export default function MultiImageUpload({
accept = 'image/*', accept = 'image/*',
}: Props) { }: Props) {
const urls = normalizeUrls(value); const urls = normalizeUrls(value);
const urlsRef = useRef(urls);
const onChangeRef = useRef(onChange);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const batchBuf = useRef<File[]>([]); const batchBuf = useRef<File[]>([]);
const batchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const uploadChain = useRef(Promise.resolve());
useEffect(() => {
urlsRef.current = urls;
}, [urls]);
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);
useEffect(() => {
return () => {
if (batchTimer.current) clearTimeout(batchTimer.current);
};
}, []);
const remaining = maxCount != null ? Math.max(0, maxCount - urls.length) : Number.POSITIVE_INFINITY; const remaining = maxCount != null ? Math.max(0, maxCount - urls.length) : Number.POSITIVE_INFINITY;
const canAdd = remaining > 0; const canAdd = remaining > 0;
async function uploadBatch(files: File[]) { async function uploadBatch(files: File[]) {
const room = maxCount != null ? Math.max(0, maxCount - urls.length) : files.length; const current = urlsRef.current;
const room = maxCount != null ? Math.max(0, maxCount - current.length) : files.length;
const picked = files.slice(0, room); const picked = files.slice(0, room);
if (!picked.length) { if (!picked.length) {
message.warning(maxCount != null ? `最多 ${maxCount}` : '无法上传'); message.warning(maxCount != null ? `最多 ${maxCount}` : '无法上传');
@@ -63,7 +82,10 @@ export default function MultiImageUpload({
} }
} }
if (appended.length) { if (appended.length) {
onChange?.([...urls, ...appended]); // 始终基于最新列表追加,避免并行上传互相覆盖
const next = [...urlsRef.current, ...appended];
urlsRef.current = next;
onChangeRef.current?.(next);
message.success(`成功上传 ${appended.length}${fail ? `,失败 ${fail}` : ''}`); message.success(`成功上传 ${appended.length}${fail ? `,失败 ${fail}` : ''}`);
} else if (fail) { } else if (fail) {
message.error('上传失败'); message.error('上传失败');
@@ -73,18 +95,37 @@ export default function MultiImageUpload({
} }
} }
const beforeUpload: UploadProps['beforeUpload'] = (file, fileList) => { function enqueueUploadBatch(files: File[]) {
batchBuf.current.push(file as File); uploadChain.current = uploadChain.current
if (batchBuf.current.length >= fileList.length) { .then(() => uploadBatch(files))
.catch(() => undefined);
}
function flushBatch() {
if (batchTimer.current) {
clearTimeout(batchTimer.current);
batchTimer.current = null;
}
if (!batchBuf.current.length) return;
const files = [...batchBuf.current]; const files = [...batchBuf.current];
batchBuf.current = []; batchBuf.current = [];
void uploadBatch(files); enqueueUploadBatch(files);
} }
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
batchBuf.current.push(file as File);
// 多选时 beforeUpload 可能逐文件触发;短防抖合并成一次批量
if (batchTimer.current) clearTimeout(batchTimer.current);
batchTimer.current = setTimeout(() => {
flushBatch();
}, 80);
return false; return false;
}; };
function removeAt(index: number) { function removeAt(index: number) {
onChange?.(urls.filter((_, i) => i !== index)); const next = urlsRef.current.filter((_, i) => i !== index);
urlsRef.current = next;
onChangeRef.current?.(next);
} }
return ( return (
@@ -53,7 +53,9 @@ export default function MultiOssUploadField({
} }
async function uploadFiles(files: File[]) { async function uploadFiles(files: File[]) {
const picked = files.slice(0, remaining); const current = normalizeUrls(value);
const room = Math.max(0, maxCount - current.length);
const picked = files.slice(0, room);
if (!picked.length) { if (!picked.length) {
showUploadError(`最多 ${maxCount}`); showUploadError(`最多 ${maxCount}`);
return; return;
@@ -68,7 +70,7 @@ export default function MultiOssUploadField({
appended.push(result.url); appended.push(result.url);
} }
if (appended.length) { if (appended.length) {
onChange?.([...urls, ...appended]); onChange?.([...current, ...appended]);
toastSuccess(`已上传 ${appended.length}`); toastSuccess(`已上传 ${appended.length}`);
} }
} catch (e) { } catch (e) {