From a02369197c0cffa2e9c05e1c8e472dbb228f6173 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Wed, 25 Feb 2026 09:25:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=95=86=E5=93=81=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E8=B6=85=E8=BF=8710=E4=B8=AASKU=E6=94=B9=E4=B8=BA=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web-antd/src/api/product/index.ts | 68 +++++++ .../product-detail-page/data-actions.ts | 182 +++++++++++++----- .../product-detail-page/helpers.ts | 4 - .../product-detail-page/sku-actions.ts | 10 +- 4 files changed, 203 insertions(+), 61 deletions(-) diff --git a/apps/web-antd/src/api/product/index.ts b/apps/web-antd/src/api/product/index.ts index 498fd42..763fc40 100644 --- a/apps/web-antd/src/api/product/index.ts +++ b/apps/web-antd/src/api/product/index.ts @@ -16,6 +16,14 @@ export type ProductKind = 'combo' | 'single'; /** 沽清模式。 */ export type ProductSoldoutMode = 'permanent' | 'timed' | 'today'; +/** SKU 异步保存任务状态。 */ +export type ProductSkuSaveJobStatus = + | 'canceled' + | 'failed' + | 'queued' + | 'running' + | 'succeeded'; + /** 分类展示渠道。 */ export type ProductCategoryChannel = 'dine_in' | 'pickup' | 'wm'; @@ -262,6 +270,45 @@ export interface SaveProductDto { 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 { productId: string; @@ -680,6 +727,27 @@ export async function saveProductApi(data: SaveProductDto) { return requestClient.post('/product/save', data); } +/** 创建 SKU 异步保存任务。 */ +export async function createProductSkuSaveJobApi( + data: CreateProductSkuSaveJobDto, +) { + return requestClient.post( + '/product/sku-save-jobs', + data, + ); +} + +/** 查询 SKU 异步保存任务。 */ +export async function getProductSkuSaveJobApi(params: ProductSkuSaveJobQuery) { + const { jobId, ...query } = params; + return requestClient.get( + `/product/sku-save-jobs/${jobId}`, + { + params: query, + }, + ); +} + /** 删除商品。 */ export async function deleteProductApi(data: DeleteProductDto) { return requestClient.post('/product/delete', data); diff --git a/apps/web-antd/src/views/product/detail/composables/product-detail-page/data-actions.ts b/apps/web-antd/src/views/product/detail/composables/product-detail-page/data-actions.ts index 865870b..562da34 100644 --- a/apps/web-antd/src/views/product/detail/composables/product-detail-page/data-actions.ts +++ b/apps/web-antd/src/views/product/detail/composables/product-detail-page/data-actions.ts @@ -18,18 +18,19 @@ import { message } from 'ant-design-vue'; import { uploadTenantFileApi } from '#/api/files'; import { + createProductSkuSaveJobApi, deleteProductApi, getProductAddonGroupListApi, getProductCategoryListApi, getProductDetailApi, getProductLabelListApi, + getProductSkuSaveJobApi, getProductSpecListApi, saveProductApi, } from '#/api/product'; import { DEFAULT_PRODUCT_DETAIL_FORM } from './constants'; import { - buildLocalSkuCode, dedupeTextList, normalizeComboGroups, normalizeSkuRows, @@ -42,6 +43,9 @@ const ALLOWED_PRODUCT_IMAGE_MIME_SET = new Set([ 'image/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 { addonGroupOptions: Ref; @@ -307,6 +311,55 @@ export function createProductDetailDataActions( } } + function sleep(ms: number) { + return new Promise((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() { if (!storeId.value || !form.id) return; if (!form.name.trim()) { @@ -353,6 +406,46 @@ export function createProductDetailDataActions( isSubmitting.value = true; 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({ id: form.id, storeId: storeId.value, @@ -387,59 +480,48 @@ export function createProductDetailDataActions( 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)), - ), - })), - })) - : [], + skus: shouldSaveSkuAsync ? undefined : normalizedSkus, + comboGroups: normalizedComboGroups, tags: [], }); - detail.value = saved; - patchForm(saved); + let latestDetail = 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(); - message.success('商品详情已保存'); } catch (error) { console.error(error); } finally { diff --git a/apps/web-antd/src/views/product/detail/composables/product-detail-page/helpers.ts b/apps/web-antd/src/views/product/detail/composables/product-detail-page/helpers.ts index d49e606..aeddd7d 100644 --- a/apps/web-antd/src/views/product/detail/composables/product-detail-page/helpers.ts +++ b/apps/web-antd/src/views/product/detail/composables/product-detail-page/helpers.ts @@ -111,7 +111,3 @@ export function normalizeComboGroups( }), ); } - -export function buildLocalSkuCode(index: number) { - return `SKU-${String(index).padStart(2, '0')}`; -} diff --git a/apps/web-antd/src/views/product/detail/composables/product-detail-page/sku-actions.ts b/apps/web-antd/src/views/product/detail/composables/product-detail-page/sku-actions.ts index ae445ab..3315c35 100644 --- a/apps/web-antd/src/views/product/detail/composables/product-detail-page/sku-actions.ts +++ b/apps/web-antd/src/views/product/detail/composables/product-detail-page/sku-actions.ts @@ -8,11 +8,7 @@ import type { import type { ProductSpecDto } from '#/api/product'; -import { - buildLocalSkuCode, - buildSkuCombinations, - buildSkuKey, -} from './helpers'; +import { buildSkuCombinations, buildSkuKey } from './helpers'; interface CreateProductDetailSkuActionsOptions { form: ProductDetailFormState; @@ -114,7 +110,7 @@ export function createProductDetailSkuActions( form.skus = [ { id: fallback?.id || '', - skuCode: fallback?.skuCode || buildLocalSkuCode(1), + skuCode: fallback?.skuCode || '', price: fallback?.price ?? Number(form.price || 0), originalPrice: fallback?.originalPrice ?? @@ -149,7 +145,7 @@ export function createProductDetailSkuActions( : null; return { id: cached?.id || '', - skuCode: cached?.skuCode || buildLocalSkuCode(skuIndex), + skuCode: cached?.skuCode || '', price: cached?.price ?? defaultPrice, originalPrice: cached?.originalPrice ?? defaultOriginalPrice, stock: