diff --git a/apps/web-antd/src/views/product/detail/composables/product-detail-page/data-actions.ts b/apps/web-antd/src/views/product/detail/composables/product-detail-page/data-actions.ts index c67d797..187e2f1 100644 --- a/apps/web-antd/src/views/product/detail/composables/product-detail-page/data-actions.ts +++ b/apps/web-antd/src/views/product/detail/composables/product-detail-page/data-actions.ts @@ -35,6 +35,14 @@ import { normalizeSkuRows, } from './helpers'; +const MAX_PRODUCT_IMAGE_COUNT = 5; +const ALLOWED_PRODUCT_IMAGE_MIME_SET = new Set([ + 'image/jpeg', + 'image/png', + 'image/webp', +]); +const ALLOWED_PRODUCT_IMAGE_EXT = ['.jpg', '.jpeg', '.png', '.webp']; + interface CreateProductDetailDataActionsOptions { addonGroupOptions: Ref; buildSkuRows: () => void; @@ -94,7 +102,7 @@ export function createProductDetailDataActions( form.imageUrls = dedupeTextList([ ...(data.imageUrls || []), data.imageUrl, - ]).slice(0, 5); + ]).slice(0, MAX_PRODUCT_IMAGE_COUNT); form.price = Number(data.price || 0); form.originalPrice = @@ -158,22 +166,82 @@ export function createProductDetailDataActions( form.labelIds = [...selected]; } - async function uploadImage(file: File) { + function isAllowedProductImageFile(file: File) { + const mimeType = String(file.type || '').trim().toLowerCase(); + if (mimeType && ALLOWED_PRODUCT_IMAGE_MIME_SET.has(mimeType)) { + return true; + } + + const lowerName = String(file.name || '').trim().toLowerCase(); + return ALLOWED_PRODUCT_IMAGE_EXT.some((ext) => lowerName.endsWith(ext)); + } + + async function uploadImages(files: File[]) { + if (files.length === 0) return; + + if (form.imageUrls.length >= MAX_PRODUCT_IMAGE_COUNT) { + message.warning(`最多上传 ${MAX_PRODUCT_IMAGE_COUNT} 张图片`); + return; + } + + const invalidFiles = files.filter((file) => !isAllowedProductImageFile(file)); + if (invalidFiles.length > 0) { + message.warning('仅支持 JPG/PNG/WEBP 格式'); + } + + const validFiles = files.filter((file) => isAllowedProductImageFile(file)); + if (validFiles.length === 0) return; + + const remainCount = MAX_PRODUCT_IMAGE_COUNT - form.imageUrls.length; + let uploadQueue = validFiles; + if (validFiles.length > remainCount) { + uploadQueue = validFiles.slice(0, remainCount); + message.warning( + `最多上传 ${MAX_PRODUCT_IMAGE_COUNT} 张图片,本次仅上传前 ${remainCount} 张`, + ); + } + + let successCount = 0; + let failedCount = 0; + isUploadingImage.value = true; try { - const uploaded = await uploadTenantFileApi(file, 'dish_image'); - const url = String(uploaded.url || '').trim(); - if (!url) { - message.error('图片上传失败'); - return; + for (const file of uploadQueue) { + try { + const uploaded = await uploadTenantFileApi(file, 'dish_image'); + const url = String(uploaded.url || '').trim(); + if (!url) { + failedCount += 1; + continue; + } + form.imageUrls = dedupeTextList([...form.imageUrls, url]).slice( + 0, + MAX_PRODUCT_IMAGE_COUNT, + ); + successCount += 1; + } catch (error) { + failedCount += 1; + console.error(error); + } } - form.imageUrls = dedupeTextList([...form.imageUrls, url]).slice(0, 5); - message.success('图片上传成功'); - } catch (error) { - console.error(error); } finally { isUploadingImage.value = false; } + + if (successCount > 0) { + message.success( + successCount === 1 ? '图片上传成功' : `已上传 ${successCount} 张图片`, + ); + } + if (failedCount > 0) { + message.error( + failedCount === 1 ? '1 张图片上传失败' : `${failedCount} 张图片上传失败`, + ); + } + } + + async function uploadImage(file: File) { + await uploadImages([file]); } function removeImage(index: number) { @@ -405,5 +473,6 @@ export function createProductDetailDataActions( toggleLabel, toggleSaleStatus, uploadImage, + uploadImages, }; } diff --git a/apps/web-antd/src/views/product/detail/composables/useProductDetailPage.ts b/apps/web-antd/src/views/product/detail/composables/useProductDetailPage.ts index 821b377..2dbf63e 100644 --- a/apps/web-antd/src/views/product/detail/composables/useProductDetailPage.ts +++ b/apps/web-antd/src/views/product/detail/composables/useProductDetailPage.ts @@ -177,5 +177,6 @@ export function useProductDetailPage() { toggleSaleStatus: dataActions.toggleSaleStatus, toggleSpecTemplate: skuActions.toggleSpecTemplate, uploadImage: dataActions.uploadImage, + uploadImages: dataActions.uploadImages, }; } diff --git a/apps/web-antd/src/views/product/detail/index.vue b/apps/web-antd/src/views/product/detail/index.vue index a2f511b..2bcc453 100644 --- a/apps/web-antd/src/views/product/detail/index.vue +++ b/apps/web-antd/src/views/product/detail/index.vue @@ -23,7 +23,6 @@ import { Input, InputNumber, Modal, - Radio, Select, Spin, Switch, @@ -83,7 +82,7 @@ const { toggleLabel, toggleSaleStatus, toggleSpecTemplate, - uploadImage, + uploadImages, } = useProductDetailPage(); const activeSectionId = ref('detail-basic'); @@ -119,8 +118,40 @@ const timedOnShelfValue = computed({ }, }); -const beforeUpload: UploadProps['beforeUpload'] = async (file) => { - await uploadImage(file as File); +let pendingUploadBatchSignature = ''; + +const beforeUpload: UploadProps['beforeUpload'] = async (file, fileList) => { + const selectedFiles = (fileList?.length ? fileList : [file]) + .map((item) => { + const uploadFile = item as { originFileObj?: File }; + return uploadFile.originFileObj ?? (item as File); + }) + .filter((item): item is File => item instanceof File); + if (selectedFiles.length === 0) return false; + + const batchSignature = selectedFiles + .map((item) => `${item.name}:${item.size}:${item.lastModified}`) + .join('|'); + if (batchSignature && batchSignature === pendingUploadBatchSignature) { + return false; + } + + const currentUid = String((file as { uid?: string }).uid || ''); + const firstUid = String( + (fileList?.[0] as undefined | { uid?: string })?.uid || '', + ); + if (currentUid && firstUid && currentUid !== firstUid) { + return false; + } + + pendingUploadBatchSignature = batchSignature; + try { + await uploadImages(selectedFiles); + } finally { + if (pendingUploadBatchSignature === batchSignature) { + pendingUploadBatchSignature = ''; + } + } return false; }; @@ -289,53 +320,56 @@ watch( -
- -
- + -
-
-
- 建议尺寸 750×750,最多 5 张,首张为主图 + +
+ + +

+ {{ + maxUploadReached + ? '已达到最多 5 张' + : '点击或拖拽上传图片' + }} +

+

+ 建议尺寸 750×750,最多 5 张,首张为主图 +

-
+ -
+
product-image 主图 -
- +
已启用多规格,售价和库存以下方 SKU 管理中的组合为准 @@ -343,8 +377,8 @@ watch(
-
- ¥ +
+ ¥ +
-
- ¥ +
+ ¥ +
-
+
+ ¥ - +
-
+
+ ¥ - +
-
- ¥ +
+ ¥ - /份 + /份
@@ -746,10 +784,24 @@ watch(
- - 单品 - 套餐 - +
+ + +
@@ -790,21 +842,21 @@ watch(
定时上架
到达指定时间后自动上架
-
- 上架时间 - -
+
+ 上架时间 + +
diff --git a/apps/web-antd/src/views/product/detail/styles/index.less b/apps/web-antd/src/views/product/detail/styles/index.less index 01f0130..6deeb24 100644 --- a/apps/web-antd/src/views/product/detail/styles/index.less +++ b/apps/web-antd/src/views/product/detail/styles/index.less @@ -161,60 +161,77 @@ color: #9ca3af; } - .pd-upload-row { - display: flex; - gap: 12px; - align-items: center; + .pd-upload-dragger { + .ant-upload-drag { + min-height: 160px; + padding: 24px 16px; + background: #fafafa; + border: 1px dashed #d9d9d9; + border-radius: 14px; + transition: all 0.2s ease; + } + + .ant-upload-btn { + padding: 0; + } } - .pd-upload-trigger { - display: inline-flex; - align-items: center; - justify-content: center; - width: 88px; - height: 88px; - font-size: 28px; - color: #9ca3af; - cursor: pointer; - background: #f8f9fb; - border: 1px dashed #d1d5db; - border-radius: 8px; - transition: all 0.2s ease; - } - - .pd-upload-trigger:hover { - color: #1677ff; - background: #f3f8ff; + .pd-upload-dragger:not(.is-disabled):hover .ant-upload-drag { + background: #f6f9ff; border-color: #1677ff; } - .pd-upload-trigger.disabled { - color: #d1d5db; + .pd-upload-dragger.is-disabled .ant-upload-drag { cursor: not-allowed; - background: #f3f4f6; + background: #f7f7f7; border-color: #e5e7eb; } - .pd-upload-hint { - font-size: 12px; + .pd-upload-dragger-inner { + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; + justify-content: center; + min-height: 110px; + text-align: center; + } + + .pd-upload-dragger-plus { + font-size: 48px; + line-height: 1; + color: #9ca3af; + } + + .pd-upload-dragger-title { + margin: 0; + font-size: 14px; + color: #6b7280; + } + + .pd-upload-dragger-hint { + margin: 0; + font-size: 13px; color: #9ca3af; } .pd-thumbs { display: flex; flex-wrap: wrap; - gap: 10px; - margin-top: 12px; + gap: 14px; + margin-top: 16px; } .pd-thumb { position: relative; - width: 88px; - height: 88px; + width: 120px; + height: 120px; overflow: hidden; + cursor: pointer; background: #f8f9fb; border: 1px solid #e5e7eb; - border-radius: 8px; + border-radius: 12px; + transition: all 0.2s ease; } .pd-thumb img { @@ -226,7 +243,7 @@ .pd-thumb-main { border-color: #1677ff; - box-shadow: 0 0 0 1px #1677ff; + box-shadow: 0 0 0 2px rgb(22 119 255 / 25%); } .pd-thumb-badge { @@ -234,9 +251,10 @@ right: 0; bottom: 0; left: 0; - height: 18px; - font-size: 11px; - line-height: 18px; + height: 26px; + font-size: 13px; + font-weight: 600; + line-height: 26px; color: #fff; text-align: center; background: #1677ff; @@ -244,11 +262,11 @@ .pd-thumb-del { position: absolute; - top: -6px; - right: -6px; - width: 18px; - height: 18px; - font-size: 12px; + top: -10px; + right: -10px; + width: 28px; + height: 28px; + font-size: 18px; line-height: 1; color: #fff; cursor: pointer; @@ -257,19 +275,6 @@ border-radius: 50%; } - .pd-thumb-set-main { - position: absolute; - right: 0; - bottom: 0; - left: 0; - height: 20px; - font-size: 11px; - color: #fff; - cursor: pointer; - background: rgb(0 0 0 / 45%); - border: none; - } - .pd-price-alert { padding: 8px 12px; margin-bottom: 12px; @@ -280,6 +285,31 @@ border-radius: 8px; } + .pd-pricing-card .pd-ctrl { + flex: 0 0 auto; + width: 460px; + max-width: 100%; + } + + .pd-pricing-inline { + display: grid; + grid-template-columns: 18px minmax(0, 1fr) 34px; + gap: 8px; + align-items: center; + width: 100%; + } + + .pd-pricing-unit { + font-size: 12px; + color: #9ca3af; + text-align: center; + white-space: nowrap; + } + + .pd-pricing-unit.is-empty { + visibility: hidden; + } + .pd-pills { display: flex; flex-wrap: wrap; @@ -367,7 +397,9 @@ } .pd-table-wrap { - overflow-x: auto; + max-height: 520px; + overflow: auto; + border-radius: 8px; } .pd-sku-table { @@ -377,6 +409,9 @@ } .pd-sku-table th { + position: sticky; + top: 0; + z-index: 2; padding: 9px 12px; font-size: 12px; font-weight: 500; @@ -538,15 +573,50 @@ .pd-shelf-group { display: flex; - flex-direction: column; - gap: 10px; + gap: 8px; + align-items: flex-start; + } + + .pd-kind-pills { + display: inline-flex; + gap: 8px; + align-items: center; + } + + .pd-kind-pill { + min-width: 64px; + height: 34px; + padding: 0 14px; + font-size: 13px; + color: #4b5563; + cursor: pointer; + background: #fff; + border: 1px solid #d1d5db; + border-radius: 999px; + transition: all 0.2s ease; + } + + .pd-kind-pill:hover { + color: #1677ff; + background: #f8fbff; + border-color: #8ec5ff; + } + + .pd-kind-pill.active { + font-weight: 600; + color: #1677ff; + background: rgb(22 119 255 / 8%); + border-color: #1677ff; } .pd-shelf-opt { display: flex; - gap: 8px; + flex: 1; + gap: 6px; align-items: flex-start; - padding: 12px; + min-width: 0; + min-height: 0; + padding: 9px 10px; cursor: pointer; border: 1px solid #e5e7eb; border-radius: 10px; @@ -563,31 +633,35 @@ } .pd-shelf-opt input[type='radio'] { - margin-top: 3px; + margin-top: 1px; accent-color: #1677ff; } .pd-shelf-opt-body { flex: 1; + min-width: 0; } .pd-shelf-opt-title { - font-size: 13px; + font-size: 12px; font-weight: 600; + line-height: 1.35; color: #1a1a2e; } .pd-shelf-opt-desc { - margin-top: 2px; - font-size: 12px; + margin-top: 0; + font-size: 11px; + line-height: 1.3; color: #9ca3af; } - .pd-shelf-time { + .pd-shelf-time-row { display: flex; gap: 8px; align-items: center; - margin-top: 8px; + max-width: 420px; + margin-top: 10px; } .pd-save-bar { @@ -680,5 +754,43 @@ flex-direction: column; align-items: stretch; } + + .pd-pricing-card .pd-ctrl { + width: 100%; + } + + .pd-table-wrap { + max-height: 420px; + } + + .pd-kind-pills { + width: 100%; + } + + .pd-kind-pill { + flex: 1; + min-width: 0; + } + + .pd-shelf-group { + flex-direction: column; + } + + .pd-upload-dragger .ant-upload-drag { + min-height: 140px; + padding: 16px 12px; + } + + .pd-thumb { + width: 96px; + height: 96px; + border-radius: 10px; + } + + .pd-thumb-badge { + height: 22px; + font-size: 12px; + line-height: 22px; + } } }