fix(project): split product detail module and restore detail route

This commit is contained in:
2026-02-22 10:12:38 +08:00
parent 99a947cad4
commit d6e5138e53
8 changed files with 1100 additions and 814 deletions

View File

@@ -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 };

View File

@@ -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<number>;
comboPickerKeyword: Ref<string>;
comboPickerLoading: Ref<boolean>;
comboPickerOpen: Ref<boolean>;
comboPickerProducts: Ref<ProductPickerItemDto[]>;
comboPickerSelectedIds: Ref<string[]>;
form: ProductDetailFormState;
storeId: Ref<string>;
}
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,
};
}

View File

@@ -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: '',
};

View File

@@ -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<ProductAddonGroupDto[]>;
buildSkuRows: () => void;
categoryOptions: Ref<ProductDetailCategoryOption[]>;
detail: Ref<null | ProductDetailDto>;
form: ProductDetailFormState;
isLoading: Ref<boolean>;
isSubmitting: Ref<boolean>;
isUploadingImage: Ref<boolean>;
labelOptions: Ref<ProductLabelDto[]>;
productId: Ref<string>;
router: Router;
specTemplateOptions: Ref<ProductSpecDto[]>;
storeId: Ref<string>;
}
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,
};
}

View File

@@ -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')}`;
}

View File

@@ -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<ProductSpecDto[]>;
}
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,
};
}

View File

@@ -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 | ProductDetailDto>(null);
const categoryOptions = ref<CategoryOption[]>([]);
const categoryOptions = ref<ProductDetailCategoryOption[]>([]);
const specTemplateOptions = ref<ProductSpecDto[]>([]);
const addonGroupOptions = ref<ProductAddonGroupDto[]>([]);
const labelOptions = ref<ProductLabelDto[]>([]);
@@ -89,7 +46,15 @@ export function useProductDetailPage() {
stock: null,
});
const form = reactive<ProductDetailFormState>({ ...DEFAULT_FORM });
const form = reactive<ProductDetailFormState>({
...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')}`;
}

View File

@@ -9,6 +9,11 @@ export interface ProductDetailSectionItem {
title: string;
}
export interface ProductDetailCategoryOption {
label: string;
value: string;
}
export interface ProductDetailPillOption {
count?: number;
id: string;