diff --git a/apps/admin-web/src/components/DetailImageUrlList.tsx b/apps/admin-web/src/components/DetailImageUrlList.tsx index b430ba0..c5e52c9 100644 --- a/apps/admin-web/src/components/DetailImageUrlList.tsx +++ b/apps/admin-web/src/components/DetailImageUrlList.tsx @@ -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 && ( - - 最多 {maxCount} 张{label} - - )} - - {(fields, { add, remove }) => ( - <> - {fields.map((field) => ( - - - - - {fields.length > 1 && ( - remove(field.name)} style={{ marginTop: 8 }} /> - )} - - ))} - {(!maxCount || fields.length < maxCount) && ( - - )} - - )} - + + {label}:支持批量上传{maxCount != null ? `,最多 ${maxCount} 张` : ''} + + ); } diff --git a/apps/admin-web/src/components/MultiImageUpload.tsx b/apps/admin-web/src/components/MultiImageUpload.tsx new file mode 100644 index 0000000..ff80dec --- /dev/null +++ b/apps/admin-web/src/components/MultiImageUpload.tsx @@ -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([]); + + 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 ( + + + {tip ?? + (maxCount != null + ? `最多 ${maxCount} 张,支持一次选择多张批量上传` + : '支持一次选择多张批量上传')} + {maxCount != null ? `(已选 ${urls.length}/${maxCount})` : urls.length ? `(已选 ${urls.length})` : ''} + + + {urls.length > 0 ? ( + + + {urls.map((url, index) => ( +
+ +
+ ))} +
+
+ ) : null} + + + + +
+ ); +} diff --git a/apps/admin-web/src/components/PackageImagesUpload.tsx b/apps/admin-web/src/components/PackageImagesUpload.tsx index 656f55a..47bccb8 100644 --- a/apps/admin-web/src/components/PackageImagesUpload.tsx +++ b/apps/admin-web/src/components/PackageImagesUpload.tsx @@ -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 ( - - - 最多 {STORE_PACKAGE_IMAGE_MAX_COUNT} 张,可替换或删除 - - {slots.map((url, index) => ( -
-
- updateAt(index, next)} - /> -
- {(urls.length > 0 || url) && ( - - )} -
- ))} - {urls.length < STORE_PACKAGE_IMAGE_MAX_COUNT ? ( - - ) : null} - {urls.length > 1 ? ( - - - {urls.map((u) => ( - - ))} - - - ) : null} -
+ ); } diff --git a/apps/admin-web/src/pages/ProductDetailTemplatesPage.tsx b/apps/admin-web/src/pages/ProductDetailTemplatesPage.tsx index 8b661c7..e466488 100644 --- a/apps/admin-web/src/pages/ProductDetailTemplatesPage.tsx +++ b/apps/admin-web/src/pages/ProductDetailTemplatesPage.tsx @@ -34,7 +34,7 @@ function mapToForm(d: Record) { 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 ( <> - 模板详情长图(套用商品时可逐张修改) - + 模板详情长图(套用商品时可逐张修改,支持批量上传) + + + @@ -228,7 +230,7 @@ export default function ProductDetailTemplatesPage() { }} width={640}>
diff --git a/apps/admin-web/src/pages/ProductsPage.tsx b/apps/admin-web/src/pages/ProductsPage.tsx index 899c62f..b3a8b6a 100644 --- a/apps/admin-web/src/pages/ProductsPage.tsx +++ b/apps/admin-web/src/pages/ProductsPage.tsx @@ -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) { 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 ( - - {(fields, { add, remove }) => ( - <> - {fields.map((field) => ( - - - - - {fields.length > 1 && ( - remove(field.name)} style={{ marginTop: 8 }} /> - )} - - ))} - - - )} - + + + ); } @@ -156,11 +145,13 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp <> - 详情页轮播(CAROUSEL,单张最大 10MB) + 详情页轮播(CAROUSEL,支持批量上传,单张最大 10MB) - 详情长图(DETAIL,可逐张修改,单张最大 10MB) - + 详情长图(DETAIL,支持批量上传,单张最大 10MB) + + + @@ -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: '' }], }}> - 环境照片 - - 建议至少 3 张;可替换、删除,最多 20 张 - - - {(fields, { add, remove }) => ( -
- {fields.map((field, index) => ( -
- - - - -
- ))} - {fields.length < 20 ? ( - - ) : ( - 已达上限 20 张 - )} -
- )} -
+ + + @@ -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() { - 环境照片 - - 至少 3 张,可继续添加,最多 20 张 - - - {(fields, { add, remove }) => ( -
- {fields.map((field, index) => ( -
- - - - -
- ))} - {fields.length < 20 ? ( - - ) : ( - 已达上限 20 张 - )} -
- )} -
+ + + diff --git a/apps/h5-partner/src/components/MultiOssUploadField.tsx b/apps/h5-partner/src/components/MultiOssUploadField.tsx new file mode 100644 index 0000000..e277bc0 --- /dev/null +++ b/apps/h5-partner/src/components/MultiOssUploadField.tsx @@ -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(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 ( +
+ { + const list = Array.from(e.target.files ?? []); + if (list.length) void uploadFiles(list); + }} + /> + + {urls.length > 0 ? ( +
+ {urls.map((url, index) => ( +
+ + {!disabled ? ( + + ) : null} +
+ ))} +
+ ) : null} + + + {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} diff --git a/apps/h5-partner/src/components/StorePackagesForm.tsx b/apps/h5-partner/src/components/StorePackagesForm.tsx index 962939a..25e678d 100644 --- a/apps/h5-partner/src/components/StorePackagesForm.tsx +++ b/apps/h5-partner/src/components/StorePackagesForm.tsx @@ -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
- - {(() => { - const slots = + + 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 ( -
- {slots.map((url, imgIndex) => ( -
- { - 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) ? ( - - ) : null} -
- ))} - {!disabled && filled.length < STORE_PACKAGE_IMAGE_MAX_COUNT && slots.length < STORE_PACKAGE_IMAGE_MAX_COUNT ? ( - - ) : null} -
- ); - })()} + : [] + } + onChange={(imageUrls) => + updateAt(index, { + imageUrls, + imageUrl: imageUrls[0] ?? '', + }) + } + />
diff --git a/apps/h5-partner/src/pages/StoreCreatePage.tsx b/apps/h5-partner/src/pages/StoreCreatePage.tsx index d9a2b26..280c311 100644 --- a/apps/h5-partner/src/pages/StoreCreatePage.tsx +++ b/apps/h5-partner/src/pages/StoreCreatePage.tsx @@ -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() {
-

环境照片 *

- -

至少 {MIN_ENV_PHOTO_COUNT} 张,展示店内整洁环境

- -
- - {form.envPhotoUrls.map((url, index) => ( - - patchEnvPhotoUrl(index, nextUrl)} - - /> - - ))} - -
- - - +

