From d6e5138e53e08c60ec1ef020b11670e69312b7a8 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Sun, 22 Feb 2026 10:12:38 +0800 Subject: [PATCH] fix(project): split product detail module and restore detail route --- apps/web-antd/src/router/access.ts | 98 +- .../product-detail-page/combo-actions.ts | 187 ++++ .../product-detail-page/constants.ts | 25 + .../product-detail-page/data-actions.ts | 396 ++++++++ .../product-detail-page/helpers.ts | 117 +++ .../product-detail-page/sku-actions.ts | 194 ++++ .../composables/useProductDetailPage.ts | 892 ++---------------- .../src/views/product/detail/types.ts | 5 + 8 files changed, 1100 insertions(+), 814 deletions(-) create mode 100644 apps/web-antd/src/views/product/detail/composables/product-detail-page/combo-actions.ts create mode 100644 apps/web-antd/src/views/product/detail/composables/product-detail-page/constants.ts create mode 100644 apps/web-antd/src/views/product/detail/composables/product-detail-page/data-actions.ts create mode 100644 apps/web-antd/src/views/product/detail/composables/product-detail-page/helpers.ts create mode 100644 apps/web-antd/src/views/product/detail/composables/product-detail-page/sku-actions.ts diff --git a/apps/web-antd/src/router/access.ts b/apps/web-antd/src/router/access.ts index 3a48be2..be94ba3 100644 --- a/apps/web-antd/src/router/access.ts +++ b/apps/web-antd/src/router/access.ts @@ -1,6 +1,7 @@ import type { ComponentRecordType, GenerateMenuAndRoutesOptions, + RouteRecordStringComponent, } from '@vben/types'; import { generateAccessible } from '@vben/access'; @@ -14,6 +15,11 @@ import { $t } from '#/locales'; const forbiddenComponent = () => import('#/views/_core/fallback/forbidden.vue'); +const PRODUCT_PATH = '/product'; +const PRODUCT_DETAIL_PATH = '/product/detail'; +const PRODUCT_DETAIL_NAME = 'ProductDetail'; +const PRODUCT_LIST_PATH = '/product/list'; + async function generateAccess(options: GenerateMenuAndRoutesOptions) { const pageMap: ComponentRecordType = import.meta.glob('../views/**/*.vue'); @@ -29,7 +35,8 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) { content: `${$t('common.loadingMenu')}...`, duration: 1.5, }); - return await getAllMenusApi(); + const menuList = await getAllMenusApi(); + return ensureProductDetailRoute(menuList); }, // 可以指定没有权限跳转403页面 forbiddenComponent, @@ -39,4 +46,93 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) { }); } +function ensureProductDetailRoute( + routes: RouteRecordStringComponent[], +): RouteRecordStringComponent[] { + const clonedRoutes = cloneMenuRoutes(routes); + if (containsRoute(clonedRoutes, PRODUCT_DETAIL_PATH, PRODUCT_DETAIL_NAME)) { + return clonedRoutes; + } + + const productRoute = findProductRoute(clonedRoutes); + if (!productRoute) { + return clonedRoutes; + } + + const productChildren = [...(productRoute.children ?? [])]; + const listMeta = productChildren.find( + (item) => String(item.path || '').trim() === PRODUCT_LIST_PATH, + )?.meta; + + productChildren.push({ + name: PRODUCT_DETAIL_NAME, + path: PRODUCT_DETAIL_PATH, + component: '/views/product/detail/index.vue', + meta: { + ...(listMeta ?? {}), + title: '商品详情', + hideInMenu: true, + }, + }); + + productRoute.children = productChildren; + return clonedRoutes; +} + +function cloneMenuRoutes( + routes: RouteRecordStringComponent[], +): RouteRecordStringComponent[] { + return routes.map((route) => ({ + ...route, + meta: route.meta ? { ...route.meta } : route.meta, + children: route.children ? cloneMenuRoutes(route.children) : undefined, + })); +} + +function containsRoute( + routes: RouteRecordStringComponent[], + path: string, + name: string, +): boolean { + for (const route of routes) { + const routePath = String(route.path || '').trim(); + const routeName = String(route.name || '').trim(); + if (routePath === path || routeName === name) { + return true; + } + if (route.children && containsRoute(route.children, path, name)) { + return true; + } + } + return false; +} + +function findProductRoute( + routes: RouteRecordStringComponent[], +): null | RouteRecordStringComponent { + for (const route of routes) { + const routePath = String(route.path || '').trim(); + const routeName = String(route.name || '').trim(); + const hasProductListChild = (route.children ?? []).some( + (item) => String(item.path || '').trim() === PRODUCT_LIST_PATH, + ); + + if ( + routePath === PRODUCT_PATH || + routeName === 'Product' || + hasProductListChild + ) { + return route; + } + + if (route.children) { + const nestedMatch = findProductRoute(route.children); + if (nestedMatch) { + return nestedMatch; + } + } + } + return null; +} + export { generateAccess }; diff --git a/apps/web-antd/src/views/product/detail/composables/product-detail-page/combo-actions.ts b/apps/web-antd/src/views/product/detail/composables/product-detail-page/combo-actions.ts new file mode 100644 index 0000000..b36aa09 --- /dev/null +++ b/apps/web-antd/src/views/product/detail/composables/product-detail-page/combo-actions.ts @@ -0,0 +1,187 @@ +import type { Ref } from 'vue'; + +import type { ProductPickerItemDto } from '#/api/product'; + +import { computed } from 'vue'; + +import { searchProductPickerApi } from '#/api/product'; + +import type { ProductDetailFormState } from '../../types'; + +interface CreateProductDetailComboActionsOptions { + comboPickerCurrentGroupIndex: Ref; + comboPickerKeyword: Ref; + comboPickerLoading: Ref; + comboPickerOpen: Ref; + comboPickerProducts: Ref; + comboPickerSelectedIds: Ref; + form: ProductDetailFormState; + storeId: Ref; +} + +export function createProductDetailComboActions( + options: CreateProductDetailComboActionsOptions, +) { + const { + comboPickerCurrentGroupIndex, + comboPickerKeyword, + comboPickerLoading, + comboPickerOpen, + comboPickerProducts, + comboPickerSelectedIds, + form, + storeId, + } = options; + + const comboPickerSelectedProducts = computed(() => + comboPickerProducts.value.filter((item) => + comboPickerSelectedIds.value.includes(item.id), + ), + ); + + function addComboGroup() { + form.comboGroups.push({ + name: '', + minSelect: 1, + maxSelect: 1, + sortOrder: form.comboGroups.length + 1, + items: [], + }); + } + + 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; + 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); + } + + return { + addComboGroup, + comboPickerSelectedProducts, + openComboPicker, + removeComboGroup, + removeComboItem, + searchComboPicker, + setComboGroupMaxSelect, + setComboGroupMinSelect, + setComboGroupName, + setComboItemQuantity, + setComboPickerOpen, + submitComboPicker, + toggleComboPickerProduct, + }; +} diff --git a/apps/web-antd/src/views/product/detail/composables/product-detail-page/constants.ts b/apps/web-antd/src/views/product/detail/composables/product-detail-page/constants.ts new file mode 100644 index 0000000..9a0cd47 --- /dev/null +++ b/apps/web-antd/src/views/product/detail/composables/product-detail-page/constants.ts @@ -0,0 +1,25 @@ +import type { ProductDetailFormState } from '../../types'; + +export const DEFAULT_PRODUCT_DETAIL_FORM: ProductDetailFormState = { + id: '', + name: '', + subtitle: '', + 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', + shelfMode: 'draft', + timedOnShelfAt: '', +}; 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 new file mode 100644 index 0000000..06c1e0e --- /dev/null +++ b/apps/web-antd/src/views/product/detail/composables/product-detail-page/data-actions.ts @@ -0,0 +1,396 @@ +import type { Ref } from 'vue'; +import type { Router } from 'vue-router'; + +import type { + ProductAddonGroupDto, + ProductDetailDto, + ProductLabelDto, + ProductSpecDto, + ProductStatus, +} from '#/api/product'; + +import { message } from 'ant-design-vue'; + +import { uploadTenantFileApi } from '#/api/files'; +import { + deleteProductApi, + getProductAddonGroupListApi, + getProductCategoryListApi, + getProductDetailApi, + getProductLabelListApi, + getProductSpecListApi, + saveProductApi, +} from '#/api/product'; + +import type { + ProductDetailCategoryOption, + ProductDetailFormState, +} from '../../types'; + +import { DEFAULT_PRODUCT_DETAIL_FORM } from './constants'; +import { + buildLocalSkuCode, + dedupeTextList, + normalizeComboGroups, + normalizeSkuRows, +} from './helpers'; + +interface CreateProductDetailDataActionsOptions { + addonGroupOptions: Ref; + buildSkuRows: () => void; + categoryOptions: Ref; + detail: Ref; + form: ProductDetailFormState; + isLoading: Ref; + isSubmitting: Ref; + isUploadingImage: Ref; + labelOptions: Ref; + productId: Ref; + router: Router; + specTemplateOptions: Ref; + storeId: Ref; +} + +export function createProductDetailDataActions( + options: CreateProductDetailDataActionsOptions, +) { + const { + addonGroupOptions, + buildSkuRows, + categoryOptions, + detail, + form, + isLoading, + isSubmitting, + isUploadingImage, + labelOptions, + productId, + router, + specTemplateOptions, + storeId, + } = options; + + function resetForm() { + Object.assign(form, { + ...DEFAULT_PRODUCT_DETAIL_FORM, + imageUrls: [], + specTemplateIds: [], + addonGroupIds: [], + labelIds: [], + skus: [], + comboGroups: [], + }); + } + + function patchForm(data: ProductDetailDto) { + form.id = data.id; + form.name = data.name; + form.subtitle = data.subtitle; + form.categoryId = data.categoryId; + form.kind = data.kind; + form.description = data.description; + 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; + if (data.status === 'on_sale') { + form.shelfMode = 'now'; + } else if (data.timedOnShelfAt) { + form.shelfMode = 'scheduled'; + } else { + form.shelfMode = 'draft'; + } + form.timedOnShelfAt = data.timedOnShelfAt || ''; + } + + function clearPageData() { + detail.value = null; + resetForm(); + categoryOptions.value = []; + specTemplateOptions.value = []; + addonGroupOptions.value = []; + labelOptions.value = []; + } + + 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]; + } + + async function uploadImage(file: File) { + isUploadingImage.value = true; + try { + const uploaded = await uploadTenantFileApi(file, 'dish_image'); + const url = String(uploaded.url || '').trim(); + if (!url) { + message.error('图片上传失败'); + return; + } + form.imageUrls = dedupeTextList([...form.imageUrls, url]).slice(0, 5); + message.success('图片上传成功'); + } catch (error) { + console.error(error); + } finally { + isUploadingImage.value = false; + } + } + + 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 next = [...form.imageUrls]; + const [picked] = next.splice(index, 1); + if (!picked) return; + form.imageUrls = [picked, ...next]; + } + + async function loadDetail() { + if (!storeId.value || !productId.value) { + clearPageData(); + 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); + clearPageData(); + } finally { + isLoading.value = false; + } + } + + async function saveDetail() { + if (!storeId.value || !form.id) return; + if (!form.name.trim()) { + message.warning('请输入商品名称'); + return; + } + if (!form.categoryId) { + message.warning('请选择商品分类'); + return; + } + if (form.shelfMode === 'scheduled' && !form.timedOnShelfAt) { + message.warning('请选择定时上架时间'); + 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({ + id: form.id, + storeId: storeId.value, + categoryId: form.categoryId, + kind: form.kind, + name: form.name.trim(), + subtitle: form.subtitle.trim(), + description: form.description.trim(), + price: Number(Number(form.price || 0).toFixed(2)), + 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))), + status: form.status, + shelfMode: form.shelfMode, + timedOnShelfAt: + 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); + } finally { + isSubmitting.value = false; + } + } + + async function toggleSaleStatus(next: ProductStatus) { + if (next !== 'on_sale' && next !== 'off_shelf') return; + form.status = next; + form.shelfMode = next === 'on_sale' ? 'now' : 'draft'; + 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({ + storeId: storeId.value, + productId: form.id, + }); + message.success('商品已删除'); + router.push('/product/list'); + } + + return { + deleteCurrentProduct, + loadDetail, + removeImage, + saveDetail, + setPrimaryImage, + setShelfMode, + toggleAddonGroup, + toggleLabel, + toggleSaleStatus, + uploadImage, + }; +} diff --git a/apps/web-antd/src/views/product/detail/composables/product-detail-page/helpers.ts b/apps/web-antd/src/views/product/detail/composables/product-detail-page/helpers.ts new file mode 100644 index 0000000..d49e606 --- /dev/null +++ b/apps/web-antd/src/views/product/detail/composables/product-detail-page/helpers.ts @@ -0,0 +1,117 @@ +import type { + ProductDetailComboGroupItemState, + ProductDetailComboGroupState, + ProductDetailSkuAttrState, + ProductDetailSkuRowState, +} from '../../types'; + +import type { ProductDetailDto, ProductSkuDto } from '#/api/product'; + +interface ProductDetailSkuTemplateInput { + id: string; + name: string; + options: Array<{ id: string; name: string }>; +} + +export function dedupeTextList(source: string[]) { + return source + .map((item) => String(item || '').trim()) + .filter(Boolean) + .filter((item, index, list) => list.indexOf(item) === index); +} + +export function buildSkuCombinations( + templates: ProductDetailSkuTemplateInput[], +) { + 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; +} + +export 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('|'); +} + +export 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); +} + +export function normalizeComboGroups( + source: ProductDetailDto['comboGroups'], +): ProductDetailComboGroupState[] { + 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)), + ), + }), + ), + }), + ); +} + +export function buildLocalSkuCode(index: number) { + return `SKU-${String(index).padStart(2, '0')}`; +} diff --git a/apps/web-antd/src/views/product/detail/composables/product-detail-page/sku-actions.ts b/apps/web-antd/src/views/product/detail/composables/product-detail-page/sku-actions.ts new file mode 100644 index 0000000..ffe39ee --- /dev/null +++ b/apps/web-antd/src/views/product/detail/composables/product-detail-page/sku-actions.ts @@ -0,0 +1,194 @@ +import type { Ref } from 'vue'; + +import type { ProductSpecDto } from '#/api/product'; + +import type { + ProductDetailFormState, + ProductDetailSkuBatchState, + ProductDetailSkuRowState, +} from '../../types'; + +import { + buildLocalSkuCode, + buildSkuCombinations, + buildSkuKey, +} from './helpers'; + +interface CreateProductDetailSkuActionsOptions { + form: ProductDetailFormState; + skuBatch: ProductDetailSkuBatchState; + specTemplateOptions: Ref; +} + +export function createProductDetailSkuActions( + options: CreateProductDetailSkuActionsOptions, +) { + const { form, skuBatch, specTemplateOptions } = options; + + 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; + } + + 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 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, + })); + } + + return { + applySkuBatchPrice, + applySkuBatchStock, + buildSkuRows, + getOptionName, + getSkuAttrOptionId, + getTemplateName, + setSkuEnabled, + setSkuOriginalPrice, + setSkuPrice, + setSkuStock, + toggleSpecTemplate, + }; +} 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 6352503..7089484 100644 --- a/apps/web-antd/src/views/product/detail/composables/useProductDetailPage.ts +++ b/apps/web-antd/src/views/product/detail/composables/useProductDetailPage.ts @@ -1,10 +1,7 @@ import type { - ProductDetailComboGroupItemState, - ProductDetailComboGroupState, + ProductDetailCategoryOption, ProductDetailFormState, - ProductDetailSkuAttrState, ProductDetailSkuBatchState, - ProductDetailSkuRowState, } from '../types'; import type { @@ -12,56 +9,16 @@ import type { ProductDetailDto, ProductLabelDto, ProductPickerItemDto, - ProductSkuDto, ProductSpecDto, - ProductStatus, } from '#/api/product'; import { computed, reactive, ref, watch } from 'vue'; import { useRoute, useRouter } from 'vue-router'; -import { message } from 'ant-design-vue'; - -import { uploadTenantFileApi } from '#/api/files'; -import { - deleteProductApi, - getProductAddonGroupListApi, - getProductCategoryListApi, - getProductDetailApi, - getProductLabelListApi, - getProductSpecListApi, - saveProductApi, - searchProductPickerApi, -} from '#/api/product'; - -const DEFAULT_FORM: ProductDetailFormState = { - id: '', - name: '', - subtitle: '', - 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', - shelfMode: 'draft', - timedOnShelfAt: '', -}; - -interface CategoryOption { - label: string; - value: string; -} +import { DEFAULT_PRODUCT_DETAIL_FORM } from './product-detail-page/constants'; +import { createProductDetailComboActions } from './product-detail-page/combo-actions'; +import { createProductDetailDataActions } from './product-detail-page/data-actions'; +import { createProductDetailSkuActions } from './product-detail-page/sku-actions'; export function useProductDetailPage() { const route = useRoute(); @@ -72,7 +29,7 @@ export function useProductDetailPage() { const isUploadingImage = ref(false); const detail = ref(null); - const categoryOptions = ref([]); + const categoryOptions = ref([]); const specTemplateOptions = ref([]); const addonGroupOptions = ref([]); const labelOptions = ref([]); @@ -89,7 +46,15 @@ export function useProductDetailPage() { stock: null, }); - const form = reactive({ ...DEFAULT_FORM }); + const form = reactive({ + ...DEFAULT_PRODUCT_DETAIL_FORM, + imageUrls: [], + specTemplateIds: [], + addonGroupIds: [], + labelIds: [], + skus: [], + comboGroups: [], + }); const storeId = computed(() => String(route.query.storeId || '')); const productId = computed(() => String(route.query.productId || '')); @@ -112,632 +77,38 @@ export function useProductDetailPage() { .filter(Boolean), ); - const comboPickerSelectedProducts = computed(() => - comboPickerProducts.value.filter((item) => - comboPickerSelectedIds.value.includes(item.id), - ), - ); + const skuActions = createProductDetailSkuActions({ + form, + skuBatch, + specTemplateOptions, + }); - function resetForm() { - Object.assign(form, { - ...DEFAULT_FORM, - imageUrls: [], - specTemplateIds: [], - addonGroupIds: [], - labelIds: [], - skus: [], - comboGroups: [], - }); - } + const comboActions = createProductDetailComboActions({ + comboPickerCurrentGroupIndex, + comboPickerKeyword, + comboPickerLoading, + comboPickerOpen, + comboPickerProducts, + comboPickerSelectedIds, + form, + storeId, + }); - function patchForm(data: ProductDetailDto) { - form.id = data.id; - form.name = data.name; - form.subtitle = data.subtitle; - form.categoryId = data.categoryId; - form.kind = data.kind; - form.description = data.description; - 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; - if (data.status === 'on_sale') { - form.shelfMode = 'now'; - } else if (data.timedOnShelfAt) { - form.shelfMode = 'scheduled'; - } else { - form.shelfMode = 'draft'; - } - form.timedOnShelfAt = data.timedOnShelfAt || ''; - } - - 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; - } - - 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 { - const uploaded = await uploadTenantFileApi(file, 'dish_image'); - const url = String(uploaded.url || '').trim(); - if (!url) { - message.error('图片上传失败'); - return; - } - form.imageUrls = dedupeTextList([...form.imageUrls, url]).slice(0, 5); - message.success('图片上传成功'); - } catch (error) { - console.error(error); - } finally { - isUploadingImage.value = false; - } - } - - 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 next = [...form.imageUrls]; - const [picked] = next.splice(index, 1); - if (!picked) return; - form.imageUrls = [picked, ...next]; - } - - function addComboGroup() { - form.comboGroups.push({ - name: '', - minSelect: 1, - maxSelect: 1, - sortOrder: form.comboGroups.length + 1, - items: [], - }); - } - - 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; - 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; - } - if (!form.categoryId) { - message.warning('请选择商品分类'); - return; - } - if (form.shelfMode === 'scheduled' && !form.timedOnShelfAt) { - message.warning('请选择定时上架时间'); - 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({ - id: form.id, - storeId: storeId.value, - categoryId: form.categoryId, - kind: form.kind, - name: form.name.trim(), - subtitle: form.subtitle.trim(), - description: form.description.trim(), - price: Number(Number(form.price || 0).toFixed(2)), - 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))), - status: form.status, - shelfMode: form.shelfMode, - timedOnShelfAt: - 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); - } finally { - isSubmitting.value = false; - } - } - - async function toggleSaleStatus(next: ProductStatus) { - if (next !== 'on_sale' && next !== 'off_shelf') return; - form.status = next; - form.shelfMode = next === 'on_sale' ? 'now' : 'draft'; - 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({ - storeId: storeId.value, - productId: form.id, - }); - message.success('商品已删除'); - router.push('/product/list'); - } + const dataActions = createProductDetailDataActions({ + addonGroupOptions, + buildSkuRows: skuActions.buildSkuRows, + categoryOptions, + detail, + form, + isLoading, + isSubmitting, + isUploadingImage, + labelOptions, + productId, + router, + specTemplateOptions, + storeId, + }); function goBack() { router.push('/product/list'); @@ -746,16 +117,16 @@ export function useProductDetailPage() { watch( [storeId, productId], () => { - void loadDetail(); + void dataActions.loadDetail(); }, { immediate: true }, ); return { addonGroupOptions, - addComboGroup, - applySkuBatchPrice, - applySkuBatchStock, + addComboGroup: comboActions.addComboGroup, + applySkuBatchPrice: skuActions.applySkuBatchPrice, + applySkuBatchStock: skuActions.applySkuBatchStock, categoryOptions, comboPickerCurrentGroupIndex, comboPickerKeyword, @@ -763,153 +134,48 @@ export function useProductDetailPage() { comboPickerOpen, comboPickerProducts, comboPickerSelectedIds, - comboPickerSelectedProducts, - deleteCurrentProduct, + comboPickerSelectedProducts: comboActions.comboPickerSelectedProducts, + deleteCurrentProduct: dataActions.deleteCurrentProduct, detail, form, - getOptionName, - getSkuAttrOptionId, - getTemplateName, + getOptionName: skuActions.getOptionName, + getSkuAttrOptionId: skuActions.getSkuAttrOptionId, + getTemplateName: skuActions.getTemplateName, goBack, isLoading, isSubmitting, isUploadingImage, labelOptions, - loadDetail, - openComboPicker, - removeComboGroup, - removeComboItem, - removeImage, - saveDetail, - searchComboPicker, - setComboGroupMaxSelect, - setComboGroupMinSelect, - setComboGroupName, - setComboItemQuantity, - setComboPickerOpen, - setPrimaryImage, - setShelfMode, - setSkuEnabled, - setSkuOriginalPrice, - setSkuPrice, - setSkuStock, + loadDetail: dataActions.loadDetail, + openComboPicker: comboActions.openComboPicker, + removeComboGroup: comboActions.removeComboGroup, + removeComboItem: comboActions.removeComboItem, + removeImage: dataActions.removeImage, + saveDetail: dataActions.saveDetail, + searchComboPicker: comboActions.searchComboPicker, + setComboGroupMaxSelect: comboActions.setComboGroupMaxSelect, + setComboGroupMinSelect: comboActions.setComboGroupMinSelect, + setComboGroupName: comboActions.setComboGroupName, + setComboItemQuantity: comboActions.setComboItemQuantity, + setComboPickerOpen: comboActions.setComboPickerOpen, + setPrimaryImage: dataActions.setPrimaryImage, + setShelfMode: dataActions.setShelfMode, + setSkuEnabled: skuActions.setSkuEnabled, + setSkuOriginalPrice: skuActions.setSkuOriginalPrice, + setSkuPrice: skuActions.setSkuPrice, + setSkuStock: skuActions.setSkuStock, skuBatch, skuTemplateColumns, specTemplateOptions, statusColor, statusText, storeId, - submitComboPicker, - toggleAddonGroup, - toggleComboPickerProduct, - toggleLabel, - toggleSaleStatus, - toggleSpecTemplate, - uploadImage, + submitComboPicker: comboActions.submitComboPicker, + toggleAddonGroup: dataActions.toggleAddonGroup, + toggleComboPickerProduct: comboActions.toggleComboPickerProduct, + toggleLabel: dataActions.toggleLabel, + toggleSaleStatus: dataActions.toggleSaleStatus, + toggleSpecTemplate: skuActions.toggleSpecTemplate, + uploadImage: dataActions.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/types.ts b/apps/web-antd/src/views/product/detail/types.ts index 90b9a7b..60d0356 100644 --- a/apps/web-antd/src/views/product/detail/types.ts +++ b/apps/web-antd/src/views/product/detail/types.ts @@ -9,6 +9,11 @@ export interface ProductDetailSectionItem { title: string; } +export interface ProductDetailCategoryOption { + label: string; + value: string; +} + export interface ProductDetailPillOption { count?: number; id: string;