feat: 商品详情图片支持多选上传与格式限制

This commit is contained in:
2026-02-24 09:34:08 +08:00
parent 37b3ef693d
commit 9e6d599c6b
4 changed files with 368 additions and 134 deletions

View File

@@ -35,6 +35,14 @@ import {
normalizeSkuRows, normalizeSkuRows,
} from './helpers'; } 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 { interface CreateProductDetailDataActionsOptions {
addonGroupOptions: Ref<ProductAddonGroupDto[]>; addonGroupOptions: Ref<ProductAddonGroupDto[]>;
buildSkuRows: () => void; buildSkuRows: () => void;
@@ -94,7 +102,7 @@ export function createProductDetailDataActions(
form.imageUrls = dedupeTextList([ form.imageUrls = dedupeTextList([
...(data.imageUrls || []), ...(data.imageUrls || []),
data.imageUrl, data.imageUrl,
]).slice(0, 5); ]).slice(0, MAX_PRODUCT_IMAGE_COUNT);
form.price = Number(data.price || 0); form.price = Number(data.price || 0);
form.originalPrice = form.originalPrice =
@@ -158,22 +166,82 @@ export function createProductDetailDataActions(
form.labelIds = [...selected]; 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; isUploadingImage.value = true;
try {
for (const file of uploadQueue) {
try { try {
const uploaded = await uploadTenantFileApi(file, 'dish_image'); const uploaded = await uploadTenantFileApi(file, 'dish_image');
const url = String(uploaded.url || '').trim(); const url = String(uploaded.url || '').trim();
if (!url) { if (!url) {
message.error('图片上传失败'); failedCount += 1;
return; continue;
} }
form.imageUrls = dedupeTextList([...form.imageUrls, url]).slice(0, 5); form.imageUrls = dedupeTextList([...form.imageUrls, url]).slice(
message.success('图片上传成功'); 0,
MAX_PRODUCT_IMAGE_COUNT,
);
successCount += 1;
} catch (error) { } catch (error) {
failedCount += 1;
console.error(error); console.error(error);
}
}
} finally { } finally {
isUploadingImage.value = false; 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) { function removeImage(index: number) {
@@ -405,5 +473,6 @@ export function createProductDetailDataActions(
toggleLabel, toggleLabel,
toggleSaleStatus, toggleSaleStatus,
uploadImage, uploadImage,
uploadImages,
}; };
} }

View File

@@ -177,5 +177,6 @@ export function useProductDetailPage() {
toggleSaleStatus: dataActions.toggleSaleStatus, toggleSaleStatus: dataActions.toggleSaleStatus,
toggleSpecTemplate: skuActions.toggleSpecTemplate, toggleSpecTemplate: skuActions.toggleSpecTemplate,
uploadImage: dataActions.uploadImage, uploadImage: dataActions.uploadImage,
uploadImages: dataActions.uploadImages,
}; };
} }

View File

@@ -23,7 +23,6 @@ import {
Input, Input,
InputNumber, InputNumber,
Modal, Modal,
Radio,
Select, Select,
Spin, Spin,
Switch, Switch,
@@ -83,7 +82,7 @@ const {
toggleLabel, toggleLabel,
toggleSaleStatus, toggleSaleStatus,
toggleSpecTemplate, toggleSpecTemplate,
uploadImage, uploadImages,
} = useProductDetailPage(); } = useProductDetailPage();
const activeSectionId = ref('detail-basic'); const activeSectionId = ref('detail-basic');
@@ -119,8 +118,40 @@ const timedOnShelfValue = computed<Dayjs | undefined>({
}, },
}); });
const beforeUpload: UploadProps['beforeUpload'] = async (file) => { let pendingUploadBatchSignature = '';
await uploadImage(file as File);
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; return false;
}; };
@@ -289,53 +320,56 @@ watch(
<Card id="detail-images" :bordered="false" class="pd-section-card"> <Card id="detail-images" :bordered="false" class="pd-section-card">
<template #title>商品图片</template> <template #title>商品图片</template>
<div class="pd-upload-row"> <Upload.Dragger
<Upload class="pd-upload-dragger"
:class="{ 'is-disabled': maxUploadReached }"
multiple
:show-upload-list="false" :show-upload-list="false"
accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp"
:before-upload="beforeUpload" :before-upload="beforeUpload"
:disabled="isUploadingImage || maxUploadReached" :disabled="isUploadingImage || maxUploadReached"
> >
<div <div class="pd-upload-dragger-inner">
class="pd-upload-trigger" <span class="pd-upload-dragger-plus">+</span>
:class="{ disabled: maxUploadReached }" <p class="pd-upload-dragger-title">
> {{
<span>+</span> maxUploadReached
</div> ? '已达到最多 5 张'
</Upload> : '点击或拖拽上传图片'
<div class="pd-upload-hint"> }}
</p>
<p class="pd-upload-dragger-hint">
建议尺寸 750×750最多 5 首张为主图 建议尺寸 750×750最多 5 首张为主图
</p>
</div> </div>
</div> </Upload.Dragger>
<div class="pd-thumbs"> <div v-if="form.imageUrls.length > 0" class="pd-thumbs">
<div <div
v-for="(url, index) in form.imageUrls" v-for="(url, index) in form.imageUrls"
:key="url" :key="url"
class="pd-thumb" class="pd-thumb"
:class="{ 'pd-thumb-main': index === 0 }" :class="{ 'pd-thumb-main': index === 0 }"
@click="index > 0 && setPrimaryImage(index)"
> >
<img :src="url" alt="product-image" /> <img :src="url" alt="product-image" />
<button <button
class="pd-thumb-del" class="pd-thumb-del"
type="button" type="button"
@click="removeImage(index)" @click.stop="removeImage(index)"
> >
× ×
</button> </button>
<span v-if="index === 0" class="pd-thumb-badge">主图</span> <span v-if="index === 0" class="pd-thumb-badge">主图</span>
<button
v-else
type="button"
class="pd-thumb-set-main"
@click="setPrimaryImage(index)"
>
设主图
</button>
</div> </div>
</div> </div>
</Card> </Card>
<Card id="detail-pricing" :bordered="false" class="pd-section-card"> <Card
id="detail-pricing"
:bordered="false"
class="pd-section-card pd-pricing-card"
>
<template #title>价格库存</template> <template #title>价格库存</template>
<div v-if="form.skus.length > 1" class="pd-price-alert"> <div v-if="form.skus.length > 1" class="pd-price-alert">
已启用多规格售价和库存以下方 SKU 管理中的组合为准 已启用多规格售价和库存以下方 SKU 管理中的组合为准
@@ -343,8 +377,8 @@ watch(
<div class="pd-row"> <div class="pd-row">
<label class="pd-label required">售价</label> <label class="pd-label required">售价</label>
<div class="pd-ctrl"> <div class="pd-ctrl">
<div class="pd-inline"> <div class="pd-pricing-inline">
<span class="pd-unit">¥</span> <span class="pd-pricing-unit">¥</span>
<InputNumber <InputNumber
v-model:value="form.price" v-model:value="form.price"
:min="0" :min="0"
@@ -352,14 +386,15 @@ watch(
:step="0.1" :step="0.1"
class="pd-input-fluid" class="pd-input-fluid"
/> />
<span class="pd-pricing-unit is-empty"> </span>
</div> </div>
</div> </div>
</div> </div>
<div class="pd-row"> <div class="pd-row">
<label class="pd-label">划线价</label> <label class="pd-label">划线价</label>
<div class="pd-ctrl"> <div class="pd-ctrl">
<div class="pd-inline"> <div class="pd-pricing-inline">
<span class="pd-unit">¥</span> <span class="pd-pricing-unit">¥</span>
<InputNumber <InputNumber
v-model:value="form.originalPrice" v-model:value="form.originalPrice"
:min="0" :min="0"
@@ -367,13 +402,15 @@ watch(
:step="0.1" :step="0.1"
class="pd-input-fluid" class="pd-input-fluid"
/> />
<span class="pd-pricing-unit is-empty"> </span>
</div> </div>
</div> </div>
</div> </div>
<div class="pd-row"> <div class="pd-row">
<label class="pd-label">库存数量</label> <label class="pd-label">库存数量</label>
<div class="pd-ctrl"> <div class="pd-ctrl">
<div class="pd-inline"> <div class="pd-pricing-inline">
<span class="pd-pricing-unit is-empty"> ¥ </span>
<InputNumber <InputNumber
v-model:value="form.stock" v-model:value="form.stock"
:min="0" :min="0"
@@ -381,14 +418,15 @@ watch(
:step="1" :step="1"
class="pd-input-fluid" class="pd-input-fluid"
/> />
<span class="pd-unit"></span> <span class="pd-pricing-unit"></span>
</div> </div>
</div> </div>
</div> </div>
<div class="pd-row"> <div class="pd-row">
<label class="pd-label">库存预警</label> <label class="pd-label">库存预警</label>
<div class="pd-ctrl"> <div class="pd-ctrl">
<div class="pd-inline"> <div class="pd-pricing-inline">
<span class="pd-pricing-unit is-empty"> ¥ </span>
<InputNumber <InputNumber
v-model:value="form.warningStock" v-model:value="form.warningStock"
:min="0" :min="0"
@@ -396,15 +434,15 @@ watch(
:step="1" :step="1"
class="pd-input-fluid" class="pd-input-fluid"
/> />
<span class="pd-unit"></span> <span class="pd-pricing-unit"></span>
</div> </div>
</div> </div>
</div> </div>
<div class="pd-row"> <div class="pd-row">
<label class="pd-label">打包费</label> <label class="pd-label">打包费</label>
<div class="pd-ctrl"> <div class="pd-ctrl">
<div class="pd-inline"> <div class="pd-pricing-inline">
<span class="pd-unit">¥</span> <span class="pd-pricing-unit">¥</span>
<InputNumber <InputNumber
v-model:value="form.packingFee" v-model:value="form.packingFee"
:min="0" :min="0"
@@ -412,7 +450,7 @@ watch(
:step="0.1" :step="0.1"
class="pd-input-fluid" class="pd-input-fluid"
/> />
<span class="pd-unit">/</span> <span class="pd-pricing-unit"> / </span>
</div> </div>
</div> </div>
</div> </div>
@@ -746,10 +784,24 @@ watch(
<div class="pd-row"> <div class="pd-row">
<label class="pd-label">商品类型</label> <label class="pd-label">商品类型</label>
<div class="pd-ctrl"> <div class="pd-ctrl">
<Radio.Group :value="form.kind" @update:value="onKindChange"> <div class="pd-kind-pills">
<Radio.Button value="single">单品</Radio.Button> <button
<Radio.Button value="combo">套餐</Radio.Button> type="button"
</Radio.Group> class="pd-kind-pill"
:class="{ active: form.kind === 'single' }"
@click="onKindChange('single')"
>
单品
</button>
<button
type="button"
class="pd-kind-pill"
:class="{ active: form.kind === 'combo' }"
@click="onKindChange('combo')"
>
套餐
</button>
</div>
</div> </div>
</div> </div>
@@ -790,9 +842,12 @@ watch(
<div class="pd-shelf-opt-body"> <div class="pd-shelf-opt-body">
<div class="pd-shelf-opt-title">定时上架</div> <div class="pd-shelf-opt-title">定时上架</div>
<div class="pd-shelf-opt-desc">到达指定时间后自动上架</div> <div class="pd-shelf-opt-desc">到达指定时间后自动上架</div>
</div>
</div>
</div>
<div <div
v-if="form.shelfMode === 'scheduled'" v-if="form.shelfMode === 'scheduled'"
class="pd-shelf-time" class="pd-shelf-time-row"
> >
<span class="pd-unit">上架时间</span> <span class="pd-unit">上架时间</span>
<DatePicker <DatePicker
@@ -802,9 +857,6 @@ watch(
class="pd-input-fluid" class="pd-input-fluid"
/> />
</div> </div>
</div>
</div>
</div>
</Card> </Card>
<Card :bordered="false" class="pd-save-bar"> <Card :bordered="false" class="pd-save-bar">

View File

@@ -161,60 +161,77 @@
color: #9ca3af; color: #9ca3af;
} }
.pd-upload-row { .pd-upload-dragger {
display: flex; .ant-upload-drag {
gap: 12px; min-height: 160px;
align-items: center; padding: 24px 16px;
} background: #fafafa;
border: 1px dashed #d9d9d9;
.pd-upload-trigger { border-radius: 14px;
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; transition: all 0.2s ease;
} }
.pd-upload-trigger:hover { .ant-upload-btn {
color: #1677ff; padding: 0;
background: #f3f8ff; }
}
.pd-upload-dragger:not(.is-disabled):hover .ant-upload-drag {
background: #f6f9ff;
border-color: #1677ff; border-color: #1677ff;
} }
.pd-upload-trigger.disabled { .pd-upload-dragger.is-disabled .ant-upload-drag {
color: #d1d5db;
cursor: not-allowed; cursor: not-allowed;
background: #f3f4f6; background: #f7f7f7;
border-color: #e5e7eb; border-color: #e5e7eb;
} }
.pd-upload-hint { .pd-upload-dragger-inner {
font-size: 12px; 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; color: #9ca3af;
} }
.pd-thumbs { .pd-thumbs {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px; gap: 14px;
margin-top: 12px; margin-top: 16px;
} }
.pd-thumb { .pd-thumb {
position: relative; position: relative;
width: 88px; width: 120px;
height: 88px; height: 120px;
overflow: hidden; overflow: hidden;
cursor: pointer;
background: #f8f9fb; background: #f8f9fb;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 8px; border-radius: 12px;
transition: all 0.2s ease;
} }
.pd-thumb img { .pd-thumb img {
@@ -226,7 +243,7 @@
.pd-thumb-main { .pd-thumb-main {
border-color: #1677ff; border-color: #1677ff;
box-shadow: 0 0 0 1px #1677ff; box-shadow: 0 0 0 2px rgb(22 119 255 / 25%);
} }
.pd-thumb-badge { .pd-thumb-badge {
@@ -234,9 +251,10 @@
right: 0; right: 0;
bottom: 0; bottom: 0;
left: 0; left: 0;
height: 18px; height: 26px;
font-size: 11px; font-size: 13px;
line-height: 18px; font-weight: 600;
line-height: 26px;
color: #fff; color: #fff;
text-align: center; text-align: center;
background: #1677ff; background: #1677ff;
@@ -244,11 +262,11 @@
.pd-thumb-del { .pd-thumb-del {
position: absolute; position: absolute;
top: -6px; top: -10px;
right: -6px; right: -10px;
width: 18px; width: 28px;
height: 18px; height: 28px;
font-size: 12px; font-size: 18px;
line-height: 1; line-height: 1;
color: #fff; color: #fff;
cursor: pointer; cursor: pointer;
@@ -257,19 +275,6 @@
border-radius: 50%; 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 { .pd-price-alert {
padding: 8px 12px; padding: 8px 12px;
margin-bottom: 12px; margin-bottom: 12px;
@@ -280,6 +285,31 @@
border-radius: 8px; 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 { .pd-pills {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -367,7 +397,9 @@
} }
.pd-table-wrap { .pd-table-wrap {
overflow-x: auto; max-height: 520px;
overflow: auto;
border-radius: 8px;
} }
.pd-sku-table { .pd-sku-table {
@@ -377,6 +409,9 @@
} }
.pd-sku-table th { .pd-sku-table th {
position: sticky;
top: 0;
z-index: 2;
padding: 9px 12px; padding: 9px 12px;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
@@ -538,15 +573,50 @@
.pd-shelf-group { .pd-shelf-group {
display: flex; display: flex;
flex-direction: column; gap: 8px;
gap: 10px; 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 { .pd-shelf-opt {
display: flex; display: flex;
gap: 8px; flex: 1;
gap: 6px;
align-items: flex-start; align-items: flex-start;
padding: 12px; min-width: 0;
min-height: 0;
padding: 9px 10px;
cursor: pointer; cursor: pointer;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 10px; border-radius: 10px;
@@ -563,31 +633,35 @@
} }
.pd-shelf-opt input[type='radio'] { .pd-shelf-opt input[type='radio'] {
margin-top: 3px; margin-top: 1px;
accent-color: #1677ff; accent-color: #1677ff;
} }
.pd-shelf-opt-body { .pd-shelf-opt-body {
flex: 1; flex: 1;
min-width: 0;
} }
.pd-shelf-opt-title { .pd-shelf-opt-title {
font-size: 13px; font-size: 12px;
font-weight: 600; font-weight: 600;
line-height: 1.35;
color: #1a1a2e; color: #1a1a2e;
} }
.pd-shelf-opt-desc { .pd-shelf-opt-desc {
margin-top: 2px; margin-top: 0;
font-size: 12px; font-size: 11px;
line-height: 1.3;
color: #9ca3af; color: #9ca3af;
} }
.pd-shelf-time { .pd-shelf-time-row {
display: flex; display: flex;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
margin-top: 8px; max-width: 420px;
margin-top: 10px;
} }
.pd-save-bar { .pd-save-bar {
@@ -680,5 +754,43 @@
flex-direction: column; flex-direction: column;
align-items: stretch; 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;
}
} }
} }