fix(project): split product detail module and restore detail route
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
ComponentRecordType,
|
ComponentRecordType,
|
||||||
GenerateMenuAndRoutesOptions,
|
GenerateMenuAndRoutesOptions,
|
||||||
|
RouteRecordStringComponent,
|
||||||
} from '@vben/types';
|
} from '@vben/types';
|
||||||
|
|
||||||
import { generateAccessible } from '@vben/access';
|
import { generateAccessible } from '@vben/access';
|
||||||
@@ -14,6 +15,11 @@ import { $t } from '#/locales';
|
|||||||
|
|
||||||
const forbiddenComponent = () => import('#/views/_core/fallback/forbidden.vue');
|
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) {
|
async function generateAccess(options: GenerateMenuAndRoutesOptions) {
|
||||||
const pageMap: ComponentRecordType = import.meta.glob('../views/**/*.vue');
|
const pageMap: ComponentRecordType = import.meta.glob('../views/**/*.vue');
|
||||||
|
|
||||||
@@ -29,7 +35,8 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) {
|
|||||||
content: `${$t('common.loadingMenu')}...`,
|
content: `${$t('common.loadingMenu')}...`,
|
||||||
duration: 1.5,
|
duration: 1.5,
|
||||||
});
|
});
|
||||||
return await getAllMenusApi();
|
const menuList = await getAllMenusApi();
|
||||||
|
return ensureProductDetailRoute(menuList);
|
||||||
},
|
},
|
||||||
// 可以指定没有权限跳转403页面
|
// 可以指定没有权限跳转403页面
|
||||||
forbiddenComponent,
|
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 };
|
export { generateAccess };
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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: '',
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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')}`;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
ProductDetailComboGroupItemState,
|
ProductDetailCategoryOption,
|
||||||
ProductDetailComboGroupState,
|
|
||||||
ProductDetailFormState,
|
ProductDetailFormState,
|
||||||
ProductDetailSkuAttrState,
|
|
||||||
ProductDetailSkuBatchState,
|
ProductDetailSkuBatchState,
|
||||||
ProductDetailSkuRowState,
|
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -12,56 +9,16 @@ import type {
|
|||||||
ProductDetailDto,
|
ProductDetailDto,
|
||||||
ProductLabelDto,
|
ProductLabelDto,
|
||||||
ProductPickerItemDto,
|
ProductPickerItemDto,
|
||||||
ProductSkuDto,
|
|
||||||
ProductSpecDto,
|
ProductSpecDto,
|
||||||
ProductStatus,
|
|
||||||
} from '#/api/product';
|
} from '#/api/product';
|
||||||
|
|
||||||
import { computed, reactive, ref, watch } from 'vue';
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { message } from 'ant-design-vue';
|
import { DEFAULT_PRODUCT_DETAIL_FORM } from './product-detail-page/constants';
|
||||||
|
import { createProductDetailComboActions } from './product-detail-page/combo-actions';
|
||||||
import { uploadTenantFileApi } from '#/api/files';
|
import { createProductDetailDataActions } from './product-detail-page/data-actions';
|
||||||
import {
|
import { createProductDetailSkuActions } from './product-detail-page/sku-actions';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useProductDetailPage() {
|
export function useProductDetailPage() {
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -72,7 +29,7 @@ export function useProductDetailPage() {
|
|||||||
const isUploadingImage = ref(false);
|
const isUploadingImage = ref(false);
|
||||||
const detail = ref<null | ProductDetailDto>(null);
|
const detail = ref<null | ProductDetailDto>(null);
|
||||||
|
|
||||||
const categoryOptions = ref<CategoryOption[]>([]);
|
const categoryOptions = ref<ProductDetailCategoryOption[]>([]);
|
||||||
const specTemplateOptions = ref<ProductSpecDto[]>([]);
|
const specTemplateOptions = ref<ProductSpecDto[]>([]);
|
||||||
const addonGroupOptions = ref<ProductAddonGroupDto[]>([]);
|
const addonGroupOptions = ref<ProductAddonGroupDto[]>([]);
|
||||||
const labelOptions = ref<ProductLabelDto[]>([]);
|
const labelOptions = ref<ProductLabelDto[]>([]);
|
||||||
@@ -89,7 +46,15 @@ export function useProductDetailPage() {
|
|||||||
stock: null,
|
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 storeId = computed(() => String(route.query.storeId || ''));
|
||||||
const productId = computed(() => String(route.query.productId || ''));
|
const productId = computed(() => String(route.query.productId || ''));
|
||||||
@@ -112,632 +77,38 @@ export function useProductDetailPage() {
|
|||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
);
|
);
|
||||||
|
|
||||||
const comboPickerSelectedProducts = computed(() =>
|
const skuActions = createProductDetailSkuActions({
|
||||||
comboPickerProducts.value.filter((item) =>
|
form,
|
||||||
comboPickerSelectedIds.value.includes(item.id),
|
skuBatch,
|
||||||
),
|
specTemplateOptions,
|
||||||
);
|
|
||||||
|
|
||||||
function resetForm() {
|
|
||||||
Object.assign(form, {
|
|
||||||
...DEFAULT_FORM,
|
|
||||||
imageUrls: [],
|
|
||||||
specTemplateIds: [],
|
|
||||||
addonGroupIds: [],
|
|
||||||
labelIds: [],
|
|
||||||
skus: [],
|
|
||||||
comboGroups: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchForm(data: ProductDetailDto) {
|
|
||||||
form.id = data.id;
|
|
||||||
form.name = data.name;
|
|
||||||
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);
|
const comboActions = createProductDetailComboActions({
|
||||||
}
|
comboPickerCurrentGroupIndex,
|
||||||
|
comboPickerKeyword,
|
||||||
async function loadDetail() {
|
comboPickerLoading,
|
||||||
if (!storeId.value || !productId.value) {
|
comboPickerOpen,
|
||||||
detail.value = null;
|
comboPickerProducts,
|
||||||
resetForm();
|
comboPickerSelectedIds,
|
||||||
categoryOptions.value = [];
|
form,
|
||||||
specTemplateOptions.value = [];
|
storeId,
|
||||||
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;
|
const dataActions = createProductDetailDataActions({
|
||||||
patchForm(saved);
|
addonGroupOptions,
|
||||||
buildSkuRows();
|
buildSkuRows: skuActions.buildSkuRows,
|
||||||
message.success('商品详情已保存');
|
categoryOptions,
|
||||||
} catch (error) {
|
detail,
|
||||||
console.error(error);
|
form,
|
||||||
} finally {
|
isLoading,
|
||||||
isSubmitting.value = false;
|
isSubmitting,
|
||||||
}
|
isUploadingImage,
|
||||||
}
|
labelOptions,
|
||||||
|
productId,
|
||||||
async function toggleSaleStatus(next: ProductStatus) {
|
router,
|
||||||
if (next !== 'on_sale' && next !== 'off_shelf') return;
|
specTemplateOptions,
|
||||||
form.status = next;
|
storeId,
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
router.push('/product/list');
|
router.push('/product/list');
|
||||||
@@ -746,16 +117,16 @@ export function useProductDetailPage() {
|
|||||||
watch(
|
watch(
|
||||||
[storeId, productId],
|
[storeId, productId],
|
||||||
() => {
|
() => {
|
||||||
void loadDetail();
|
void dataActions.loadDetail();
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
addonGroupOptions,
|
addonGroupOptions,
|
||||||
addComboGroup,
|
addComboGroup: comboActions.addComboGroup,
|
||||||
applySkuBatchPrice,
|
applySkuBatchPrice: skuActions.applySkuBatchPrice,
|
||||||
applySkuBatchStock,
|
applySkuBatchStock: skuActions.applySkuBatchStock,
|
||||||
categoryOptions,
|
categoryOptions,
|
||||||
comboPickerCurrentGroupIndex,
|
comboPickerCurrentGroupIndex,
|
||||||
comboPickerKeyword,
|
comboPickerKeyword,
|
||||||
@@ -763,153 +134,48 @@ export function useProductDetailPage() {
|
|||||||
comboPickerOpen,
|
comboPickerOpen,
|
||||||
comboPickerProducts,
|
comboPickerProducts,
|
||||||
comboPickerSelectedIds,
|
comboPickerSelectedIds,
|
||||||
comboPickerSelectedProducts,
|
comboPickerSelectedProducts: comboActions.comboPickerSelectedProducts,
|
||||||
deleteCurrentProduct,
|
deleteCurrentProduct: dataActions.deleteCurrentProduct,
|
||||||
detail,
|
detail,
|
||||||
form,
|
form,
|
||||||
getOptionName,
|
getOptionName: skuActions.getOptionName,
|
||||||
getSkuAttrOptionId,
|
getSkuAttrOptionId: skuActions.getSkuAttrOptionId,
|
||||||
getTemplateName,
|
getTemplateName: skuActions.getTemplateName,
|
||||||
goBack,
|
goBack,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
isUploadingImage,
|
isUploadingImage,
|
||||||
labelOptions,
|
labelOptions,
|
||||||
loadDetail,
|
loadDetail: dataActions.loadDetail,
|
||||||
openComboPicker,
|
openComboPicker: comboActions.openComboPicker,
|
||||||
removeComboGroup,
|
removeComboGroup: comboActions.removeComboGroup,
|
||||||
removeComboItem,
|
removeComboItem: comboActions.removeComboItem,
|
||||||
removeImage,
|
removeImage: dataActions.removeImage,
|
||||||
saveDetail,
|
saveDetail: dataActions.saveDetail,
|
||||||
searchComboPicker,
|
searchComboPicker: comboActions.searchComboPicker,
|
||||||
setComboGroupMaxSelect,
|
setComboGroupMaxSelect: comboActions.setComboGroupMaxSelect,
|
||||||
setComboGroupMinSelect,
|
setComboGroupMinSelect: comboActions.setComboGroupMinSelect,
|
||||||
setComboGroupName,
|
setComboGroupName: comboActions.setComboGroupName,
|
||||||
setComboItemQuantity,
|
setComboItemQuantity: comboActions.setComboItemQuantity,
|
||||||
setComboPickerOpen,
|
setComboPickerOpen: comboActions.setComboPickerOpen,
|
||||||
setPrimaryImage,
|
setPrimaryImage: dataActions.setPrimaryImage,
|
||||||
setShelfMode,
|
setShelfMode: dataActions.setShelfMode,
|
||||||
setSkuEnabled,
|
setSkuEnabled: skuActions.setSkuEnabled,
|
||||||
setSkuOriginalPrice,
|
setSkuOriginalPrice: skuActions.setSkuOriginalPrice,
|
||||||
setSkuPrice,
|
setSkuPrice: skuActions.setSkuPrice,
|
||||||
setSkuStock,
|
setSkuStock: skuActions.setSkuStock,
|
||||||
skuBatch,
|
skuBatch,
|
||||||
skuTemplateColumns,
|
skuTemplateColumns,
|
||||||
specTemplateOptions,
|
specTemplateOptions,
|
||||||
statusColor,
|
statusColor,
|
||||||
statusText,
|
statusText,
|
||||||
storeId,
|
storeId,
|
||||||
submitComboPicker,
|
submitComboPicker: comboActions.submitComboPicker,
|
||||||
toggleAddonGroup,
|
toggleAddonGroup: dataActions.toggleAddonGroup,
|
||||||
toggleComboPickerProduct,
|
toggleComboPickerProduct: comboActions.toggleComboPickerProduct,
|
||||||
toggleLabel,
|
toggleLabel: dataActions.toggleLabel,
|
||||||
toggleSaleStatus,
|
toggleSaleStatus: dataActions.toggleSaleStatus,
|
||||||
toggleSpecTemplate,
|
toggleSpecTemplate: skuActions.toggleSpecTemplate,
|
||||||
uploadImage,
|
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')}`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ export interface ProductDetailSectionItem {
|
|||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProductDetailCategoryOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProductDetailPillOption {
|
export interface ProductDetailPillOption {
|
||||||
count?: number;
|
count?: number;
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user