feat: 商品详情超过10个SKU改为异步保存
All checks were successful
Build and Deploy TenantUI / build-and-deploy (push) Successful in 52s

This commit is contained in:
2026-02-25 09:25:32 +08:00
parent 2b485de5a8
commit a02369197c
4 changed files with 203 additions and 61 deletions

View File

@@ -16,6 +16,14 @@ export type ProductKind = 'combo' | 'single';
/** 沽清模式。 */ /** 沽清模式。 */
export type ProductSoldoutMode = 'permanent' | 'timed' | 'today'; export type ProductSoldoutMode = 'permanent' | 'timed' | 'today';
/** SKU 异步保存任务状态。 */
export type ProductSkuSaveJobStatus =
| 'canceled'
| 'failed'
| 'queued'
| 'running'
| 'succeeded';
/** 分类展示渠道。 */ /** 分类展示渠道。 */
export type ProductCategoryChannel = 'dine_in' | 'pickup' | 'wm'; export type ProductCategoryChannel = 'dine_in' | 'pickup' | 'wm';
@@ -262,6 +270,45 @@ export interface SaveProductDto {
warningStock?: null | number; warningStock?: null | number;
} }
/** 创建 SKU 异步保存任务参数。 */
export interface CreateProductSkuSaveJobDto {
productId: string;
skus: Array<{
attributes: Array<{
optionId: string;
templateId: string;
}>;
isEnabled: boolean;
originalPrice: null | number;
price: number;
skuCode?: string;
sortOrder: number;
stock: number;
}>;
specTemplateIds?: string[];
storeId: string;
}
/** 查询 SKU 异步保存任务参数。 */
export interface ProductSkuSaveJobQuery {
jobId: string;
storeId: string;
}
/** SKU 异步保存任务结果。 */
export interface ProductSkuSaveJobDto {
errorMessage: null | string;
failedCount: number;
finishedAt: null | string;
jobId: string;
productId: string;
progressProcessed: number;
progressTotal: number;
startedAt: null | string;
status: ProductSkuSaveJobStatus;
storeId: string;
}
/** 删除商品参数。 */ /** 删除商品参数。 */
export interface DeleteProductDto { export interface DeleteProductDto {
productId: string; productId: string;
@@ -680,6 +727,27 @@ export async function saveProductApi(data: SaveProductDto) {
return requestClient.post<ProductDetailDto>('/product/save', data); return requestClient.post<ProductDetailDto>('/product/save', data);
} }
/** 创建 SKU 异步保存任务。 */
export async function createProductSkuSaveJobApi(
data: CreateProductSkuSaveJobDto,
) {
return requestClient.post<ProductSkuSaveJobDto>(
'/product/sku-save-jobs',
data,
);
}
/** 查询 SKU 异步保存任务。 */
export async function getProductSkuSaveJobApi(params: ProductSkuSaveJobQuery) {
const { jobId, ...query } = params;
return requestClient.get<ProductSkuSaveJobDto>(
`/product/sku-save-jobs/${jobId}`,
{
params: query,
},
);
}
/** 删除商品。 */ /** 删除商品。 */
export async function deleteProductApi(data: DeleteProductDto) { export async function deleteProductApi(data: DeleteProductDto) {
return requestClient.post('/product/delete', data); return requestClient.post('/product/delete', data);

View File

@@ -18,18 +18,19 @@ import { message } from 'ant-design-vue';
import { uploadTenantFileApi } from '#/api/files'; import { uploadTenantFileApi } from '#/api/files';
import { import {
createProductSkuSaveJobApi,
deleteProductApi, deleteProductApi,
getProductAddonGroupListApi, getProductAddonGroupListApi,
getProductCategoryListApi, getProductCategoryListApi,
getProductDetailApi, getProductDetailApi,
getProductLabelListApi, getProductLabelListApi,
getProductSkuSaveJobApi,
getProductSpecListApi, getProductSpecListApi,
saveProductApi, saveProductApi,
} from '#/api/product'; } from '#/api/product';
import { DEFAULT_PRODUCT_DETAIL_FORM } from './constants'; import { DEFAULT_PRODUCT_DETAIL_FORM } from './constants';
import { import {
buildLocalSkuCode,
dedupeTextList, dedupeTextList,
normalizeComboGroups, normalizeComboGroups,
normalizeSkuRows, normalizeSkuRows,
@@ -42,6 +43,9 @@ const ALLOWED_PRODUCT_IMAGE_MIME_SET = new Set([
'image/webp', 'image/webp',
]); ]);
const ALLOWED_PRODUCT_IMAGE_EXT = ['.jpg', '.jpeg', '.png', '.webp']; const ALLOWED_PRODUCT_IMAGE_EXT = ['.jpg', '.jpeg', '.png', '.webp'];
const ASYNC_SKU_THRESHOLD = 10;
const ASYNC_SKU_POLL_INTERVAL_MS = 1000;
const ASYNC_SKU_POLL_MAX_ATTEMPTS = 180;
interface CreateProductDetailDataActionsOptions { interface CreateProductDetailDataActionsOptions {
addonGroupOptions: Ref<ProductAddonGroupDto[]>; addonGroupOptions: Ref<ProductAddonGroupDto[]>;
@@ -307,6 +311,55 @@ export function createProductDetailDataActions(
} }
} }
function sleep(ms: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}
async function waitForSkuSaveJob(storeIdValue: string, jobId: string) {
let lastStatus = 'queued';
let lastErrorMessage: null | string = null;
for (let i = 0; i < ASYNC_SKU_POLL_MAX_ATTEMPTS; i += 1) {
try {
const status = await getProductSkuSaveJobApi({
storeId: storeIdValue,
jobId,
});
lastStatus = status.status;
lastErrorMessage = status.errorMessage;
} catch (error) {
console.error(error);
return {
errorMessage: lastErrorMessage,
status: lastStatus,
timedOut: true,
};
}
if (
lastStatus === 'succeeded' ||
lastStatus === 'failed' ||
lastStatus === 'canceled'
) {
return {
errorMessage: lastErrorMessage,
status: lastStatus,
timedOut: false,
};
}
await sleep(ASYNC_SKU_POLL_INTERVAL_MS);
}
return {
errorMessage: lastErrorMessage,
status: lastStatus,
timedOut: true,
};
}
async function saveDetail() { async function saveDetail() {
if (!storeId.value || !form.id) return; if (!storeId.value || !form.id) return;
if (!form.name.trim()) { if (!form.name.trim()) {
@@ -353,6 +406,46 @@ export function createProductDetailDataActions(
isSubmitting.value = true; isSubmitting.value = true;
try { try {
const normalizedSkus = form.skus.map((item, index) => ({
skuCode: item.skuCode || undefined,
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,
})),
}));
const normalizedComboGroups =
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)),
),
})),
}))
: [];
const shouldSaveSkuAsync = normalizedSkus.length > ASYNC_SKU_THRESHOLD;
const saved = await saveProductApi({ const saved = await saveProductApi({
id: form.id, id: form.id,
storeId: storeId.value, storeId: storeId.value,
@@ -387,59 +480,48 @@ export function createProductDetailDataActions(
specTemplateIds: [...form.specTemplateIds], specTemplateIds: [...form.specTemplateIds],
addonGroupIds: [...form.addonGroupIds], addonGroupIds: [...form.addonGroupIds],
labelIds: [...form.labelIds], labelIds: [...form.labelIds],
skus: form.skus.map((item, index) => ({ skus: shouldSaveSkuAsync ? undefined : normalizedSkus,
skuCode: item.skuCode || buildLocalSkuCode(index + 1), comboGroups: normalizedComboGroups,
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: [], tags: [],
}); });
detail.value = saved; let latestDetail = saved;
patchForm(saved); if (shouldSaveSkuAsync) {
const createdJob = await createProductSkuSaveJobApi({
storeId: storeId.value,
productId: saved.id,
specTemplateIds: [...form.specTemplateIds],
skus: normalizedSkus,
});
const jobResult = await waitForSkuSaveJob(
storeId.value,
createdJob.jobId,
);
if (jobResult.status === 'succeeded') {
latestDetail = await getProductDetailApi({
storeId: storeId.value,
productId: saved.id,
});
message.success('商品详情已保存SKU 已异步更新');
} else if (
jobResult.status === 'failed' ||
jobResult.status === 'canceled'
) {
message.error(
jobResult.errorMessage || '商品基础信息已保存,但 SKU 异步保存失败',
);
} else if (jobResult.timedOut) {
message.warning('商品基础信息已保存SKU 正在后台处理');
} else {
message.warning('商品基础信息已保存SKU 保存状态请稍后刷新查看');
}
} else {
message.success('商品详情已保存');
}
detail.value = latestDetail;
patchForm(latestDetail);
buildSkuRows(); buildSkuRows();
message.success('商品详情已保存');
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} finally { } finally {

View File

@@ -111,7 +111,3 @@ export function normalizeComboGroups(
}), }),
); );
} }
export function buildLocalSkuCode(index: number) {
return `SKU-${String(index).padStart(2, '0')}`;
}

