diff --git a/apps/web-antd/src/api/product/index.ts b/apps/web-antd/src/api/product/index.ts index 6c0e33e..7d42e09 100644 --- a/apps/web-antd/src/api/product/index.ts +++ b/apps/web-antd/src/api/product/index.ts @@ -140,15 +140,60 @@ export interface ProductListItemDto { tags: string[]; } +/** 套餐分组商品。 */ +export interface ProductComboGroupItemDto { + productId: string; + productName: string; + quantity: number; + sortOrder: number; +} + +/** 套餐分组。 */ +export interface ProductComboGroupDto { + id: string; + items: ProductComboGroupItemDto[]; + maxSelect: number; + minSelect: number; + name: string; + sortOrder: number; +} + +/** SKU 规格属性。 */ +export interface ProductSkuAttributeDto { + optionId: string; + templateId: string; +} + +/** SKU 详情。 */ +export interface ProductSkuDto { + attributes: ProductSkuAttributeDto[]; + id: string; + isEnabled: boolean; + originalPrice: null | number; + price: number; + skuCode: string; + sortOrder: number; + stock: number; +} + /** 商品详情。 */ export interface ProductDetailDto extends ProductListItemDto { + addonGroupIds: string[]; + comboGroups: ProductComboGroupDto[]; description: string; - imageUrls?: string[]; + imageUrls: string[]; + labelIds: string[]; notifyManager: boolean; + packingFee: null | number; recoverAt: null | string; remainStock: number; + skus: ProductSkuDto[]; soldoutReason: string; + sortWeight: number; + specTemplateIds: string[]; syncToPlatform: boolean; + timedOnShelfAt: null | string; + warningStock: null | number; } /** 商品列表查询参数。 */ @@ -170,22 +215,51 @@ export interface ProductDetailQuery { /** 保存商品参数。 */ export interface SaveProductDto { + addonGroupIds?: string[]; categoryId: string; + comboGroups?: Array<{ + items: Array<{ + productId: string; + quantity: number; + sortOrder: number; + }>; + maxSelect: number; + minSelect: number; + name: string; + sortOrder: number; + }>; description: string; id?: string; imageUrls?: string[]; + labelIds?: string[]; kind: ProductKind; name: string; originalPrice: null | number; + packingFee?: null | number; price: number; + skus?: Array<{ + attributes: Array<{ + optionId: string; + templateId: string; + }>; + isEnabled: boolean; + originalPrice: null | number; + price: number; + skuCode?: string; + sortOrder: number; + stock: number; + }>; shelfMode: 'draft' | 'now' | 'scheduled'; + sortWeight?: number; + specTemplateIds?: string[]; spuCode?: string; status: ProductStatus; stock: number; storeId: string; subtitle: string; - tags: string[]; + tags?: string[]; timedOnShelfAt?: string; + warningStock?: null | number; } /** 删除商品参数。 */ 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 8507eda..6352503 100644 --- a/apps/web-antd/src/views/product/detail/composables/useProductDetailPage.ts +++ b/apps/web-antd/src/views/product/detail/composables/useProductDetailPage.ts @@ -1,6 +1,21 @@ -import type { ProductDetailFormState } from '../types'; +import type { + ProductDetailComboGroupItemState, + ProductDetailComboGroupState, + ProductDetailFormState, + ProductDetailSkuAttrState, + ProductDetailSkuBatchState, + ProductDetailSkuRowState, +} from '../types'; -import type { ProductDetailDto, ProductStatus } from '#/api/product'; +import type { + ProductAddonGroupDto, + ProductDetailDto, + ProductLabelDto, + ProductPickerItemDto, + ProductSkuDto, + ProductSpecDto, + ProductStatus, +} from '#/api/product'; import { computed, reactive, ref, watch } from 'vue'; import { useRoute, useRouter } from 'vue-router'; @@ -10,14 +25,14 @@ import { message } from 'ant-design-vue'; import { uploadTenantFileApi } from '#/api/files'; import { deleteProductApi, + getProductAddonGroupListApi, getProductCategoryListApi, getProductDetailApi, + getProductLabelListApi, + getProductSpecListApi, saveProductApi, + searchProductPickerApi, } from '#/api/product'; -import { - tagsToText, - textToTags, -} from '#/views/product/list/composables/product-list-page/helpers'; const DEFAULT_FORM: ProductDetailFormState = { id: '', @@ -26,16 +41,28 @@ const DEFAULT_FORM: ProductDetailFormState = { categoryId: '', kind: 'single', description: '', + sortWeight: 0, + imageUrls: [], price: 0, originalPrice: null, stock: 0, + warningStock: null, + packingFee: null, + specTemplateIds: [], + addonGroupIds: [], + labelIds: [], + skus: [], + comboGroups: [], status: 'off_shelf', - tagsText: '', - imageUrls: [], shelfMode: 'draft', timedOnShelfAt: '', }; +interface CategoryOption { + label: string; + value: string; +} + export function useProductDetailPage() { const route = useRoute(); const router = useRouter(); @@ -44,7 +71,24 @@ export function useProductDetailPage() { const isSubmitting = ref(false); const isUploadingImage = ref(false); const detail = ref(null); - const categoryOptions = ref>([]); + + const categoryOptions = ref([]); + const specTemplateOptions = ref([]); + const addonGroupOptions = ref([]); + const labelOptions = ref([]); + + const comboPickerOpen = ref(false); + const comboPickerLoading = ref(false); + const comboPickerKeyword = ref(''); + const comboPickerProducts = ref([]); + const comboPickerSelectedIds = ref([]); + const comboPickerCurrentGroupIndex = ref(-1); + + const skuBatch = reactive({ + price: null, + stock: null, + }); + const form = reactive({ ...DEFAULT_FORM }); const storeId = computed(() => String(route.query.storeId || '')); @@ -62,7 +106,30 @@ export function useProductDetailPage() { return '#9ca3af'; }); - /** 回填编辑表单。 */ + const skuTemplateColumns = computed(() => + form.specTemplateIds + .map((id) => specTemplateOptions.value.find((item) => item.id === id)) + .filter(Boolean), + ); + + const comboPickerSelectedProducts = computed(() => + comboPickerProducts.value.filter((item) => + comboPickerSelectedIds.value.includes(item.id), + ), + ); + + function resetForm() { + Object.assign(form, { + ...DEFAULT_FORM, + imageUrls: [], + specTemplateIds: [], + addonGroupIds: [], + labelIds: [], + skus: [], + comboGroups: [], + }); + } + function patchForm(data: ProductDetailDto) { form.id = data.id; form.name = data.name; @@ -70,54 +137,222 @@ export function useProductDetailPage() { form.categoryId = data.categoryId; form.kind = data.kind; form.description = data.description; - form.price = data.price; - form.originalPrice = data.originalPrice; - form.stock = data.stock; + form.sortWeight = Math.max(0, Number(data.sortWeight || 0)); + + form.imageUrls = dedupeTextList([ + ...(data.imageUrls || []), + data.imageUrl, + ]).slice(0, 5); + + form.price = Number(data.price || 0); + form.originalPrice = + data.originalPrice !== null && data.originalPrice !== undefined + ? Number(data.originalPrice) + : null; + form.stock = Math.max(0, Math.floor(Number(data.stock || 0))); + form.warningStock = + data.warningStock !== null && data.warningStock !== undefined + ? Math.max(0, Math.floor(Number(data.warningStock))) + : null; + form.packingFee = + data.packingFee !== null && data.packingFee !== undefined + ? Number(data.packingFee) + : null; + + form.specTemplateIds = dedupeTextList(data.specTemplateIds || []); + form.addonGroupIds = dedupeTextList(data.addonGroupIds || []); + form.labelIds = dedupeTextList(data.labelIds || []); + + form.skus = normalizeSkuRows(data.skus || []); + form.comboGroups = normalizeComboGroups(data.comboGroups || []); + form.status = data.status; - form.tagsText = tagsToText(data.tags || []); - form.imageUrls = [...(data.imageUrls || []), data.imageUrl] - .map((item) => String(item || '').trim()) - .filter(Boolean) - .filter((item, index, source) => source.indexOf(item) === index) - .slice(0, 5); - form.shelfMode = data.status === 'on_sale' ? 'now' : 'draft'; - form.timedOnShelfAt = ''; + if (data.status === 'on_sale') { + form.shelfMode = 'now'; + } else if (data.timedOnShelfAt) { + form.shelfMode = 'scheduled'; + } else { + form.shelfMode = 'draft'; + } + form.timedOnShelfAt = data.timedOnShelfAt || ''; } - /** 解析门店+商品并拉取详情。 */ - async function loadDetail() { - if (!storeId.value || !productId.value) { - detail.value = null; - categoryOptions.value = []; + function getTemplateName(templateId: string) { + return ( + specTemplateOptions.value.find((item) => item.id === templateId)?.name || + templateId + ); + } + + function getOptionName(templateId: string, optionId: string) { + const template = specTemplateOptions.value.find( + (item) => item.id === templateId, + ); + if (!template) return optionId; + return ( + template.values.find((item) => item.id === optionId)?.name || optionId + ); + } + + function getSkuAttrOptionId( + row: ProductDetailSkuRowState, + templateId: string, + ) { + return row.attributes.find((item) => item.templateId === templateId) + ?.optionId; + } + + function buildSkuRows() { + const selectedTemplates = form.specTemplateIds + .map((id) => specTemplateOptions.value.find((item) => item.id === id)) + .filter(Boolean) + .map((item) => ({ + id: item.id, + name: item.name, + options: [...(item.values || [])] + .toSorted((a, b) => a.sort - b.sort) + .map((value) => ({ + id: value.id, + name: value.name, + })), + })) + .filter((item) => item.options.length > 0); + + const previousMap = new Map( + form.skus.map((item) => [buildSkuKey(item.attributes), item]), + ); + + const combos = buildSkuCombinations(selectedTemplates); + if (combos.length === 0) { + const fallback = previousMap.get('default'); + form.skus = [ + { + id: fallback?.id || '', + skuCode: fallback?.skuCode || buildLocalSkuCode(1), + price: fallback?.price ?? Number(form.price || 0), + originalPrice: + fallback?.originalPrice ?? + (form.originalPrice && form.originalPrice > 0 + ? form.originalPrice + : null), + stock: + fallback?.stock ?? Math.max(0, Math.floor(Number(form.stock || 0))), + isEnabled: fallback?.isEnabled ?? true, + sortOrder: 1, + attributes: [], + }, + ]; return; } - isLoading.value = true; - try { - const [detailData, categories] = await Promise.all([ - getProductDetailApi({ - storeId: storeId.value, - productId: productId.value, - }), - getProductCategoryListApi(storeId.value), - ]); - - detail.value = detailData; - categoryOptions.value = categories.map((item) => ({ - label: item.name, - value: item.id, - })); - patchForm(detailData); - } catch (error) { - console.error(error); - detail.value = null; - categoryOptions.value = []; - } finally { - isLoading.value = false; - } + form.skus = combos.map((attrs, index) => { + const key = buildSkuKey(attrs); + const cached = previousMap.get(key); + const skuIndex = index + 1; + return { + id: cached?.id || '', + skuCode: cached?.skuCode || buildLocalSkuCode(skuIndex), + price: cached?.price ?? Number(form.price || 0), + originalPrice: + cached?.originalPrice ?? + (form.originalPrice && form.originalPrice > 0 + ? form.originalPrice + : null), + stock: + cached?.stock ?? Math.max(0, Math.floor(Number(form.stock || 0))), + isEnabled: cached?.isEnabled ?? true, + sortOrder: skuIndex, + attributes: attrs, + }; + }); + } + + function toggleSpecTemplate(templateId: string) { + const selected = new Set(form.specTemplateIds); + if (selected.has(templateId)) { + selected.delete(templateId); + } else { + selected.add(templateId); + } + form.specTemplateIds = [...selected]; + buildSkuRows(); + } + + function toggleAddonGroup(groupId: string) { + const selected = new Set(form.addonGroupIds); + if (selected.has(groupId)) { + selected.delete(groupId); + } else { + selected.add(groupId); + } + form.addonGroupIds = [...selected]; + } + + function toggleLabel(labelId: string) { + const selected = new Set(form.labelIds); + if (selected.has(labelId)) { + selected.delete(labelId); + } else { + selected.add(labelId); + } + form.labelIds = [...selected]; + } + + function setSkuPrice(index: number, value: number) { + if (index < 0 || index >= form.skus.length) return; + form.skus[index] = { + ...form.skus[index], + price: Number.isFinite(value) + ? Math.max(0, value) + : form.skus[index].price, + }; + } + + function setSkuOriginalPrice(index: number, value: null | number) { + if (index < 0 || index >= form.skus.length) return; + form.skus[index] = { + ...form.skus[index], + originalPrice: + value !== null && value !== undefined && Number(value) > 0 + ? Number(value) + : null, + }; + } + + function setSkuStock(index: number, value: number) { + if (index < 0 || index >= form.skus.length) return; + form.skus[index] = { + ...form.skus[index], + stock: Math.max(0, Math.floor(Number(value || 0))), + }; + } + + function setSkuEnabled(index: number, checked: boolean) { + if (index < 0 || index >= form.skus.length) return; + form.skus[index] = { + ...form.skus[index], + isEnabled: checked, + }; + } + + function applySkuBatchPrice() { + if (skuBatch.price === null || skuBatch.price === undefined) return; + const price = Math.max(0, Number(skuBatch.price)); + form.skus = form.skus.map((item) => ({ + ...item, + price, + })); + } + + function applySkuBatchStock() { + if (skuBatch.stock === null || skuBatch.stock === undefined) return; + const stock = Math.max(0, Math.floor(Number(skuBatch.stock))); + form.skus = form.skus.map((item) => ({ + ...item, + stock, + })); } - /** 上传图片并追加到图集。 */ async function uploadImage(file: File) { isUploadingImage.value = true; try { @@ -127,9 +362,7 @@ export function useProductDetailPage() { message.error('图片上传失败'); return; } - form.imageUrls = [...form.imageUrls, url] - .filter((item, index, source) => source.indexOf(item) === index) - .slice(0, 5); + form.imageUrls = dedupeTextList([...form.imageUrls, url]).slice(0, 5); message.success('图片上传成功'); } catch (error) { console.error(error); @@ -138,24 +371,213 @@ export function useProductDetailPage() { } } - /** 设置主图。 */ + function removeImage(index: number) { + if (index < 0 || index >= form.imageUrls.length) return; + form.imageUrls = form.imageUrls.filter( + (_, itemIndex) => itemIndex !== index, + ); + } + function setPrimaryImage(index: number) { if (index <= 0 || index >= form.imageUrls.length) return; - const current = [...form.imageUrls]; - const [selected] = current.splice(index, 1); - if (!selected) return; - form.imageUrls = [selected, ...current]; + const next = [...form.imageUrls]; + const [picked] = next.splice(index, 1); + if (!picked) return; + form.imageUrls = [picked, ...next]; } - /** 删除图片。 */ - function removeImage(index: number) { - form.imageUrls = form.imageUrls.filter((_, i) => i !== index); + function addComboGroup() { + form.comboGroups.push({ + name: '', + minSelect: 1, + maxSelect: 1, + sortOrder: form.comboGroups.length + 1, + items: [], + }); } - /** 保存详情。 */ - async function saveDetail() { + function removeComboGroup(groupIndex: number) { + if (groupIndex < 0 || groupIndex >= form.comboGroups.length) return; + form.comboGroups = form.comboGroups.filter( + (_, index) => index !== groupIndex, + ); + form.comboGroups.forEach((group, index) => { + group.sortOrder = index + 1; + }); + } + + function removeComboItem(groupIndex: number, itemIndex: number) { + const group = form.comboGroups[groupIndex]; + if (!group) return; + group.items = group.items.filter((_, index) => index !== itemIndex); + group.items.forEach((item, index) => { + item.sortOrder = index + 1; + }); + } + + function setComboGroupName(groupIndex: number, value: string) { + const group = form.comboGroups[groupIndex]; + if (!group) return; + group.name = value; + } + + function setComboGroupMinSelect(groupIndex: number, value: number) { + const group = form.comboGroups[groupIndex]; + if (!group) return; + group.minSelect = Math.max(1, Math.floor(Number(value || 1))); + if (group.maxSelect < group.minSelect) { + group.maxSelect = group.minSelect; + } + } + + function setComboGroupMaxSelect(groupIndex: number, value: number) { + const group = form.comboGroups[groupIndex]; + if (!group) return; + group.maxSelect = Math.max(1, Math.floor(Number(value || 1))); + if (group.maxSelect < group.minSelect) { + group.minSelect = group.maxSelect; + } + } + + function setComboItemQuantity( + groupIndex: number, + itemIndex: number, + value: number, + ) { + const group = form.comboGroups[groupIndex]; + if (!group) return; + const item = group.items[itemIndex]; + if (!item) return; + item.quantity = Math.max(1, Math.floor(Number(value || 1))); + } + + function setComboPickerOpen(value: boolean) { + comboPickerOpen.value = value; + if (!value) { + comboPickerCurrentGroupIndex.value = -1; + comboPickerSelectedIds.value = []; + comboPickerKeyword.value = ''; + comboPickerProducts.value = []; + } + } + + async function searchComboPicker() { if (!storeId.value) return; - if (!form.id) return; + comboPickerLoading.value = true; + try { + const products = await searchProductPickerApi({ + storeId: storeId.value, + keyword: comboPickerKeyword.value.trim() || undefined, + limit: 100, + }); + comboPickerProducts.value = products.filter( + (item) => item.id !== form.id, + ); + } catch (error) { + console.error(error); + comboPickerProducts.value = []; + } finally { + comboPickerLoading.value = false; + } + } + + async function openComboPicker(groupIndex: number) { + const group = form.comboGroups[groupIndex]; + if (!group) return; + comboPickerCurrentGroupIndex.value = groupIndex; + comboPickerSelectedIds.value = group.items.map((item) => item.productId); + setComboPickerOpen(true); + await searchComboPicker(); + } + + function toggleComboPickerProduct(productId: string) { + const selected = new Set(comboPickerSelectedIds.value); + if (selected.has(productId)) { + selected.delete(productId); + } else { + selected.add(productId); + } + comboPickerSelectedIds.value = [...selected]; + } + + function submitComboPicker() { + const groupIndex = comboPickerCurrentGroupIndex.value; + const group = form.comboGroups[groupIndex]; + if (!group) { + setComboPickerOpen(false); + return; + } + + const currentItemMap = new Map( + group.items.map((item) => [item.productId, item]), + ); + + const selectedProducts = comboPickerSelectedProducts.value; + group.items = selectedProducts.map((product, index) => { + const cached = currentItemMap.get(product.id); + return { + productId: product.id, + productName: product.name, + quantity: cached?.quantity ?? 1, + sortOrder: index + 1, + }; + }); + + setComboPickerOpen(false); + } + + async function loadDetail() { + if (!storeId.value || !productId.value) { + detail.value = null; + resetForm(); + categoryOptions.value = []; + specTemplateOptions.value = []; + addonGroupOptions.value = []; + labelOptions.value = []; + return; + } + + isLoading.value = true; + try { + const [detailData, categories, specs, addons, labels] = await Promise.all( + [ + getProductDetailApi({ + storeId: storeId.value, + productId: productId.value, + }), + getProductCategoryListApi(storeId.value), + getProductSpecListApi({ storeId: storeId.value }), + getProductAddonGroupListApi({ storeId: storeId.value }), + getProductLabelListApi({ storeId: storeId.value }), + ], + ); + + detail.value = detailData; + categoryOptions.value = categories.map((item) => ({ + label: item.name, + value: item.id, + })); + specTemplateOptions.value = specs; + addonGroupOptions.value = addons; + labelOptions.value = labels; + + patchForm(detailData); + buildSkuRows(); + } catch (error) { + console.error(error); + detail.value = null; + resetForm(); + categoryOptions.value = []; + specTemplateOptions.value = []; + addonGroupOptions.value = []; + labelOptions.value = []; + } finally { + isLoading.value = false; + } + } + + async function saveDetail() { + if (!storeId.value || !form.id) return; if (!form.name.trim()) { message.warning('请输入商品名称'); return; @@ -169,6 +591,35 @@ export function useProductDetailPage() { return; } + if (form.kind === 'combo') { + if (form.comboGroups.length === 0) { + message.warning('套餐至少需要一个分组'); + return; + } + + for (const group of form.comboGroups) { + if (!group.name.trim()) { + message.warning('请填写套餐分组名称'); + return; + } + if (group.items.length === 0) { + message.warning(`分组「${group.name}」至少需要一个商品`); + return; + } + if (group.maxSelect < group.minSelect) { + message.warning(`分组「${group.name}」最大选择数不能小于最小选择数`); + return; + } + } + } + + for (const sku of form.skus) { + if (sku.price < 0 || sku.stock < 0) { + message.warning('SKU 的售价和库存不能小于 0'); + return; + } + } + isSubmitting.value = true; try { const saved = await saveProductApi({ @@ -179,21 +630,85 @@ export function useProductDetailPage() { name: form.name.trim(), subtitle: form.subtitle.trim(), description: form.description.trim(), - price: Number(form.price || 0), + price: Number(Number(form.price || 0).toFixed(2)), originalPrice: - form.originalPrice && Number(form.originalPrice) > 0 - ? Number(form.originalPrice) + form.originalPrice !== null && + form.originalPrice !== undefined && + Number(form.originalPrice) > 0 + ? Number(Number(form.originalPrice).toFixed(2)) : null, stock: Math.max(0, Math.floor(Number(form.stock || 0))), - tags: textToTags(form.tagsText), status: form.status, shelfMode: form.shelfMode, timedOnShelfAt: - form.shelfMode === 'scheduled' ? form.timedOnShelfAt : undefined, + form.shelfMode === 'scheduled' && form.timedOnShelfAt + ? form.timedOnShelfAt + : undefined, imageUrls: [...form.imageUrls], + sortWeight: Math.max(0, Math.floor(Number(form.sortWeight || 0))), + warningStock: + form.warningStock !== null && form.warningStock !== undefined + ? Math.max(0, Math.floor(Number(form.warningStock))) + : null, + packingFee: + form.packingFee !== null && form.packingFee !== undefined + ? Math.max(0, Number(Number(form.packingFee).toFixed(2))) + : null, + specTemplateIds: [...form.specTemplateIds], + addonGroupIds: [...form.addonGroupIds], + labelIds: [...form.labelIds], + skus: form.skus.map((item, index) => ({ + skuCode: item.skuCode || buildLocalSkuCode(index + 1), + price: Math.max(0, Number(Number(item.price || 0).toFixed(2))), + originalPrice: + item.originalPrice !== null && + item.originalPrice !== undefined && + Number(item.originalPrice) > 0 + ? Number(Number(item.originalPrice).toFixed(2)) + : null, + stock: Math.max(0, Math.floor(Number(item.stock || 0))), + isEnabled: item.isEnabled, + sortOrder: Math.max( + 1, + Math.floor(Number(item.sortOrder || index + 1)), + ), + attributes: item.attributes.map((attr) => ({ + templateId: attr.templateId, + optionId: attr.optionId, + })), + })), + comboGroups: + form.kind === 'combo' + ? form.comboGroups.map((group, groupIndex) => ({ + name: group.name.trim(), + minSelect: Math.max( + 1, + Math.floor(Number(group.minSelect || 1)), + ), + maxSelect: Math.max( + 1, + Math.floor(Number(group.maxSelect || 1)), + ), + sortOrder: Math.max( + 1, + Math.floor(Number(group.sortOrder || groupIndex + 1)), + ), + items: group.items.map((item, itemIndex) => ({ + productId: item.productId, + quantity: Math.max(1, Math.floor(Number(item.quantity || 1))), + sortOrder: Math.max( + 1, + Math.floor(Number(item.sortOrder || itemIndex + 1)), + ), + })), + })) + : [], + tags: [], }); + detail.value = saved; patchForm(saved); + buildSkuRows(); message.success('商品详情已保存'); } catch (error) { console.error(error); @@ -202,7 +717,6 @@ export function useProductDetailPage() { } } - /** 切换在售/下架。 */ async function toggleSaleStatus(next: ProductStatus) { if (next !== 'on_sale' && next !== 'off_shelf') return; form.status = next; @@ -210,7 +724,11 @@ export function useProductDetailPage() { await saveDetail(); } - /** 删除当前商品并返回列表。 */ + function setShelfMode(mode: 'draft' | 'now' | 'scheduled') { + form.shelfMode = mode; + form.status = mode === 'now' ? 'on_sale' : 'off_shelf'; + } + async function deleteCurrentProduct() { if (!storeId.value || !form.id) return; await deleteProductApi({ @@ -221,30 +739,177 @@ export function useProductDetailPage() { router.push('/product/list'); } - /** 返回列表页。 */ function goBack() { router.push('/product/list'); } - watch([storeId, productId], loadDetail, { immediate: true }); + watch( + [storeId, productId], + () => { + void loadDetail(); + }, + { immediate: true }, + ); return { + addonGroupOptions, + addComboGroup, + applySkuBatchPrice, + applySkuBatchStock, categoryOptions, + comboPickerCurrentGroupIndex, + comboPickerKeyword, + comboPickerLoading, + comboPickerOpen, + comboPickerProducts, + comboPickerSelectedIds, + comboPickerSelectedProducts, deleteCurrentProduct, detail, form, + getOptionName, + getSkuAttrOptionId, + getTemplateName, goBack, isLoading, isSubmitting, isUploadingImage, + labelOptions, loadDetail, + openComboPicker, + removeComboGroup, + removeComboItem, removeImage, saveDetail, + searchComboPicker, + setComboGroupMaxSelect, + setComboGroupMinSelect, + setComboGroupName, + setComboItemQuantity, + setComboPickerOpen, setPrimaryImage, + setShelfMode, + setSkuEnabled, + setSkuOriginalPrice, + setSkuPrice, + setSkuStock, + skuBatch, + skuTemplateColumns, + specTemplateOptions, statusColor, statusText, storeId, + submitComboPicker, + toggleAddonGroup, + toggleComboPickerProduct, + toggleLabel, toggleSaleStatus, + toggleSpecTemplate, uploadImage, }; } + +function dedupeTextList(source: string[]) { + return source + .map((item) => String(item || '').trim()) + .filter(Boolean) + .filter((item, index, list) => list.indexOf(item) === index); +} + +function buildSkuCombinations( + templates: Array<{ + id: string; + name: string; + options: Array<{ id: string; name: string }>; + }>, +) { + if (templates.length === 0) return []; + + const combos: ProductDetailSkuAttrState[][] = []; + const walk = (depth: number, chain: ProductDetailSkuAttrState[]) => { + if (depth >= templates.length) { + combos.push([...chain]); + return; + } + const current = templates[depth]; + for (const option of current.options) { + walk(depth + 1, [ + ...chain, + { + templateId: current.id, + optionId: option.id, + }, + ]); + } + }; + + walk(0, []); + return combos; +} + +function buildSkuKey(attrs: ProductDetailSkuAttrState[]) { + if (attrs.length === 0) return 'default'; + return attrs + .toSorted((a, b) => { + if (a.templateId === b.templateId) { + return a.optionId.localeCompare(b.optionId); + } + return a.templateId.localeCompare(b.templateId); + }) + .map((item) => `${item.templateId}:${item.optionId}`) + .join('|'); +} + +function normalizeSkuRows(source: ProductSkuDto[]) { + const rows = source.map( + (item, index): ProductDetailSkuRowState => ({ + id: item.id || '', + skuCode: item.skuCode || '', + price: Number(item.price || 0), + originalPrice: + item.originalPrice !== null && item.originalPrice !== undefined + ? Number(item.originalPrice) + : null, + stock: Math.max(0, Math.floor(Number(item.stock || 0))), + isEnabled: item.isEnabled !== false, + sortOrder: Math.max(1, Math.floor(Number(item.sortOrder || index + 1))), + attributes: (item.attributes || []) + .map((attr) => ({ + templateId: String(attr.templateId || '').trim(), + optionId: String(attr.optionId || '').trim(), + })) + .filter((attr) => attr.templateId && attr.optionId), + }), + ); + + return rows.toSorted((a, b) => a.sortOrder - b.sortOrder); +} + +function normalizeComboGroups(source: ProductDetailDto['comboGroups']) { + return source.map( + (group, groupIndex): ProductDetailComboGroupState => ({ + name: String(group.name || '').trim(), + minSelect: Math.max(1, Math.floor(Number(group.minSelect || 1))), + maxSelect: Math.max(1, Math.floor(Number(group.maxSelect || 1))), + sortOrder: Math.max( + 1, + Math.floor(Number(group.sortOrder || groupIndex + 1)), + ), + items: (group.items || []).map( + (item, itemIndex): ProductDetailComboGroupItemState => ({ + productId: String(item.productId || '').trim(), + productName: String(item.productName || '').trim(), + quantity: Math.max(1, Math.floor(Number(item.quantity || 1))), + sortOrder: Math.max( + 1, + Math.floor(Number(item.sortOrder || itemIndex + 1)), + ), + }), + ), + }), + ); +} + +function buildLocalSkuCode(index: number) { + return `SKU-${String(index).padStart(2, '0')}`; +} diff --git a/apps/web-antd/src/views/product/detail/index.vue b/apps/web-antd/src/views/product/detail/index.vue index e13f6f9..a2f511b 100644 --- a/apps/web-antd/src/views/product/detail/index.vue +++ b/apps/web-antd/src/views/product/detail/index.vue @@ -1,23 +1,25 @@ + + + + +
+
加载中...
+
+ 暂无可选商品 +
+ +
+ +
+ 已选 {{ comboPickerSelectedProducts.length }} 个商品 +
+
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 b0b4f5b..01f0130 100644 --- a/apps/web-antd/src/views/product/detail/styles/index.less +++ b/apps/web-antd/src/views/product/detail/styles/index.less @@ -1,9 +1,12 @@ .page-product-detail { + font-size: 13px; + .pd-header { display: flex; - gap: 10px; + flex-wrap: wrap; + gap: 12px; align-items: center; - margin-bottom: 14px; + margin-bottom: 18px; } .pd-back-btn { @@ -22,12 +25,19 @@ text-overflow: ellipsis; font-size: 18px; font-weight: 700; - color: #111827; + color: #1a1a2e; white-space: nowrap; } + .pd-head-subtitle { + margin-top: 2px; + font-size: 12px; + color: #6b7280; + } + .pd-head-spu { margin-top: 2px; + font-family: monospace; font-size: 12px; color: #9ca3af; } @@ -36,9 +46,10 @@ display: inline-flex; align-items: center; justify-content: center; - min-width: 52px; - padding: 2px 10px; + min-width: 56px; + padding: 2px 12px; font-size: 11px; + font-weight: 600; color: #fff; border-radius: 6px; } @@ -49,7 +60,7 @@ .pd-body { display: flex; - gap: 16px; + gap: 18px; align-items: flex-start; } @@ -63,7 +74,7 @@ .pd-nav-item { display: block; - padding: 8px 14px; + padding: 9px 18px; margin: 2px 0; font-size: 13px; color: #4b5563; @@ -74,7 +85,7 @@ .pd-nav-item:hover { color: #1677ff; - background: #f7faff; + background: #f8f9fb; } .pd-nav-item.active { @@ -88,85 +99,503 @@ display: flex; flex: 1; flex-direction: column; - gap: 12px; + gap: 16px; min-width: 0; } - .pd-two-col { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; + .pd-section-card { + border-radius: 10px; } - .pd-three-col { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + .pd-row { + display: flex; gap: 12px; + align-items: flex-start; + margin-bottom: 14px; } - .pd-image-list { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); + .pd-row:last-child { + margin-bottom: 0; + } + + .pd-label { + flex-shrink: 0; + width: 84px; + font-size: 13px; + font-weight: 500; + line-height: 32px; + color: #4b5563; + text-align: right; + } + + .pd-label.required::before { + margin-right: 2px; + color: #ef4444; + content: '*'; + } + + .pd-ctrl { + flex: 1; + min-width: 0; + } + + .pd-inline { + display: flex; + gap: 8px; + align-items: center; + } + + .pd-unit { + font-size: 12px; + color: #9ca3af; + white-space: nowrap; + } + + .pd-input-fluid { + width: 100%; + } + + .pd-hint { + margin-top: 8px; + font-size: 12px; + color: #9ca3af; + } + + .pd-upload-row { + display: flex; + gap: 12px; + align-items: center; + } + + .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; + border-color: #1677ff; + } + + .pd-upload-trigger.disabled { + color: #d1d5db; + cursor: not-allowed; + background: #f3f4f6; + border-color: #e5e7eb; + } + + .pd-upload-hint { + font-size: 12px; + color: #9ca3af; + } + + .pd-thumbs { + display: flex; + flex-wrap: wrap; gap: 10px; margin-top: 12px; } - .pd-image-item { + .pd-thumb { + position: relative; + width: 88px; + height: 88px; overflow: hidden; - background: #fff; + background: #f8f9fb; border: 1px solid #e5e7eb; border-radius: 8px; } - .pd-image-item.primary { - border-color: #1677ff; - box-shadow: 0 0 0 1px rgb(22 119 255 / 25%); - } - - .pd-image-item img { + .pd-thumb img { display: block; width: 100%; - height: 130px; + height: 100%; object-fit: cover; - background: #f3f4f6; } - .pd-image-actions { + .pd-thumb-main { + border-color: #1677ff; + box-shadow: 0 0 0 1px #1677ff; + } + + .pd-thumb-badge { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 18px; + font-size: 11px; + line-height: 18px; + color: #fff; + text-align: center; + background: #1677ff; + } + + .pd-thumb-del { + position: absolute; + top: -6px; + right: -6px; + width: 18px; + height: 18px; + font-size: 12px; + line-height: 1; + color: #fff; + cursor: pointer; + background: #ef4444; + border: none; + 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; + font-size: 12px; + color: #1677ff; + background: rgb(22 119 255 / 6%); + border: 1px solid rgb(22 119 255 / 20%); + border-radius: 8px; + } + + .pd-pills { display: flex; + flex-wrap: wrap; gap: 8px; + } + + .pd-pill { + display: inline-flex; align-items: center; justify-content: center; - padding: 8px; - border-top: 1px solid #f3f4f6; + height: 30px; + padding: 0 12px; + font-size: 12px; + color: #4b5563; + cursor: pointer; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 6px; + transition: all 0.2s ease; } - .pd-module-hint { - margin-bottom: 10px; - font-size: 13px; - color: #6b7280; + .pd-pill:hover { + color: #1677ff; + border-color: #1677ff; } - .pd-sku-row { - display: grid; - grid-template-columns: minmax(120px, 1fr) 120px 120px; + .pd-pill.checked { + font-weight: 600; + color: #1677ff; + background: rgb(22 119 255 / 8%); + border-color: #1677ff; + } + + .pd-sku-hd { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; + } + + .pd-sku-hd-left { + display: flex; gap: 10px; align-items: center; } - .pd-sku-row .name { + .pd-sku-title { + font-size: 16px; + font-weight: 600; + color: #1a1a2e; + } + + .pd-sku-count { + font-size: 12px; + color: #9ca3af; + } + + .pd-sku-hint { + font-size: 12px; + color: #9ca3af; + } + + .pd-sku-batch { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-bottom: 12px; + } + + .pd-sku-batch label { + font-size: 12px; + color: #6b7280; + white-space: nowrap; + } + + .pd-sku-batch-input { + width: 110px; + } + + .pd-sku-batch-gap { + width: 16px; + } + + .pd-table-wrap { + overflow-x: auto; + } + + .pd-sku-table { + width: 100%; + min-width: 860px; + border-collapse: collapse; + } + + .pd-sku-table th { + padding: 9px 12px; + font-size: 12px; + font-weight: 500; + color: #6b7280; + text-align: left; + white-space: nowrap; + background: #f8f9fb; + border-bottom: 1px solid #f0f0f0; + } + + .pd-sku-table td { + padding: 8px 12px; + vertical-align: top; + color: #1a1a2e; + border-bottom: 1px solid #f3f4f6; + } + + .pd-sku-table tr:last-child td { + border-bottom: none; + } + + .pd-sku-spec { + display: inline-block; + padding: 2px 8px; + font-size: 11px; + color: #4b5563; + white-space: nowrap; + background: #f0f0f0; + border-radius: 4px; + } + + .pd-sku-code { + font-family: monospace; + font-size: 11px; + color: #9ca3af; + } + + .pd-sku-stock-warn { + margin-top: 2px; + font-size: 11px; + color: #fa8c16; + } + + .pd-sku-stock-warn.soldout { + color: #ef4444; + } + + .pd-sku-toggle { + display: flex; + align-items: center; + justify-content: center; + } + + .pd-tag-wrap { + display: flex; + flex-wrap: wrap; + } + + .pd-tag-sel { + display: inline-flex; + align-items: center; + padding: 4px 12px; + margin: 0 8px 8px 0; + font-size: 12px; + color: #4b5563; + cursor: pointer; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 6px; + transition: all 0.2s ease; + } + + .pd-tag-sel:hover { + color: #1677ff; + border-color: #1677ff; + } + + .pd-tag-sel.selected { + font-weight: 600; + color: #1677ff; + background: rgb(22 119 255 / 8%); + border-color: #1677ff; + } + + .pd-combo-list { + display: flex; + flex-direction: column; + gap: 10px; + } + + .pd-combo-card { + padding: 12px; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 10px; + } + + .pd-combo-head { + display: flex; + gap: 10px; + align-items: center; + } + + .pd-combo-range { + display: inline-flex; + gap: 8px; + align-items: center; + white-space: nowrap; + } + + .pd-combo-range .ant-input-number { + width: 70px; + } + + .pd-combo-items { + margin-top: 10px; + } + + .pd-combo-item { + display: flex; + gap: 10px; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + } + + .pd-combo-item .name { font-size: 13px; color: #374151; } - .pd-sku-price, - .pd-sku-stock { - width: 100%; + .pd-combo-item .actions { + display: inline-flex; + gap: 8px; + align-items: center; + } + + .pd-combo-item .actions .ant-input-number { + width: 70px; + } + + .pd-combo-empty { + font-size: 12px; + color: #9ca3af; + } + + .pd-combo-add-item { + padding-left: 0; + } + + .pd-combo-add-group { + height: 36px; + color: #1677ff; + cursor: pointer; + background: #fff; + border: 1px dashed #d1d5db; + border-radius: 8px; + } + + .pd-shelf-group { + display: flex; + flex-direction: column; + gap: 10px; + } + + .pd-shelf-opt { + display: flex; + gap: 8px; + align-items: flex-start; + padding: 12px; + cursor: pointer; + border: 1px solid #e5e7eb; + border-radius: 10px; + transition: all 0.2s ease; + } + + .pd-shelf-opt:hover { + border-color: #1677ff; + } + + .pd-shelf-opt.active { + background: rgb(22 119 255 / 5%); + border-color: #1677ff; + } + + .pd-shelf-opt input[type='radio'] { + margin-top: 3px; + accent-color: #1677ff; + } + + .pd-shelf-opt-body { + flex: 1; + } + + .pd-shelf-opt-title { + font-size: 13px; + font-weight: 600; + color: #1a1a2e; + } + + .pd-shelf-opt-desc { + margin-top: 2px; + font-size: 12px; + color: #9ca3af; + } + + .pd-shelf-time { + display: flex; + gap: 8px; + align-items: center; + margin-top: 8px; } .pd-save-bar { position: sticky; bottom: 0; - z-index: 12; + z-index: 11; + padding: 12px 20px; + background: #fafbfc; border-radius: 10px; } @@ -175,6 +604,61 @@ gap: 10px; justify-content: flex-end; } + + .pd-picker-search { + display: flex; + gap: 8px; + margin-bottom: 10px; + } + + .pd-picker-list { + max-height: 320px; + overflow: auto; + border: 1px solid #f0f0f0; + border-radius: 8px; + } + + .pd-picker-empty { + padding: 20px 12px; + color: #9ca3af; + text-align: center; + } + + .pd-picker-item { + display: flex; + gap: 10px; + align-items: center; + padding: 10px 12px; + cursor: pointer; + border-bottom: 1px solid #f5f5f5; + } + + .pd-picker-item:last-child { + border-bottom: none; + } + + .pd-picker-item .name { + flex: 1; + min-width: 0; + } + + .pd-picker-item .spu { + font-family: monospace; + font-size: 12px; + color: #9ca3af; + } + + .pd-picker-item .price { + font-size: 12px; + color: #111827; + white-space: nowrap; + } + + .pd-picker-selected { + margin-top: 10px; + font-size: 12px; + color: #1677ff; + } } @media (width <= 1024px) { @@ -188,9 +672,13 @@ width: 100%; } - .pd-two-col, - .pd-three-col { - grid-template-columns: 1fr; + .pd-label { + width: 72px; + } + + .pd-combo-head { + flex-direction: column; + align-items: stretch; } } } diff --git a/apps/web-antd/src/views/product/detail/types.ts b/apps/web-antd/src/views/product/detail/types.ts index 649d82a..90b9a7b 100644 --- a/apps/web-antd/src/views/product/detail/types.ts +++ b/apps/web-antd/src/views/product/detail/types.ts @@ -1,23 +1,77 @@ -import type { ProductKind, ProductStatus } from '#/api/product'; - -export interface ProductDetailFormState { - categoryId: string; - description: string; - id: string; - imageUrls: string[]; - kind: ProductKind; - name: string; - originalPrice: null | number; - price: number; - shelfMode: 'draft' | 'now' | 'scheduled'; - status: ProductStatus; - stock: number; - subtitle: string; - tagsText: string; - timedOnShelfAt: string; -} +import type { + ProductKind, + ProductStatus, + ProductSwitchStatus, +} from '#/api/product'; export interface ProductDetailSectionItem { id: string; title: string; } + +export interface ProductDetailPillOption { + count?: number; + id: string; + name: string; + status?: ProductSwitchStatus; +} + +export interface ProductDetailSkuAttrState { + optionId: string; + templateId: string; +} + +export interface ProductDetailSkuRowState { + attributes: ProductDetailSkuAttrState[]; + id: string; + isEnabled: boolean; + originalPrice: null | number; + price: number; + skuCode: string; + sortOrder: number; + stock: number; +} + +export interface ProductDetailComboGroupItemState { + productId: string; + productName: string; + quantity: number; + sortOrder: number; +} + +export interface ProductDetailComboGroupState { + items: ProductDetailComboGroupItemState[]; + maxSelect: number; + minSelect: number; + name: string; + sortOrder: number; +} + +export interface ProductDetailFormState { + addonGroupIds: string[]; + categoryId: string; + comboGroups: ProductDetailComboGroupState[]; + description: string; + id: string; + imageUrls: string[]; + kind: ProductKind; + labelIds: string[]; + name: string; + originalPrice: null | number; + packingFee: null | number; + price: number; + shelfMode: 'draft' | 'now' | 'scheduled'; + skus: ProductDetailSkuRowState[]; + sortWeight: number; + specTemplateIds: string[]; + status: ProductStatus; + stock: number; + subtitle: string; + timedOnShelfAt: string; + warningStock: null | number; +} + +export interface ProductDetailSkuBatchState { + price: null | number; + stock: null | number; +} diff --git a/apps/web-antd/src/views/product/list/components/ProductEditorDrawer.vue b/apps/web-antd/src/views/product/list/components/ProductEditorDrawer.vue index 370fd08..651fc44 100644 --- a/apps/web-antd/src/views/product/list/components/ProductEditorDrawer.vue +++ b/apps/web-antd/src/views/product/list/components/ProductEditorDrawer.vue @@ -1,12 +1,14 @@ + + + + +
+
+ 加载中... +
+
+ 暂无可选商品 +
+ +
+
diff --git a/apps/web-antd/src/views/product/list/composables/product-list-page/constants.ts b/apps/web-antd/src/views/product/list/composables/product-list-page/constants.ts index 358c9a4..d43f57e 100644 --- a/apps/web-antd/src/views/product/list/composables/product-list-page/constants.ts +++ b/apps/web-antd/src/views/product/list/composables/product-list-page/constants.ts @@ -83,6 +83,8 @@ export const DEFAULT_EDITOR_FORM: ProductEditorFormState = { name: '', subtitle: '', description: '', + comboGroups: [], + imageUrls: [], categoryId: '', kind: 'single', price: 0, diff --git a/apps/web-antd/src/views/product/list/composables/product-list-page/drawer-actions.ts b/apps/web-antd/src/views/product/list/composables/product-list-page/drawer-actions.ts index 2dcb244..21550a9 100644 --- a/apps/web-antd/src/views/product/list/composables/product-list-page/drawer-actions.ts +++ b/apps/web-antd/src/views/product/list/composables/product-list-page/drawer-actions.ts @@ -58,6 +58,8 @@ export function createDrawerActions(options: CreateDrawerActionsOptions) { options.editorForm.name = ''; options.editorForm.subtitle = ''; options.editorForm.description = ''; + options.editorForm.comboGroups = []; + options.editorForm.imageUrls = []; options.editorForm.categoryId = defaultCategoryId; options.editorForm.kind = 'single'; options.editorForm.price = 0; @@ -107,6 +109,37 @@ export function createDrawerActions(options: CreateDrawerActionsOptions) { message.warning('请选择定时上架时间'); return; } + if (options.editorForm.kind === 'combo') { + if (options.editorForm.comboGroups.length === 0) { + message.warning('套餐至少需要一个分组'); + return; + } + + for (const group of options.editorForm.comboGroups) { + if (!group.name.trim()) { + message.warning('请填写套餐分组名称'); + return; + } + if (group.minSelect <= 0 || group.maxSelect <= 0) { + message.warning(`分组「${group.name}」的最小/最大选择数必须大于 0`); + return; + } + if (group.maxSelect < group.minSelect) { + message.warning( + `分组「${group.name}」的最大选择数不能小于最小选择数`, + ); + return; + } + if (group.items.length === 0) { + message.warning(`分组「${group.name}」至少需要一个商品`); + return; + } + if (group.items.some((item) => item.quantity <= 0)) { + message.warning(`分组「${group.name}」的商品数量必须大于 0`); + return; + } + } + } options.isEditorSubmitting.value = true; try { @@ -141,14 +174,19 @@ export function createDrawerActions(options: CreateDrawerActionsOptions) { const current = options.currentQuickEditProduct.value; options.isQuickEditSubmitting.value = true; try { + const detail = await getProductDetailApi({ + storeId: options.selectedStoreId.value, + productId: current.id, + }); + await saveProductApi({ id: current.id, storeId: options.selectedStoreId.value, - categoryId: current.categoryId, - kind: current.kind, - name: current.name, - subtitle: current.subtitle, - description: current.subtitle, + categoryId: detail.categoryId, + kind: detail.kind, + name: detail.name, + subtitle: detail.subtitle, + description: detail.description, price: Number(options.quickEditForm.price || 0), originalPrice: Number(options.quickEditForm.originalPrice || 0) > 0 @@ -158,10 +196,22 @@ export function createDrawerActions(options: CreateDrawerActionsOptions) { 0, Math.floor(Number(options.quickEditForm.stock || 0)), ), - tags: [...current.tags], + tags: [...detail.tags], + comboGroups: (detail.comboGroups ?? []).map((group) => ({ + name: group.name, + minSelect: group.minSelect, + maxSelect: group.maxSelect, + sortOrder: group.sortOrder, + items: group.items.map((item) => ({ + productId: item.productId, + quantity: item.quantity, + sortOrder: item.sortOrder, + })), + })), + imageUrls: detail.imageUrls ?? [], status: options.quickEditForm.isOnSale ? 'on_sale' : 'off_shelf', shelfMode: options.quickEditForm.isOnSale ? 'now' : 'draft', - spuCode: current.spuCode, + spuCode: detail.spuCode, }); message.success('商品已更新'); options.isQuickEditDrawerOpen.value = false; diff --git a/apps/web-antd/src/views/product/list/composables/product-list-page/helpers.ts b/apps/web-antd/src/views/product/list/composables/product-list-page/helpers.ts index 21e72ef..50e870e 100644 --- a/apps/web-antd/src/views/product/list/composables/product-list-page/helpers.ts +++ b/apps/web-antd/src/views/product/list/composables/product-list-page/helpers.ts @@ -49,6 +49,19 @@ export function cloneEditorForm( name: source.name, subtitle: source.subtitle, description: source.description, + comboGroups: source.comboGroups.map((group) => ({ + name: group.name, + minSelect: group.minSelect, + maxSelect: group.maxSelect, + sortOrder: group.sortOrder, + items: group.items.map((item) => ({ + productId: item.productId, + productName: item.productName, + quantity: item.quantity, + sortOrder: item.sortOrder, + })), + })), + imageUrls: [...source.imageUrls], categoryId: source.categoryId, kind: source.kind, price: source.price, @@ -143,11 +156,14 @@ export function formatDateTime(value: Date | dayjs.Dayjs | string) { export function mapListItemToEditorForm( item: ProductListItemDto, ): ProductEditorFormState { + const imageUrls = item.imageUrl ? [item.imageUrl] : []; return { id: item.id, name: item.name, subtitle: item.subtitle, description: item.subtitle, + comboGroups: [], + imageUrls, categoryId: item.categoryId, kind: item.kind, price: item.price, @@ -164,11 +180,30 @@ export function mapListItemToEditorForm( export function mapDetailToEditorForm( detail: ProductDetailDto, ): ProductEditorFormState { + const imageUrls = [...(detail.imageUrls ?? []), detail.imageUrl] + .map((item) => String(item || '').trim()) + .filter(Boolean) + .filter((item, index, source) => source.indexOf(item) === index) + .slice(0, 5); + return { id: detail.id, name: detail.name, subtitle: detail.subtitle, description: detail.description, + comboGroups: (detail.comboGroups ?? []).map((group) => ({ + name: group.name, + minSelect: group.minSelect, + maxSelect: group.maxSelect, + sortOrder: group.sortOrder, + items: group.items.map((item) => ({ + productId: item.productId, + productName: item.productName, + quantity: item.quantity, + sortOrder: item.sortOrder, + })), + })), + imageUrls, categoryId: detail.categoryId, kind: detail.kind, price: detail.price, @@ -237,6 +272,31 @@ export function toSavePayload( : null, stock: Math.max(0, Math.floor(Number(form.stock || 0))), tags: textToTags(form.tagsText), + comboGroups: + form.kind === 'combo' + ? form.comboGroups.map((group, groupIndex) => ({ + name: group.name.trim(), + minSelect: Math.max(1, Math.floor(Number(group.minSelect || 1))), + maxSelect: Math.max(1, Math.floor(Number(group.maxSelect || 1))), + sortOrder: Math.max( + 1, + Math.floor(Number(group.sortOrder || groupIndex + 1)), + ), + items: group.items.map((item, itemIndex) => ({ + productId: item.productId, + quantity: Math.max(1, Math.floor(Number(item.quantity || 1))), + sortOrder: Math.max( + 1, + Math.floor(Number(item.sortOrder || itemIndex + 1)), + ), + })), + })) + : [], + imageUrls: form.imageUrls + .map((item) => item.trim()) + .filter(Boolean) + .filter((item, index, source) => source.indexOf(item) === index) + .slice(0, 5), status: normalizedStatus, shelfMode: form.shelfMode, timedOnShelfAt: diff --git a/apps/web-antd/src/views/product/list/composables/useProductListPage.ts b/apps/web-antd/src/views/product/list/composables/useProductListPage.ts index d6bbe0b..b58cb6c 100644 --- a/apps/web-antd/src/views/product/list/composables/useProductListPage.ts +++ b/apps/web-antd/src/views/product/list/composables/useProductListPage.ts @@ -7,6 +7,7 @@ import type { ProductCategoryDto, ProductListItemDto, + ProductPickerItemDto, ProductStatus, } from '#/api/product'; import type { StoreListItemDto } from '#/api/store'; @@ -18,6 +19,11 @@ import type { import { computed, onMounted, reactive, ref, watch } from 'vue'; import { useRouter } from 'vue-router'; +import { message, Modal } from 'ant-design-vue'; + +import { uploadTenantFileApi } from '#/api/files'; +import { searchProductPickerApi } from '#/api/product'; + import { createBatchActions } from './product-list-page/batch-actions'; import { DEFAULT_EDITOR_FORM, @@ -49,6 +55,7 @@ export function useProductListPage() { const isCategoryLoading = ref(false); const isListLoading = ref(false); const isEditorSubmitting = ref(false); + const isEditorImageUploading = ref(false); const isQuickEditSubmitting = ref(false); const isSoldoutSubmitting = ref(false); @@ -78,6 +85,13 @@ export function useProductListPage() { const soldoutForm = reactive(cloneSoldoutForm(DEFAULT_SOLDOUT_FORM)); const currentSoldoutProduct = ref(null); + const isComboPickerOpen = ref(false); + const isComboPickerLoading = ref(false); + const comboPickerKeyword = ref(''); + const comboPickerProducts = ref([]); + const comboPickerSelectedIds = ref([]); + const comboPickerTargetGroupIndex = ref(-1); + // 4. 衍生状态。 const storeOptions = computed(() => stores.value.map((item) => ({ label: item.name, value: item.id })), @@ -243,8 +257,242 @@ export function useProductListPage() { editorForm.description = value; } + function removeEditorImage(index: number) { + editorForm.imageUrls = editorForm.imageUrls.filter( + (_, idx) => idx !== index, + ); + } + + function setEditorPrimaryImage(index: number) { + if (index <= 0 || index >= editorForm.imageUrls.length) return; + const next = [...editorForm.imageUrls]; + const [selected] = next.splice(index, 1); + if (!selected) return; + editorForm.imageUrls = [selected, ...next]; + } + + async function uploadEditorImage(file: File) { + if (editorForm.imageUrls.length >= 5) { + message.warning('最多上传 5 张图片'); + return; + } + + isEditorImageUploading.value = true; + try { + const uploaded = await uploadTenantFileApi(file, 'dish_image'); + const url = String(uploaded.url || '').trim(); + if (!url) { + message.error('图片上传失败'); + return; + } + + editorForm.imageUrls = [...editorForm.imageUrls, url] + .map((item) => String(item || '').trim()) + .filter(Boolean) + .filter((item, index, source) => source.indexOf(item) === index) + .slice(0, 5); + message.success('图片上传成功'); + } catch (error) { + console.error(error); + } finally { + isEditorImageUploading.value = false; + } + } + + function normalizeComboGroups() { + editorForm.comboGroups = editorForm.comboGroups.map( + (group, groupIndex) => ({ + ...group, + sortOrder: groupIndex + 1, + items: group.items.map((item, itemIndex) => ({ + ...item, + sortOrder: itemIndex + 1, + })), + }), + ); + } + function setEditorKind(value: 'combo' | 'single') { + if (value === editorForm.kind) { + return; + } + + if (editorForm.kind === 'combo' && value === 'single') { + const hasComboData = editorForm.comboGroups.some( + (group) => group.items.length > 0 || Boolean(group.name.trim()), + ); + if (hasComboData) { + Modal.confirm({ + title: '切换为单品后将清空套餐分组,确认继续吗?', + okText: '确认切换', + cancelText: '取消', + onOk() { + editorForm.kind = 'single'; + editorForm.comboGroups = []; + }, + }); + return; + } + editorForm.comboGroups = []; + } + editorForm.kind = value; + if (value === 'combo' && editorForm.comboGroups.length === 0) { + editorForm.comboGroups = [ + { + name: '', + minSelect: 1, + maxSelect: 1, + sortOrder: 1, + items: [], + }, + ]; + } + } + + function addEditorComboGroup() { + editorForm.comboGroups = [ + ...editorForm.comboGroups, + { + name: '', + minSelect: 1, + maxSelect: 1, + sortOrder: editorForm.comboGroups.length + 1, + items: [], + }, + ]; + } + + function removeEditorComboGroup(groupIndex: number) { + if (groupIndex < 0 || groupIndex >= editorForm.comboGroups.length) { + return; + } + editorForm.comboGroups = editorForm.comboGroups.filter( + (_, index) => index !== groupIndex, + ); + if (comboPickerTargetGroupIndex.value === groupIndex) { + isComboPickerOpen.value = false; + comboPickerTargetGroupIndex.value = -1; + } + normalizeComboGroups(); + } + + function setEditorComboGroupName(groupIndex: number, value: string) { + const group = editorForm.comboGroups[groupIndex]; + if (!group) return; + group.name = value; + } + + function setEditorComboGroupMinSelect(groupIndex: number, value: number) { + const group = editorForm.comboGroups[groupIndex]; + if (!group) return; + group.minSelect = Math.max(1, Math.floor(Number(value || 1))); + } + + function setEditorComboGroupMaxSelect(groupIndex: number, value: number) { + const group = editorForm.comboGroups[groupIndex]; + if (!group) return; + group.maxSelect = Math.max(1, Math.floor(Number(value || 1))); + } + + function removeEditorComboItem(groupIndex: number, itemIndex: number) { + const group = editorForm.comboGroups[groupIndex]; + if (!group) return; + group.items = group.items.filter((_, index) => index !== itemIndex); + normalizeComboGroups(); + } + + function setEditorComboItemQuantity( + groupIndex: number, + itemIndex: number, + value: number, + ) { + const group = editorForm.comboGroups[groupIndex]; + if (!group) return; + const item = group.items[itemIndex]; + if (!item) return; + item.quantity = Math.max(1, Math.floor(Number(value || 1))); + } + + async function loadComboPickerProducts() { + if (!selectedStoreId.value) { + comboPickerProducts.value = []; + return; + } + isComboPickerLoading.value = true; + try { + comboPickerProducts.value = await searchProductPickerApi({ + storeId: selectedStoreId.value, + keyword: comboPickerKeyword.value.trim() || undefined, + limit: 500, + }); + } catch (error) { + console.error(error); + comboPickerProducts.value = []; + message.error('加载商品失败'); + } finally { + isComboPickerLoading.value = false; + } + } + + async function openComboGroupPicker(groupIndex: number) { + const group = editorForm.comboGroups[groupIndex]; + if (!group) return; + comboPickerTargetGroupIndex.value = groupIndex; + comboPickerKeyword.value = ''; + comboPickerSelectedIds.value = group.items.map((item) => item.productId); + comboPickerProducts.value = []; + isComboPickerOpen.value = true; + await loadComboPickerProducts(); + } + + function setComboPickerOpen(value: boolean) { + isComboPickerOpen.value = value; + if (!value) { + comboPickerTargetGroupIndex.value = -1; + } + } + + function setComboPickerKeyword(value: string) { + comboPickerKeyword.value = value; + } + + function toggleComboPickerProduct(productId: string) { + if (comboPickerSelectedIds.value.includes(productId)) { + comboPickerSelectedIds.value = comboPickerSelectedIds.value.filter( + (id) => id !== productId, + ); + return; + } + comboPickerSelectedIds.value = [...comboPickerSelectedIds.value, productId]; + } + + function submitComboPickerSelection() { + const groupIndex = comboPickerTargetGroupIndex.value; + const group = editorForm.comboGroups[groupIndex]; + if (!group) return; + const selectedIds = comboPickerSelectedIds.value; + if (selectedIds.length === 0) { + message.warning('请至少选择一个商品'); + return; + } + + const sourceLookup = new Map( + comboPickerProducts.value.map((item) => [item.id, item]), + ); + group.items = selectedIds.map((id, index) => { + const existing = group.items.find((item) => item.productId === id); + const source = sourceLookup.get(id); + return { + productId: id, + productName: existing?.productName ?? source?.name ?? id, + quantity: Math.max(1, Math.floor(Number(existing?.quantity ?? 1))), + sortOrder: index + 1, + }; + }); + normalizeComboGroups(); + isComboPickerOpen.value = false; + comboPickerTargetGroupIndex.value = -1; } function setEditorPrice(value: number) { @@ -409,6 +657,9 @@ export function useProductListPage() { categoryOptions, categorySidebarItems, clearSelection, + comboPickerKeyword, + comboPickerProducts, + comboPickerSelectedIds, currentPageIds, deleteProduct: handleDeleteProduct, editorDrawerMode, @@ -425,7 +676,10 @@ export function useProductListPage() { isAllCurrentPageChecked, isCategoryLoading, isCurrentPageIndeterminate, + isComboPickerLoading, + isComboPickerOpen, isEditorDrawerOpen, + isEditorImageUploading, isEditorSubmitting, isListLoading, isQuickEditDrawerOpen, @@ -451,6 +705,19 @@ export function useProductListPage() { setEditorDescription, setEditorDrawerOpen, setEditorKind, + addEditorComboGroup, + removeEditorComboGroup, + setEditorComboGroupName, + setEditorComboGroupMinSelect, + setEditorComboGroupMaxSelect, + removeEditorComboItem, + setEditorComboItemQuantity, + openComboGroupPicker, + setComboPickerOpen, + setComboPickerKeyword, + toggleComboPickerProduct, + submitComboPickerSelection, + loadComboPickerProducts, setEditorName, setEditorOriginalPrice, setEditorPrice, @@ -459,6 +726,9 @@ export function useProductListPage() { setEditorSubtitle, setEditorTagsText, setEditorTimedOnShelfAt, + removeEditorImage, + setEditorPrimaryImage, + uploadEditorImage, setFilterKind, setFilterKeyword, setFilterStatus, diff --git a/apps/web-antd/src/views/product/list/index.vue b/apps/web-antd/src/views/product/list/index.vue index 84a33a4..88c0dca 100644 --- a/apps/web-antd/src/views/product/list/index.vue +++ b/apps/web-antd/src/views/product/list/index.vue @@ -28,6 +28,9 @@ const { categoryOptions, categorySidebarItems, clearSelection, + comboPickerKeyword, + comboPickerProducts, + comboPickerSelectedIds, deleteProduct, editorDrawerMode, editorDrawerTitle, @@ -41,6 +44,8 @@ const { handleToggleSelectAll, isAllCurrentPageChecked, isCategoryLoading, + isComboPickerLoading, + isComboPickerOpen, isCurrentPageIndeterminate, isEditorDrawerOpen, isEditorSubmitting, @@ -62,7 +67,18 @@ const { selectedCount, selectedProductIds, selectedStoreId, + addEditorComboGroup, + loadComboPickerProducts, + openComboGroupPicker, + removeEditorComboGroup, + removeEditorComboItem, + setComboPickerKeyword, + setComboPickerOpen, setEditorCategoryId, + setEditorComboGroupMaxSelect, + setEditorComboGroupMinSelect, + setEditorComboGroupName, + setEditorComboItemQuantity, setEditorDescription, setEditorDrawerOpen, setEditorKind, @@ -72,7 +88,6 @@ const { setEditorShelfMode, setEditorStock, setEditorSubtitle, - setEditorTagsText, setEditorTimedOnShelfAt, setFilterKind, setFilterKeyword, @@ -91,6 +106,7 @@ const { setSoldoutRecoverAt, setSoldoutRemainStock, setSoldoutSyncToPlatform, + submitComboPickerSelection, setViewMode, soldoutForm, soldoutSummary, @@ -98,6 +114,7 @@ const { submitEditor, submitQuickEdit, submitSoldout, + toggleComboPickerProduct, total, viewMode, } = useProductListPage(); @@ -204,12 +221,29 @@ function onBatchAction(action: ProductBatchAction) { :on-set-category-id="setEditorCategoryId" :on-set-description="setEditorDescription" :on-set-kind="setEditorKind" + :on-add-combo-group="addEditorComboGroup" + :on-remove-combo-group="removeEditorComboGroup" + :on-set-combo-group-name="setEditorComboGroupName" + :on-set-combo-group-min-select="setEditorComboGroupMinSelect" + :on-set-combo-group-max-select="setEditorComboGroupMaxSelect" + :on-remove-combo-item="removeEditorComboItem" + :on-set-combo-item-quantity="setEditorComboItemQuantity" + :on-open-combo-group-picker="openComboGroupPicker" :on-set-price="setEditorPrice" :on-set-original-price="setEditorOriginalPrice" :on-set-stock="setEditorStock" - :on-set-tags-text="setEditorTagsText" :on-set-shelf-mode="setEditorShelfMode" :on-set-timed-on-shelf-at="setEditorTimedOnShelfAt" + :combo-picker-open="isComboPickerOpen" + :combo-picker-loading="isComboPickerLoading" + :combo-picker-keyword="comboPickerKeyword" + :combo-picker-products="comboPickerProducts" + :combo-picker-selected-ids="comboPickerSelectedIds" + :on-set-combo-picker-open="setComboPickerOpen" + :on-set-combo-picker-keyword="setComboPickerKeyword" + :on-search-combo-picker="loadComboPickerProducts" + :on-toggle-combo-picker-product="toggleComboPickerProduct" + :on-submit-combo-picker="submitComboPickerSelection" @update:open="setEditorDrawerOpen" @submit="submitEditor" @detail="handleEditorDetail" diff --git a/apps/web-antd/src/views/product/list/styles/drawer.less b/apps/web-antd/src/views/product/list/styles/drawer.less index 55863f6..7ce7410 100644 --- a/apps/web-antd/src/views/product/list/styles/drawer.less +++ b/apps/web-antd/src/views/product/list/styles/drawer.less @@ -28,12 +28,26 @@ } .product-drawer-section-title { + position: relative; + padding-left: 14px; margin-bottom: 10px; font-size: 14px; font-weight: 600; color: #1f2937; } + .product-drawer-section-title::before { + position: absolute; + top: -2px; + bottom: -2px; + left: 0; + width: 4px; + content: ''; + background: linear-gradient(180deg, #4da3ff 0%, #1677ff 100%); + border-radius: 6px; + box-shadow: 0 0 0 1px rgb(22 119 255 / 10%); + } + .drawer-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -90,24 +104,55 @@ } .product-editor-drawer { - .product-kind-radio-group { - width: 100%; + .drawer-field-hint { + margin-top: 8px; + font-size: 12px; + color: #9ca3af; } - .product-kind-radio-group .ant-radio-button-wrapper { - width: 96px; - text-align: center; + .price-stock-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .product-kind-pill-group { + display: flex; + gap: 8px; + } + + .product-kind-pill { + min-width: 90px; + height: 34px; + padding: 0 16px; + font-size: 13px; + color: #6b7280; + cursor: pointer; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 999px; + transition: + color 0.2s ease, + border-color 0.2s ease, + background-color 0.2s ease; + } + + .product-kind-pill.active { + color: #1677ff; + background: #eff6ff; + border-color: #1677ff; } .product-shelf-radio-group { - display: flex; - flex-direction: column; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; + width: 100%; } .shelf-radio-item { - padding: 8px 10px; - margin-inline-start: 0; + box-sizing: border-box; + width: 100%; + padding: 10px 12px; + margin-inline: 0; background: #f8fafc; border: 1px solid #e5e7eb; border-radius: 8px; @@ -116,6 +161,187 @@ .shelf-time-row { margin-top: 10px; } + + .combo-hint { + margin-bottom: 10px; + } + + .combo-group-list { + display: flex; + flex-direction: column; + gap: 10px; + } + + .combo-group-card { + padding: 12px; + background: #f8f9fb; + border: 1px solid #e5e7eb; + border-radius: 10px; + } + + .combo-group-head { + display: flex; + gap: 10px; + align-items: center; + margin-bottom: 10px; + } + + .combo-group-range { + display: flex; + flex-shrink: 0; + gap: 6px; + align-items: center; + font-size: 12px; + color: #6b7280; + } + + .combo-range-input { + width: 48px; + } + + .combo-remove-group { + flex-shrink: 0; + padding-inline: 8px; + } + + .combo-item-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 4px; + } + + .combo-item-row { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + background: #fff; + border: 1px solid #edf0f3; + border-radius: 8px; + } + + .combo-item-empty { + padding: 10px; + font-size: 12px; + color: #9ca3af; + text-align: center; + background: #fff; + border: 1px dashed #d1d5db; + border-radius: 8px; + } + + .combo-item-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: 13px; + color: #1f2937; + white-space: nowrap; + } + + .combo-item-actions { + display: flex; + flex-shrink: 0; + gap: 6px; + align-items: center; + } + + .combo-item-multiplier { + font-size: 12px; + color: #9ca3af; + } + + .combo-quantity-input { + width: 50px; + } + + .combo-remove-item { + padding-inline: 8px; + color: #6b7280; + } + + .combo-remove-item:hover { + color: #ef4444; + } + + .combo-add-product { + padding-left: 0; + font-size: 13px; + } + + .combo-add-group { + width: 100%; + height: 36px; + font-size: 13px; + color: #9ca3af; + cursor: pointer; + background: #fff; + border: 1px solid #e5e7eb; + border-style: dashed; + border-radius: 8px; + transition: + color 0.2s ease, + border-color 0.2s ease; + } + + .combo-add-group:hover { + color: #1677ff; + border-color: #1677ff; + } +} + +.combo-picker-search { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 10px; +} + +.combo-picker-list { + max-height: 300px; + overflow-y: auto; + border: 1px solid #f0f0f0; + border-radius: 8px; +} + +.combo-picker-item { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + gap: 8px; + align-items: center; + padding: 10px 12px; + border-bottom: 1px solid #f5f5f5; +} + +.combo-picker-item:last-child { + border-bottom: none; +} + +.combo-picker-item .name { + overflow: hidden; + text-overflow: ellipsis; + color: #1f2937; + white-space: nowrap; +} + +.combo-picker-item .spu { + font-size: 12px; + color: #9ca3af; +} + +.combo-picker-item .price { + font-size: 12px; + color: #1677ff; +} + +.combo-picker-empty { + padding: 24px 0; + font-size: 12px; + color: #9ca3af; + text-align: center; } .product-quick-edit-drawer, diff --git a/apps/web-antd/src/views/product/list/types.ts b/apps/web-antd/src/views/product/list/types.ts index 0e18ed4..ed984ca 100644 --- a/apps/web-antd/src/views/product/list/types.ts +++ b/apps/web-antd/src/views/product/list/types.ts @@ -33,11 +33,30 @@ export interface ProductCategorySidebarItem { /** 商品编辑抽屉模式。 */ export type ProductEditorDrawerMode = 'create' | 'edit'; +/** 套餐分组商品编辑态。 */ +export interface ProductEditorComboItemState { + productId: string; + productName: string; + quantity: number; + sortOrder: number; +} + +/** 套餐分组编辑态。 */ +export interface ProductEditorComboGroupState { + items: ProductEditorComboItemState[]; + maxSelect: number; + minSelect: number; + name: string; + sortOrder: number; +} + /** 商品编辑表单。 */ export interface ProductEditorFormState { categoryId: string; + comboGroups: ProductEditorComboGroupState[]; description: string; id: string; + imageUrls: string[]; kind: ProductKind; name: string; originalPrice: null | number;