Files
dukang/apps/h5-shop/src/components/ShopPackagesForm.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

239 lines
9.4 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 {
STORE_PACKAGE_IMAGE_MAX_COUNT,
STORE_PACKAGE_MAX_COUNT,
} from '@dukang/shared-types';
import { emptyPackage, type PackageFormItem } from '../lib/storePackages';
import { uploadFileToOss } from '../lib/upload';
type Props = {
items: PackageFormItem[];
onChange: (items: PackageFormItem[]) => void;
disabled?: boolean;
};
export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
const fileRefs = useRef<Record<number, HTMLInputElement | null>>({});
async function pickPackageImages(pkgIndex: number, fileList?: FileList | null) {
if (!fileList?.length || disabled) return;
const current = items[pkgIndex];
const existing =
Array.isArray(current.imageUrls) && current.imageUrls.length > 0
? current.imageUrls.map((u) => String(u ?? '')).filter((u) => u.trim())
: current.imageUrl
? [String(current.imageUrl)]
: [];
const room = Math.max(0, STORE_PACKAGE_IMAGE_MAX_COUNT - existing.length);
const files = Array.from(fileList).slice(0, room);
if (!files.length) return;
setUploadingIndex(pkgIndex);
try {
const appended: string[] = [];
for (const file of files) {
const result = await uploadFileToOss(file, 'STORE_PACKAGE');
appended.push(result.url);
}
const next = [...existing, ...appended];
updateAt(pkgIndex, { imageUrls: next, imageUrl: next[0] ?? '' });
} finally {
setUploadingIndex(null);
const input = fileRefs.current[pkgIndex];
if (input) input.value = '';
}
}
function updateAt(index: number, patch: Partial<PackageFormItem>) {
onChange(items.map((item, i) => (i === index ? { ...item, ...patch } : item)));
}
function addItem() {
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
onChange([...items, emptyPackage(items.length)]);
}
function removeAt(index: number) {
onChange(items.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })));
setCollapsed((prev) => {
const next: Record<number, boolean> = {};
Object.entries(prev).forEach(([k, v]) => {
const i = Number(k);
if (i < index) next[i] = v;
else if (i > index) next[i - 1] = v;
});
return next;
});
}
function toggleCollapse(index: number) {
setCollapsed((prev) => ({ ...prev, [index]: !prev[index] }));
}
const list = items.length ? items : [emptyPackage(0)];
return (
<div className="shop-packages-form">
{list.map((item, index) => {
const isCollapsed = !!collapsed[index];
const displayName = item.name.trim() || `套餐 ${index + 1}`;
const filled =
Array.isArray(item.imageUrls) && item.imageUrls.length > 0
? item.imageUrls.map((u) => String(u ?? '')).filter((u) => u.trim())
: item.imageUrl
? [String(item.imageUrl)]
: [];
return (
<section key={index} className={`shop-packages-card${isCollapsed ? ' shop-packages-card--collapsed' : ''}`}>
<div className="shop-packages-card-head">
<button
type="button"
className="shop-packages-toggle"
onClick={() => toggleCollapse(index)}
aria-expanded={!isCollapsed}
aria-label={isCollapsed ? '展开套餐' : '收起套餐'}
>
<span className="material-symbols-outlined">
{isCollapsed ? 'expand_more' : 'expand_less'}
</span>
<span className="shop-packages-card-title">{displayName}</span>
</button>
{!disabled && list.length > 1 ? (
<button type="button" className="shop-packages-remove" onClick={() => removeAt(index)}>
删除
</button>
) : null}
</div>
{!isCollapsed ? (
<>
<label className="shop-packages-field">
<span className="shop-packages-label">套餐名称</span>
<input
className="shop-packages-input"
placeholder="如:套餐A"
value={item.name}
disabled={disabled}
onChange={(e) => updateAt(index, { name: e.target.value })}
/>
</label>
<label className="shop-packages-field">
<span className="shop-packages-label">价格(元)</span>
<input
className="shop-packages-input"
type="number"
min={0}
step="0.01"
placeholder="198"
value={item.price}
disabled={disabled}
onChange={(e) => updateAt(index, { price: e.target.value })}
/>
</label>
<label className="shop-packages-field">
<span className="shop-packages-label">菜品</span>
<textarea
className="shop-packages-input"
rows={2}
placeholder="红烧肉、红烧鱼、油焖茄子"
value={item.dishes}
disabled={disabled}
onChange={(e) => updateAt(index, { dishes: e.target.value })}
/>
</label>
<label className="shop-packages-field">
<span className="shop-packages-label">使用时间</span>
<input
className="shop-packages-input"
placeholder="节假日除外"
value={item.usableTime || ''}
disabled={disabled}
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
/>
</label>
<div className="shop-packages-field">
<span className="shop-packages-label">
套餐图片(最多 {STORE_PACKAGE_IMAGE_MAX_COUNT} 张,支持批量)
</span>
{filled.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
{filled.map((url, imgIndex) => (
<div key={`${url}-${imgIndex}`} style={{ position: 'relative', width: 88 }}>
<img
src={url}
alt=""
style={{ width: 88, height: 88, objectFit: 'cover', borderRadius: 8 }}
/>
{!disabled ? (
<button
type="button"
className="shop-packages-remove"
style={{ position: 'absolute', top: 2, right: 2, margin: 0 }}
onClick={() => {
const next = filled.filter((_, i) => i !== imgIndex);
updateAt(index, { imageUrls: next, imageUrl: next[0] ?? '' });
}}
>
</button>
) : null}
</div>
))}
</div>
) : null}
<input
ref={(el) => {
fileRefs.current[index] = el;
}}
type="file"
accept="image/*"
multiple
hidden
disabled={disabled || filled.length >= STORE_PACKAGE_IMAGE_MAX_COUNT}
onChange={(e) => void pickPackageImages(index, e.target.files)}
/>
<button
type="button"
className="shop-packages-add"
style={{ marginTop: 0 }}
disabled={disabled || uploadingIndex === index || filled.length >= STORE_PACKAGE_IMAGE_MAX_COUNT}
onClick={() => fileRefs.current[index]?.click()}
>
{uploadingIndex === index
? '上传中…'
: `批量上传(${filled.length}/${STORE_PACKAGE_IMAGE_MAX_COUNT}`}
</button>
</div>
<label className="shop-packages-field">
<span className="shop-packages-label">其他说明</span>
<input
className="shop-packages-input"
placeholder="不可叠加"
value={item.otherNotes || ''}
disabled={disabled}
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
/>
</label>
</>
) : null}
</section>
);
})}
{!disabled && list.length < STORE_PACKAGE_MAX_COUNT ? (
<button type="button" className="shop-packages-add" onClick={addItem}>
<span className="material-symbols-outlined">add_circle</span>
添加套餐
</button>
) : null}
</div>
);
}