至少 {MIN_ENV_PHOTO_COUNT} 张,支持批量上传,最多 20 张

+ u.trim()).filter(Boolean)} + onChange={(urls) => setForm((prev) => ({ ...prev, envPhotoUrls: urls.length ? urls : [''] }))} + label={`批量上传(${form.envPhotoUrls.map((u) => u.trim()).filter(Boolean).length}/20)`} + />
diff --git a/apps/h5-partner/src/pages/StoreDetailPage.tsx b/apps/h5-partner/src/pages/StoreDetailPage.tsx index 516f0f0..e3fe98d 100644 --- a/apps/h5-partner/src/pages/StoreDetailPage.tsx +++ b/apps/h5-partner/src/pages/StoreDetailPage.tsx @@ -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() {
{canMutate && !readOnly ? ( <> -
- {envPhotoUrls.map((url, index) => ( - setEnvPhotoUrls((prev) => patchEnvPhotoAt(prev, index, nextUrl))} - /> - ))} -
- + setEnvPhotoUrls(urls.length ? urls : [''])} + label={`批量上传环境照(${uniqueEnvUrls(envPhotoUrls).length}/20)`} + /> - {!disabled && (filled.length > 0 || url) ? ( + {!disabled ? ( ) : null} - - ); - })} - {!disabled && filled.length < STORE_PACKAGE_IMAGE_MAX_COUNT && slots.length < STORE_PACKAGE_IMAGE_MAX_COUNT ? ( - + ))} + ) : null} + { + 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)} + /> +