feat: 商品详情超过10个SKU改为异步保存
All checks were successful
Build and Deploy TenantUI / build-and-deploy (push) Successful in 52s
All checks were successful
Build and Deploy TenantUI / build-and-deploy (push) Successful in 52s
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -111,7 +111,3 @@ export function normalizeComboGroups(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildLocalSkuCode(index: number) {
|
|
||||||
return `SKU-${String(index).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user