feat(v3.4.15): HQ store media edit and package images up to 20

Allow HQ to replace/delete store photos; support multi-image packages with a 20-image cap.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-07 19:24:07 +08:00
parent 0692bebf07
commit 76375eff83
19 changed files with 657 additions and 302 deletions
@@ -1,6 +1,6 @@
import { useState } from 'react';
import type { PackageFormItem } from '../lib/storePackages';
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
import { STORE_PACKAGE_IMAGE_MAX_COUNT, STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
import { emptyPackage } from '../lib/storePackages';
import OssUploadField from './OssUploadField';
@@ -131,13 +131,70 @@ export default function StorePackagesForm({ items, onChange, disabled, embedded
</div>
<div className="partner-field">
<label></label>
<OssUploadField
bizType="STORE_PACKAGE"
value={item.imageUrl || ''}
disabled={disabled}
onChange={(url) => updateAt(index, { imageUrl: url })}
/>
<label> {STORE_PACKAGE_IMAGE_MAX_COUNT} </label>
{(() => {
const slots =
Array.isArray(item.imageUrls) && item.imageUrls.length > 0
? item.imageUrls.map((u) => String(u ?? ''))
: item.imageUrl
? [String(item.imageUrl)]
: [''];
const filled = slots.map((u) => u.trim()).filter(Boolean);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{slots.map((url, imgIndex) => (
<div key={`${imgIndex}-${url || 'empty'}`}>
<OssUploadField
bizType="STORE_PACKAGE"
value={url || ''}
onChange={(nextUrl) => {
const next = [...slots];
next[imgIndex] = nextUrl?.trim() || '';
const cleaned = next.map((u) => u.trim()).filter(Boolean);
updateAt(index, {
imageUrls: next,
imageUrl: cleaned[0] ?? '',
});
}}
/>
{!disabled && (filled.length > 0 || url) ? (
<button
type="button"
className="partner-packages-remove"
style={{ marginTop: 6 }}
onClick={() => {
const next = slots.filter((_, i) => i !== imgIndex);
const cleaned = next.map((u) => u.trim()).filter(Boolean);
updateAt(index, {
imageUrls: next.length ? next : [],
imageUrl: cleaned[0] ?? '',
});
}}
>
</button>
) : null}
</div>
))}
{!disabled && filled.length < STORE_PACKAGE_IMAGE_MAX_COUNT && slots.length < STORE_PACKAGE_IMAGE_MAX_COUNT ? (
<button
type="button"
className="partner-packages-add"
style={{ marginTop: 0 }}
onClick={() =>
updateAt(index, {
imageUrls: [...slots, ''],
imageUrl: filled[0] ?? '',
})
}
>
<span className="material-symbols-outlined">add_a_photo</span>
{filled.length}/{STORE_PACKAGE_IMAGE_MAX_COUNT}
</button>
) : null}
</div>
);
})()}
</div>
<div className="partner-field">
+28 -11
View File
@@ -1,5 +1,9 @@
import type { StorePackageItemDto } from '@dukang/shared-types';
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
import {
STORE_PACKAGE_IMAGE_MAX_COUNT,
STORE_PACKAGE_MAX_COUNT,
normalizeStorePackageImageUrls,
} from '@dukang/shared-types';
export type PackageFormItem = StorePackageItemDto;
@@ -11,24 +15,34 @@ export function emptyPackage(index = 0): PackageFormItem {
usableTime: '',
otherNotes: '',
imageUrl: '',
imageUrls: [],
sortOrder: index,
};
}
export function normalizePackageFormItems(raw: PackageFormItem[]): PackageFormItem[] {
return raw
.map((item, index) => ({
name: item.name.trim(),
price: item.price.trim(),
dishes: item.dishes.trim(),
usableTime: item.usableTime?.trim() || '',
otherNotes: item.otherNotes?.trim() || '',
imageUrl: item.imageUrl?.trim() || '',
sortOrder: index,
}))
.map((item, index) => {
const imageUrls = normalizeStorePackageImageUrls(item);
return {
name: item.name.trim(),
price: item.price.trim(),
dishes: item.dishes.trim(),
usableTime: item.usableTime?.trim() || '',
otherNotes: item.otherNotes?.trim() || '',
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: index,
};
})
.filter(
(item) =>
item.name || item.price || item.dishes || item.usableTime || item.otherNotes || item.imageUrl,
item.name ||
item.price ||
item.dishes ||
item.usableTime ||
item.otherNotes ||
item.imageUrls.length > 0,
);
}
@@ -43,6 +57,9 @@ export function validatePackageFormItems(items: PackageFormItem[]): string | nul
const price = Number(item.price);
if (!Number.isFinite(price) || price < 0) return `${i + 1} 条套餐价格须为非负数字`;
if (!item.dishes) return `${i + 1} 条套餐菜品不能为空`;
if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) {
return `${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT}`;
}
}
return null;
}
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import type { StorePackagesResponse } from '@dukang/shared-types';
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
import StorePackagesForm from '../components/StorePackagesForm';
import { request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
@@ -30,7 +31,18 @@ export default function StorePackagesPage() {
: data.live?.length
? data.live
: [emptyPackage()];
setItems(base.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i })));
setItems(
base.map((p, i) => {
const imageUrls = normalizeStorePackageImageUrls(p);
return {
...p,
price: String(p.price),
imageUrl: imageUrls[0] ?? '',
imageUrls,
sortOrder: i,
};
}),
);
setPending(data.pendingRequest ?? null);
})
.catch((e) => setError(e instanceof Error ? e.message : '加载失败'))