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:
@@ -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">
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
|
||||
import { enqueueUpload } from '../lib/upload-lock';
|
||||
import { formatChooseImageFailMessage } from '@dukang/weixin-sdk';
|
||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||
import { toastError, toastSuccess } from '../lib/toast';
|
||||
|
||||
type Props = {
|
||||
value?: string[];
|
||||
onChange?: (urls: string[]) => void;
|
||||
bizType: string;
|
||||
mediaType?: OssMediaType;
|
||||
maxCount?: number;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
function isCancelError(msg: string): boolean {
|
||||
return /cancel|取消/i.test(msg);
|
||||
}
|
||||
|
||||
function normalizeUrls(value?: string[]) {
|
||||
return (value ?? []).map((u) => String(u || '').trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/** 合伙人端多图批量上传(微信相册可多选) */
|
||||
export default function MultiOssUploadField({
|
||||
value,
|
||||
onChange,
|
||||
bizType,
|
||||
mediaType = 'IMAGE',
|
||||
maxCount = 20,
|
||||
disabled,
|
||||
label,
|
||||
}: Props) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const pickingRef = useRef(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const urls = normalizeUrls(value);
|
||||
const remaining = Math.max(0, maxCount - urls.length);
|
||||
const inWechat = isWechatEnv();
|
||||
|
||||
useEffect(() => {
|
||||
if (!inWechat) return;
|
||||
void weixinSdk.init().catch(() => {});
|
||||
}, [inWechat]);
|
||||
|
||||
function showUploadError(text: string) {
|
||||
setError(text);
|
||||
toastError(text);
|
||||
}
|
||||
|
||||
async function uploadFiles(files: File[]) {
|
||||
const picked = files.slice(0, remaining);
|
||||
if (!picked.length) {
|
||||
showUploadError(`最多 ${maxCount} 张`);
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
setError('');
|
||||
const appended: string[] = [];
|
||||
try {
|
||||
for (const file of picked) {
|
||||
if (!file.size) continue;
|
||||
const result = await enqueueUpload(() => uploadFileToOss(file, { bizType, mediaType }));
|
||||
appended.push(result.url);
|
||||
}
|
||||
if (appended.length) {
|
||||
onChange?.([...urls, ...appended]);
|
||||
toastSuccess(`已上传 ${appended.length} 张`);
|
||||
}
|
||||
} catch (e) {
|
||||
showUploadError(e instanceof Error ? e.message : '上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function pickWechat() {
|
||||
if (pickingRef.current || uploading || disabled || remaining <= 0) return;
|
||||
pickingRef.current = true;
|
||||
setError('');
|
||||
try {
|
||||
await weixinSdk.init();
|
||||
const files = await weixinSdk.chooseImages({
|
||||
count: Math.min(remaining, 9),
|
||||
sourceType: ['album', 'camera'],
|
||||
});
|
||||
if (!files?.length) return;
|
||||
await uploadFiles(files);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '无法打开相册';
|
||||
if (isCancelError(msg)) return;
|
||||
const formatted = formatChooseImageFailMessage(msg) || msg;
|
||||
showUploadError(formatted);
|
||||
inputRef.current?.click();
|
||||
} finally {
|
||||
pickingRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeAt(index: number) {
|
||||
if (disabled) return;
|
||||
onChange?.(urls.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="partner-oss-upload">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="partner-oss-upload-input"
|
||||
disabled={disabled || uploading || remaining <= 0}
|
||||
onChange={(e) => {
|
||||
const list = Array.from(e.target.files ?? []);
|
||||
if (list.length) void uploadFiles(list);
|
||||
}}
|
||||
/>
|
||||
|
||||
{urls.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
|
||||
{urls.map((url, index) => (
|
||||
<div key={`${url}-${index}`} style={{ position: 'relative', width: 88, height: 88 }}>
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
style={{ width: 88, height: 88, objectFit: 'cover', borderRadius: 8 }}
|
||||
/>
|
||||
{!disabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className="partner-packages-remove"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
right: 2,
|
||||
margin: 0,
|
||||
padding: '2px 6px',
|
||||
fontSize: 12,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
color: '#fff',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
onClick={() => removeAt(index)}
|
||||
>
|
||||
删
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="partner-upload-dashed partner-upload-dashed--compact"
|
||||
disabled={disabled || uploading || remaining <= 0}
|
||||
onClick={() => {
|
||||
if (inWechat) void pickWechat();
|
||||
else inputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 28 }}>
|
||||
{uploading ? 'hourglass_top' : 'add_a_photo'}
|
||||
</span>
|
||||
<span className="text-primary" style={{ fontWeight: 500 }}>
|
||||
{uploading
|
||||
? '上传中…'
|
||||
: remaining <= 0
|
||||
? `已达上限 ${maxCount}`
|
||||
: label ?? `批量上传(${urls.length}/${maxCount})`}
|
||||
</span>
|
||||
</button>
|
||||
{error ? (
|
||||
<p className="partner-form-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import type { PackageFormItem } from '../lib/storePackages';
|
||||
import { STORE_PACKAGE_IMAGE_MAX_COUNT, STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||
import { emptyPackage } from '../lib/storePackages';
|
||||
import OssUploadField from './OssUploadField';
|
||||
import MultiOssUploadField from './MultiOssUploadField';
|
||||
|
||||
type Props = {
|
||||
items: PackageFormItem[];
|
||||
@@ -131,70 +131,25 @@ export default function StorePackagesForm({ items, onChange, disabled, embedded
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
<label>套餐图片(最多 {STORE_PACKAGE_IMAGE_MAX_COUNT} 张)</label>
|
||||
{(() => {
|
||||
const slots =
|
||||
<label>套餐图片(最多 {STORE_PACKAGE_IMAGE_MAX_COUNT} 张,支持批量)</label>
|
||||
<MultiOssUploadField
|
||||
bizType="STORE_PACKAGE"
|
||||
maxCount={STORE_PACKAGE_IMAGE_MAX_COUNT}
|
||||
disabled={disabled}
|
||||
value={
|
||||
Array.isArray(item.imageUrls) && item.imageUrls.length > 0
|
||||
? item.imageUrls.map((u) => String(u ?? ''))
|
||||
? item.imageUrls.map((u) => String(u ?? '')).filter((u) => u.trim())
|
||||
: 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>
|
||||
);
|
||||
})()}
|
||||
: []
|
||||
}
|
||||
onChange={(imageUrls) =>
|
||||
updateAt(index, {
|
||||
imageUrls,
|
||||
imageUrl: imageUrls[0] ?? '',
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import ChinaRegionPicker from '../components/ChinaRegionPicker';
|
||||
|
||||
import OssUploadField from '../components/OssUploadField';
|
||||
import MultiOssUploadField from '../components/MultiOssUploadField';
|
||||
|
||||
import { request } from '../lib/api';
|
||||
import { toastError, toastSuccess } from '../lib/toast';
|
||||
@@ -40,8 +41,6 @@ import {
|
||||
|
||||
validateStoreStep3,
|
||||
|
||||
patchEnvPhotoAt,
|
||||
addEnvPhotoSlot,
|
||||
MIN_ENV_PHOTO_COUNT,
|
||||
} from '../lib/storeDraft';
|
||||
import StorePackagesForm from '../components/StorePackagesForm';
|
||||
@@ -250,22 +249,6 @@ export default function StoreCreatePage() {
|
||||
|
||||
|
||||
|
||||
function patchEnvPhotoUrl(index: number, url: string) {
|
||||
|
||||
setForm((prev) => ({
|
||||
|
||||
...prev,
|
||||
|
||||
envPhotoUrls: patchEnvPhotoAt(prev.envPhotoUrls, index, url),
|
||||
|
||||
}));
|
||||
|
||||
setSubmitError('');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function bindRegionSelection(codes: string[]) {
|
||||
|
||||
const binding = resolveRegionBinding(codes, cities);
|
||||
@@ -957,46 +940,15 @@ export default function StoreCreatePage() {
|
||||
|
||||
|
||||
<section className="partner-form-card">
|
||||
|
||||
<h3 className="headline-md">环境照片 <span className="text-primary">*</span></h3>
|
||||
|
||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>至少 {MIN_ENV_PHOTO_COUNT} 张,展示店内整洁环境</p>
|
||||
|
||||
<div className="partner-upload-grid">
|
||||
|
||||
{form.envPhotoUrls.map((url, index) => (
|
||||
|
||||
<OssUploadField
|
||||
|
||||
key={index}
|
||||
|
||||
compact
|
||||
|
||||
bizType="STORE_ENV"
|
||||
|
||||
mediaType="IMAGE"
|
||||
|
||||
value={url}
|
||||
|
||||
onChange={(nextUrl) => patchEnvPhotoUrl(index, nextUrl)}
|
||||
|
||||
/>
|
||||
|
||||
))}
|
||||
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-outline"
|
||||
style={{ width: '100%', marginTop: 12 }}
|
||||
onClick={() =>
|
||||
setForm((prev) => ({ ...prev, envPhotoUrls: addEnvPhotoSlot(prev.envPhotoUrls) }))
|
||||
}
|
||||
>
|
||||
添加环境照片
|
||||
</button>
|
||||
|
||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>至少 {MIN_ENV_PHOTO_COUNT} 张,支持批量上传,最多 20 张</p>
|
||||
<MultiOssUploadField
|
||||
bizType="STORE_ENV"
|
||||
maxCount={20}
|
||||
value={form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)}
|
||||
onChange={(urls) => setForm((prev) => ({ ...prev, envPhotoUrls: urls.length ? urls : [''] }))}
|
||||
label={`批量上传(${form.envPhotoUrls.map((u) => u.trim()).filter(Boolean).length}/20)`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ import { request } from '../lib/api';
|
||||
import { toastError, toastSuccess } from '../lib/toast';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { canManagePartnerStore } from '../lib/partnerAccess';
|
||||
import { MIN_ENV_PHOTO_COUNT, normalizeStringArray, patchEnvPhotoAt, addEnvPhotoSlot } from '../lib/storeDraft';
|
||||
import { MIN_ENV_PHOTO_COUNT, normalizeStringArray } from '../lib/storeDraft';
|
||||
import OssUploadField from '../components/OssUploadField';
|
||||
import MultiOssUploadField from '../components/MultiOssUploadField';
|
||||
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
|
||||
import {
|
||||
canPartnerOpenStore,
|
||||
@@ -409,26 +410,13 @@ export default function StoreDetailPage() {
|
||||
</div>
|
||||
{canMutate && !readOnly ? (
|
||||
<>
|
||||
<div className="partner-upload-grid">
|
||||
{envPhotoUrls.map((url, index) => (
|
||||
<OssUploadField
|
||||
key={index}
|
||||
compact
|
||||
bizType="STORE_ENV"
|
||||
mediaType="IMAGE"
|
||||
value={url}
|
||||
onChange={(nextUrl) => setEnvPhotoUrls((prev) => patchEnvPhotoAt(prev, index, nextUrl))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-outline"
|
||||
style={{ width: '100%', marginTop: 12 }}
|
||||
onClick={() => setEnvPhotoUrls((prev) => addEnvPhotoSlot(prev))}
|
||||
>
|
||||
添加环境照片
|
||||
</button>
|
||||
<MultiOssUploadField
|
||||
bizType="STORE_ENV"
|
||||
maxCount={20}
|
||||
value={uniqueEnvUrls(envPhotoUrls)}
|
||||
onChange={(urls) => setEnvPhotoUrls(urls.length ? urls : [''])}
|
||||
label={`批量上传环境照(${uniqueEnvUrls(envPhotoUrls).length}/20)`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-outline"
|
||||
|
||||
@@ -14,29 +14,35 @@ type Props = {
|
||||
|
||||
export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
|
||||
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
|
||||
const [uploadingKey, setUploadingKey] = useState<string | null>(null);
|
||||
const fileRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
const [uploadingIndex, setUploadingIndex] = useState<number | null>(null);
|
||||
const fileRefs = useRef<Record<number, HTMLInputElement | null>>({});
|
||||
|
||||
async function pickPackageImage(pkgIndex: number, imgIndex: number, file?: File | null) {
|
||||
if (!file || disabled) return;
|
||||
const key = `${pkgIndex}-${imgIndex}`;
|
||||
setUploadingKey(key);
|
||||
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 result = await uploadFileToOss(file, 'STORE_PACKAGE');
|
||||
const current = items[pkgIndex];
|
||||
const imageUrls =
|
||||
Array.isArray(current.imageUrls) && current.imageUrls.length > 0
|
||||
? current.imageUrls.map((u) => String(u ?? ''))
|
||||
: current.imageUrl
|
||||
? [String(current.imageUrl)]
|
||||
: [];
|
||||
const next = [...imageUrls];
|
||||
if (imgIndex < next.length) next[imgIndex] = result.url;
|
||||
else next.push(result.url);
|
||||
const cleaned = next.map((u) => u.trim()).filter(Boolean);
|
||||
updateAt(pkgIndex, { imageUrls: next, imageUrl: cleaned[0] ?? '' });
|
||||
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 {
|
||||
setUploadingKey(null);
|
||||
setUploadingIndex(null);
|
||||
const input = fileRefs.current[pkgIndex];
|
||||
if (input) input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,13 +79,12 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
|
||||
{list.map((item, index) => {
|
||||
const isCollapsed = !!collapsed[index];
|
||||
const displayName = item.name.trim() || `套餐 ${index + 1}`;
|
||||
const imageUrls = Array.isArray(item.imageUrls) && item.imageUrls.length > 0
|
||||
? item.imageUrls.map((u) => String(u ?? ''))
|
||||
: item.imageUrl
|
||||
? [String(item.imageUrl)]
|
||||
: [''];
|
||||
const slots = imageUrls;
|
||||
const filled = slots.map((u) => u.trim()).filter(Boolean);
|
||||
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">
|
||||
@@ -153,76 +158,57 @@ export default function ShopPackagesForm({ items, onChange, disabled }: Props) {
|
||||
</label>
|
||||
|
||||
<div className="shop-packages-field">
|
||||
<span className="shop-packages-label">套餐图片(最多 {STORE_PACKAGE_IMAGE_MAX_COUNT} 张)</span>
|
||||
{slots.map((url, imgIndex) => {
|
||||
const key = `${index}-${imgIndex}`;
|
||||
return (
|
||||
<div key={key} style={{ marginBottom: 12 }}>
|
||||
{url ? (
|
||||
<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: '100%',
|
||||
maxHeight: 160,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 8,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
style={{ width: 88, height: 88, objectFit: 'cover', borderRadius: 8 }}
|
||||
/>
|
||||
) : null}
|
||||
<input
|
||||
ref={(el) => {
|
||||
fileRefs.current[key] = el;
|
||||
}}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
disabled={disabled}
|
||||
onChange={(e) => void pickPackageImage(index, imgIndex, e.target.files?.[0])}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-packages-add"
|
||||
style={{ marginTop: 0, flex: 1 }}
|
||||
disabled={disabled || uploadingKey === key}
|
||||
onClick={() => fileRefs.current[key]?.click()}
|
||||
>
|
||||
{uploadingKey === key ? '上传中…' : url ? '更换图片' : '上传图片'}
|
||||
</button>
|
||||
{!disabled && (filled.length > 0 || url) ? (
|
||||
{!disabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shop-packages-remove"
|
||||
style={{ position: 'absolute', top: 2, right: 2, margin: 0 }}
|
||||
onClick={() => {
|
||||
const next = slots.filter((_, i) => i !== imgIndex);
|
||||
const cleaned = next.map((u) => u.trim()).filter(Boolean);
|
||||
updateAt(index, { imageUrls: next, imageUrl: cleaned[0] ?? '' });
|
||||
const next = filled.filter((_, i) => i !== imgIndex);
|
||||
updateAt(index, { imageUrls: next, imageUrl: next[0] ?? '' });
|
||||
}}
|
||||
>
|
||||
删除
|
||||
删
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!disabled && filled.length < STORE_PACKAGE_IMAGE_MAX_COUNT && slots.length < STORE_PACKAGE_IMAGE_MAX_COUNT ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shop-packages-add"
|
||||
style={{ marginTop: 0 }}
|
||||
onClick={() =>
|
||||
updateAt(index, {
|
||||
imageUrls: [...slots, ''],
|
||||
imageUrl: filled[0] ?? '',
|
||||
})
|
||||
}
|
||||
>
|
||||
添加图片({filled.length}/{STORE_PACKAGE_IMAGE_MAX_COUNT})
|
||||
</button>
|
||||
))}
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user