View File

@@ -8,11 +8,7 @@ import type {
import type { ProductSpecDto } from '#/api/product'; import type { ProductSpecDto } from '#/api/product';
import { import { buildSkuCombinations, buildSkuKey } from './helpers';
buildLocalSkuCode,
buildSkuCombinations,
buildSkuKey,
} from './helpers';
interface CreateProductDetailSkuActionsOptions { interface CreateProductDetailSkuActionsOptions {
form: ProductDetailFormState; form: ProductDetailFormState;
@@ -114,7 +110,7 @@ export function createProductDetailSkuActions(
form.skus = [ form.skus = [
{ {
id: fallback?.id || '', id: fallback?.id || '',
skuCode: fallback?.skuCode || buildLocalSkuCode(1), skuCode: fallback?.skuCode || '',
price: fallback?.price ?? Number(form.price || 0), price: fallback?.price ?? Number(form.price || 0),
originalPrice: originalPrice:
fallback?.originalPrice ?? fallback?.originalPrice ??
@@ -149,7 +145,7 @@ export function createProductDetailSkuActions(
: null; : null;
return { return {
id: cached?.id || '', id: cached?.id || '',
skuCode: cached?.skuCode || buildLocalSkuCode(skuIndex), skuCode: cached?.skuCode || '',
price: cached?.price ?? defaultPrice, price: cached?.price ?? defaultPrice,
originalPrice: cached?.originalPrice ?? defaultOriginalPrice, originalPrice: cached?.originalPrice ?? defaultOriginalPrice,
stock: stock: