From 9e869e678893418153d586e71a242129157fbda1 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Thu, 26 Feb 2026 12:09:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=E5=95=86=E5=93=81?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=B7=A5=E5=85=B7=E9=A1=B5=E9=9D=A2=E5=B9=B6?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E7=9C=9F=E5=AE=9E=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web-antd/src/api/product/index.ts | 101 ++- .../components/BatchImportExportPanel.vue | 127 ++++ .../components/BatchMoveCategoryPanel.vue | 77 ++ .../components/BatchPriceAdjustPanel.vue | 216 ++++++ .../batch/components/BatchSaleSwitchPanel.vue | 129 ++++ .../batch/components/BatchStoreSyncPanel.vue | 122 ++++ .../batch/components/BatchToolCards.vue | 37 + .../product/batch/components/BatchToolbar.vue | 35 + .../batch/composables/batch-page/constants.ts | 65 ++ .../composables/batch-page/data-actions.ts | 104 +++ .../composables/batch-page/tool-actions.ts | 404 +++++++++++ .../batch/composables/useProductBatchPage.ts | 259 +++++++ .../src/views/product/batch/index.vue | 683 ++++++------------ .../src/views/product/batch/styles/base.less | 5 + .../src/views/product/batch/styles/card.less | 73 ++ .../src/views/product/batch/styles/index.less | 5 + .../views/product/batch/styles/layout.less | 51 ++ .../src/views/product/batch/styles/panel.less | 187 +++++ .../product/batch/styles/responsive.less | 25 + .../web-antd/src/views/product/batch/types.ts | 89 +++ 20 files changed, 2302 insertions(+), 492 deletions(-) create mode 100644 apps/web-antd/src/views/product/batch/components/BatchImportExportPanel.vue create mode 100644 apps/web-antd/src/views/product/batch/components/BatchMoveCategoryPanel.vue create mode 100644 apps/web-antd/src/views/product/batch/components/BatchPriceAdjustPanel.vue create mode 100644 apps/web-antd/src/views/product/batch/components/BatchSaleSwitchPanel.vue create mode 100644 apps/web-antd/src/views/product/batch/components/BatchStoreSyncPanel.vue create mode 100644 apps/web-antd/src/views/product/batch/components/BatchToolCards.vue create mode 100644 apps/web-antd/src/views/product/batch/components/BatchToolbar.vue create mode 100644 apps/web-antd/src/views/product/batch/composables/batch-page/constants.ts create mode 100644 apps/web-antd/src/views/product/batch/composables/batch-page/data-actions.ts create mode 100644 apps/web-antd/src/views/product/batch/composables/batch-page/tool-actions.ts create mode 100644 apps/web-antd/src/views/product/batch/composables/useProductBatchPage.ts create mode 100644 apps/web-antd/src/views/product/batch/styles/base.less create mode 100644 apps/web-antd/src/views/product/batch/styles/card.less create mode 100644 apps/web-antd/src/views/product/batch/styles/index.less create mode 100644 apps/web-antd/src/views/product/batch/styles/layout.less create mode 100644 apps/web-antd/src/views/product/batch/styles/panel.less create mode 100644 apps/web-antd/src/views/product/batch/styles/responsive.less create mode 100644 apps/web-antd/src/views/product/batch/types.ts diff --git a/apps/web-antd/src/api/product/index.ts b/apps/web-antd/src/api/product/index.ts index 4bc0bc5..1f060fe 100644 --- a/apps/web-antd/src/api/product/index.ts +++ b/apps/web-antd/src/api/product/index.ts @@ -611,21 +611,48 @@ export interface ChangeProductScheduleStatusDto { /** 批量范围。 */ export interface ProductBatchScopeDto { categoryId?: string; + categoryIds?: string[]; productIds?: string[]; - type: 'all' | 'category' | 'selected'; + type: 'all' | 'category' | 'manual' | 'selected'; +} + +/** 批量调价预览参数。 */ +export interface ProductBatchPriceAdjustPreviewDto { + amount: number; + amountType: 'fixed' | 'percent'; + direction: 'down' | 'up'; + scope: ProductBatchScopeDto; + storeId: string; } /** 批量调价参数。 */ -export interface ProductBatchAdjustPriceDto { +export interface ProductBatchPriceAdjustDto { amount: number; - mode: 'decrease' | 'increase' | 'set'; + amountType: 'fixed' | 'percent'; + direction: 'down' | 'up'; scope: ProductBatchScopeDto; storeId: string; } +/** 调价预览项。 */ +export interface ProductBatchPricePreviewItemDto { + deltaPrice: number; + newPrice: number; + originalPrice: number; + productId: string; + productName: string; +} + +/** 调价预览结果。 */ +export interface ProductBatchPricePreviewDto { + items: ProductBatchPricePreviewItemDto[]; + totalCount: number; +} + /** 批量移动分类参数。 */ export interface ProductBatchMoveCategoryDto { scope: ProductBatchScopeDto; + sourceCategoryId?: string; storeId: string; targetCategoryId: string; } @@ -641,32 +668,53 @@ export interface ProductBatchSaleSwitchDto { export interface ProductBatchSyncStoreDto { productIds: string[]; sourceStoreId: string; + syncPrice?: boolean; + syncStatus?: boolean; + syncStock?: boolean; targetStoreIds: string[]; } /** 批量工具通用结果。 */ export interface ProductBatchToolResultDto { failedCount: number; + skippedCount: number; successCount: number; totalCount: number; } -/** 导入导出请求参数。 */ -export interface ProductBatchImportExportDto { +/** 批量导出请求参数。 */ +export interface ProductBatchExportDto { scope: ProductBatchScopeDto; storeId: string; } -/** 导入导出回执。 */ -export interface ProductBatchImportExportResultDto { - exportedCount?: number; +/** Excel 文件回执。 */ +export interface ProductBatchExcelFileDto { failedCount: number; + fileContentBase64: string; fileName: string; - skippedCount?: number; successCount: number; totalCount: number; } +/** 批量导入参数。 */ +export interface ProductBatchImportDto { + file: File; + storeId: string; +} + +/** 批量导入错误项。 */ +export interface ProductBatchImportErrorItemDto { + message: string; + rowNo: number; +} + +/** 批量导入回执。 */ +export interface ProductBatchImportResultDto extends ProductBatchToolResultDto { + errors: ProductBatchImportErrorItemDto[]; + fileName: string; +} + /** 获取商品分类(侧栏口径)。 */ export async function getProductCategoryListApi(storeId: string) { return requestClient.get('/product/category/list', { @@ -911,9 +959,19 @@ export async function changeProductScheduleStatusApi( return requestClient.post('/product/schedule/status', data); } +/** 批量调价预览。 */ +export async function batchPreviewProductPriceApi( + data: ProductBatchPriceAdjustPreviewDto, +) { + return requestClient.post( + '/product/batch/price-adjust/preview', + data, + ); +} + /** 批量调价。 */ export async function batchAdjustProductPriceApi( - data: ProductBatchAdjustPriceDto, + data: ProductBatchPriceAdjustDto, ) { return requestClient.post( '/product/batch/price-adjust', @@ -950,16 +1008,29 @@ export async function batchSyncProductStoreApi(data: ProductBatchSyncStoreDto) { } /** 批量导入。 */ -export async function batchImportProductApi(data: ProductBatchImportExportDto) { - return requestClient.post( +export async function batchImportProductApi(data: ProductBatchImportDto) { + const formData = new FormData(); + formData.append('storeId', data.storeId); + formData.append('file', data.file); + return requestClient.post( '/product/batch/import', - data, + formData, + ); +} + +/** 下载导入模板。 */ +export async function batchDownloadProductImportTemplateApi(storeId: string) { + return requestClient.get( + '/product/batch/import/template', + { + params: { storeId }, + }, ); } /** 批量导出。 */ -export async function batchExportProductApi(data: ProductBatchImportExportDto) { - return requestClient.post( +export async function batchExportProductApi(data: ProductBatchExportDto) { + return requestClient.post( '/product/batch/export', data, ); diff --git a/apps/web-antd/src/views/product/batch/components/BatchImportExportPanel.vue b/apps/web-antd/src/views/product/batch/components/BatchImportExportPanel.vue new file mode 100644 index 0000000..42b6caf --- /dev/null +++ b/apps/web-antd/src/views/product/batch/components/BatchImportExportPanel.vue @@ -0,0 +1,127 @@ + + + diff --git a/apps/web-antd/src/views/product/batch/components/BatchMoveCategoryPanel.vue b/apps/web-antd/src/views/product/batch/components/BatchMoveCategoryPanel.vue new file mode 100644 index 0000000..397737f --- /dev/null +++ b/apps/web-antd/src/views/product/batch/components/BatchMoveCategoryPanel.vue @@ -0,0 +1,77 @@ + + + diff --git a/apps/web-antd/src/views/product/batch/components/BatchPriceAdjustPanel.vue b/apps/web-antd/src/views/product/batch/components/BatchPriceAdjustPanel.vue new file mode 100644 index 0000000..3e217be --- /dev/null +++ b/apps/web-antd/src/views/product/batch/components/BatchPriceAdjustPanel.vue @@ -0,0 +1,216 @@ + + + diff --git a/apps/web-antd/src/views/product/batch/components/BatchSaleSwitchPanel.vue b/apps/web-antd/src/views/product/batch/components/BatchSaleSwitchPanel.vue new file mode 100644 index 0000000..1a075c4 --- /dev/null +++ b/apps/web-antd/src/views/product/batch/components/BatchSaleSwitchPanel.vue @@ -0,0 +1,129 @@ + + + diff --git a/apps/web-antd/src/views/product/batch/components/BatchStoreSyncPanel.vue b/apps/web-antd/src/views/product/batch/components/BatchStoreSyncPanel.vue new file mode 100644 index 0000000..cb357e0 --- /dev/null +++ b/apps/web-antd/src/views/product/batch/components/BatchStoreSyncPanel.vue @@ -0,0 +1,122 @@ + + + diff --git a/apps/web-antd/src/views/product/batch/components/BatchToolCards.vue b/apps/web-antd/src/views/product/batch/components/BatchToolCards.vue new file mode 100644 index 0000000..f1d3635 --- /dev/null +++ b/apps/web-antd/src/views/product/batch/components/BatchToolCards.vue @@ -0,0 +1,37 @@ + + + diff --git a/apps/web-antd/src/views/product/batch/components/BatchToolbar.vue b/apps/web-antd/src/views/product/batch/components/BatchToolbar.vue new file mode 100644 index 0000000..acb4274 --- /dev/null +++ b/apps/web-antd/src/views/product/batch/components/BatchToolbar.vue @@ -0,0 +1,35 @@ + + + diff --git a/apps/web-antd/src/views/product/batch/composables/batch-page/constants.ts b/apps/web-antd/src/views/product/batch/composables/batch-page/constants.ts new file mode 100644 index 0000000..250cf9f --- /dev/null +++ b/apps/web-antd/src/views/product/batch/composables/batch-page/constants.ts @@ -0,0 +1,65 @@ +import type { BatchScopeType, BatchToolCard } from '../../types'; + +/** 批量工具卡片配置。 */ +export const BATCH_TOOL_CARDS: BatchToolCard[] = [ + { + key: 'price', + title: '批量调价', + description: '按范围统一涨价或降价,支持预览后执行。', + icon: 'lucide:dollar-sign', + tone: 'orange', + }, + { + key: 'sale', + title: '批量上下架', + description: '快速批量上架或下架商品。', + icon: 'lucide:arrow-up-down', + tone: 'green', + }, + { + key: 'category', + title: '批量移动分类', + description: '将一批商品迁移到目标分类。', + icon: 'lucide:folder-input', + tone: 'blue', + }, + { + key: 'sync', + title: '批量同步门店', + description: '把源门店商品同步到其他门店。', + icon: 'lucide:refresh-cw', + tone: 'purple', + }, + { + key: 'excel', + title: '导入导出', + description: '下载模板导入商品,或按范围导出 Excel。', + icon: 'lucide:file-spreadsheet', + tone: 'cyan', + }, +]; + +/** 范围选项。 */ +export const BATCH_SCOPE_OPTIONS: Array<{ label: string; value: BatchScopeType }> = [ + { label: '全部商品', value: 'all' }, + { label: '按分类', value: 'category' }, + { label: '手动选择', value: 'manual' }, +]; + +/** 调价方向选项。 */ +export const PRICE_DIRECTION_OPTIONS = [ + { label: '涨价', value: 'up' as const }, + { label: '降价', value: 'down' as const }, +]; + +/** 调价方式选项。 */ +export const PRICE_AMOUNT_TYPE_OPTIONS = [ + { label: '固定金额', value: 'fixed' as const }, + { label: '百分比', value: 'percent' as const }, +]; + +/** 上下架动作。 */ +export const SALE_ACTION_OPTIONS = [ + { label: '上架', value: 'on' as const }, + { label: '下架', value: 'off' as const }, +]; diff --git a/apps/web-antd/src/views/product/batch/composables/batch-page/data-actions.ts b/apps/web-antd/src/views/product/batch/composables/batch-page/data-actions.ts new file mode 100644 index 0000000..1ec3c6a --- /dev/null +++ b/apps/web-antd/src/views/product/batch/composables/batch-page/data-actions.ts @@ -0,0 +1,104 @@ +import type { Ref } from 'vue'; + +import type { ProductPickerItemDto } from '#/api/product'; +import type { StoreListItemDto } from '#/api/store'; + +import { message } from 'ant-design-vue'; + +import { + getProductCategoryManageListApi, + searchProductPickerApi, +} from '#/api/product'; +import { getStoreListApi } from '#/api/store'; + +import { + toBatchCategoryOptions, + toBatchProductOptions, + type BatchCategoryOption, + type BatchProductOption, +} from '../../types'; + +interface CreateBatchDataActionsOptions { + categoryOptions: Ref; + isCategoryLoading: Ref; + isProductLoading: Ref; + isStoreLoading: Ref; + productOptions: Ref; + selectedStoreId: Ref; + stores: Ref; +} + +/** + * 文件职责:批量工具页数据加载。 + * 1. 加载门店列表。 + * 2. 基于门店加载分类和商品选择器数据。 + */ +export function createBatchDataActions(options: CreateBatchDataActionsOptions) { + async function loadStores() { + options.isStoreLoading.value = true; + try { + const result = await getStoreListApi({ + page: 1, + pageSize: 200, + }); + const rows = result.items ?? []; + options.stores.value = rows; + if (rows.length === 0) { + options.selectedStoreId.value = ''; + return; + } + + const hasCurrent = rows.some( + (item) => item.id === options.selectedStoreId.value, + ); + if (!hasCurrent) { + options.selectedStoreId.value = rows[0]?.id ?? ''; + } + } catch (error) { + console.error(error); + message.error('加载门店失败'); + } finally { + options.isStoreLoading.value = false; + } + } + + async function loadStoreScopedData() { + if (!options.selectedStoreId.value) { + options.categoryOptions.value = []; + options.productOptions.value = []; + return; + } + + options.isCategoryLoading.value = true; + options.isProductLoading.value = true; + try { + const [categories, products] = await Promise.all([ + getProductCategoryManageListApi({ + storeId: options.selectedStoreId.value, + }), + searchProductPickerApi({ + storeId: options.selectedStoreId.value, + limit: 1000, + }), + ]); + + options.categoryOptions.value = toBatchCategoryOptions(categories); + options.productOptions.value = toBatchProductOptions( + products as ProductPickerItemDto[], + ); + } catch (error) { + console.error(error); + options.categoryOptions.value = []; + options.productOptions.value = []; + message.error('加载批量工具数据失败'); + } finally { + options.isCategoryLoading.value = false; + options.isProductLoading.value = false; + } + } + + return { + loadStoreScopedData, + loadStores, + }; +} diff --git a/apps/web-antd/src/views/product/batch/composables/batch-page/tool-actions.ts b/apps/web-antd/src/views/product/batch/composables/batch-page/tool-actions.ts new file mode 100644 index 0000000..e857f9a --- /dev/null +++ b/apps/web-antd/src/views/product/batch/composables/batch-page/tool-actions.ts @@ -0,0 +1,404 @@ +import type { Ref } from 'vue'; + +import type { + ProductBatchPricePreviewDto, + ProductBatchScopeDto, +} from '#/api/product'; + +import { message } from 'ant-design-vue'; + +import { + batchAdjustProductPriceApi, + batchDownloadProductImportTemplateApi, + batchExportProductApi, + batchImportProductApi, + batchMoveProductCategoryApi, + batchPreviewProductPriceApi, + batchSwitchProductSaleApi, + batchSyncProductStoreApi, +} from '#/api/product'; + +import type { BatchScopeType } from '../../types'; + +interface CreateBatchToolActionsOptions { + exportCategoryIds: Ref; + exportScopeType: Ref<'all' | 'category'>; + importFile: Ref; + isSubmitting: Ref; + latestResultText: Ref; + moveSourceCategoryId: Ref; + moveTargetCategoryId: Ref; + priceAmount: Ref; + priceAmountType: Ref<'fixed' | 'percent'>; + priceDirection: Ref<'down' | 'up'>; + pricePreview: Ref; + pricePreviewLoading: Ref; + saleAction: Ref<'off' | 'on'>; + scopeCategoryIds: Ref; + scopeProductIds: Ref; + scopeType: Ref; + selectedStoreId: Ref; + syncProductIds: Ref; + syncPrice: Ref; + syncStatus: Ref; + syncStock: Ref; + syncTargetStoreIds: Ref; +} + +function buildScope( + type: BatchScopeType, + categoryIds: string[], + productIds: string[], +): ProductBatchScopeDto { + if (type === 'category') { + const normalized = [...new Set(categoryIds.filter(Boolean))]; + return { + type: 'category', + categoryIds: normalized, + categoryId: normalized[0], + }; + } + + if (type === 'manual') { + return { + type: 'manual', + productIds: [...new Set(productIds.filter(Boolean))], + }; + } + + return { type: 'all' }; +} + +function ensureScopeValid( + type: BatchScopeType, + categoryIds: string[], + productIds: string[], +) { + if (type === 'category' && categoryIds.length === 0) { + message.warning('请选择至少一个分类'); + return false; + } + + if (type === 'manual' && productIds.length === 0) { + message.warning('请至少选择一个商品'); + return false; + } + + return true; +} + +function decodeBase64ToBlob(base64: string) { + const binary = window.atob(base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index); + } + + return new Blob([bytes], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }); +} + +function downloadBase64File(fileName: string, fileContentBase64: string) { + const blob = decodeBase64ToBlob(fileContentBase64); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} + +/** + * 文件职责:批量工具动作。 + * 1. 封装所有批量 API 调用。 + * 2. 统一处理范围构建、结果提示与 Excel 下载。 + */ +export function createBatchToolActions(options: CreateBatchToolActionsOptions) { + function resolveCurrentScope() { + return buildScope( + options.scopeType.value, + options.scopeCategoryIds.value, + options.scopeProductIds.value, + ); + } + + async function previewPriceAdjust() { + if (!options.selectedStoreId.value) { + return; + } + + if ( + !ensureScopeValid( + options.scopeType.value, + options.scopeCategoryIds.value, + options.scopeProductIds.value, + ) + ) { + return; + } + + if (options.priceAmount.value < 0) { + message.warning('调价数值不能小于 0'); + return; + } + + options.pricePreviewLoading.value = true; + try { + options.pricePreview.value = await batchPreviewProductPriceApi({ + storeId: options.selectedStoreId.value, + scope: resolveCurrentScope(), + direction: options.priceDirection.value, + amountType: options.priceAmountType.value, + amount: options.priceAmount.value, + }); + } catch (error) { + console.error(error); + } finally { + options.pricePreviewLoading.value = false; + } + } + + async function submitPriceAdjust() { + if (!options.selectedStoreId.value) { + return; + } + + if ( + !ensureScopeValid( + options.scopeType.value, + options.scopeCategoryIds.value, + options.scopeProductIds.value, + ) + ) { + return; + } + + if (options.priceAmount.value < 0) { + message.warning('调价数值不能小于 0'); + return; + } + + options.isSubmitting.value = true; + try { + const result = await batchAdjustProductPriceApi({ + storeId: options.selectedStoreId.value, + scope: resolveCurrentScope(), + direction: options.priceDirection.value, + amountType: options.priceAmountType.value, + amount: options.priceAmount.value, + }); + options.latestResultText.value = `调价完成:成功 ${result.successCount} / 总计 ${result.totalCount},跳过 ${result.skippedCount}`; + message.success('批量调价已完成'); + await previewPriceAdjust(); + } catch (error) { + console.error(error); + } finally { + options.isSubmitting.value = false; + } + } + + async function submitSaleSwitch() { + if (!options.selectedStoreId.value) { + return; + } + + if ( + !ensureScopeValid( + options.scopeType.value, + options.scopeCategoryIds.value, + options.scopeProductIds.value, + ) + ) { + return; + } + + options.isSubmitting.value = true; + try { + const result = await batchSwitchProductSaleApi({ + storeId: options.selectedStoreId.value, + scope: resolveCurrentScope(), + action: options.saleAction.value, + }); + options.latestResultText.value = `上下架完成:成功 ${result.successCount} / 总计 ${result.totalCount},跳过 ${result.skippedCount}`; + message.success('批量上下架已完成'); + } catch (error) { + console.error(error); + } finally { + options.isSubmitting.value = false; + } + } + + async function submitMoveCategory() { + if (!options.selectedStoreId.value) { + return; + } + + if (!options.moveTargetCategoryId.value) { + message.warning('请选择目标分类'); + return; + } + + if (options.moveSourceCategoryId.value === options.moveTargetCategoryId.value) { + message.warning('源分类和目标分类不能相同'); + return; + } + + const scope = options.moveSourceCategoryId.value + ? buildScope('category', [options.moveSourceCategoryId.value], []) + : resolveCurrentScope(); + + if ( + scope.type !== 'category' && + !ensureScopeValid( + options.scopeType.value, + options.scopeCategoryIds.value, + options.scopeProductIds.value, + ) + ) { + return; + } + + options.isSubmitting.value = true; + try { + const result = await batchMoveProductCategoryApi({ + storeId: options.selectedStoreId.value, + scope, + sourceCategoryId: options.moveSourceCategoryId.value || undefined, + targetCategoryId: options.moveTargetCategoryId.value, + }); + options.latestResultText.value = `移动分类完成:成功 ${result.successCount} / 总计 ${result.totalCount},跳过 ${result.skippedCount}`; + message.success('批量移动分类已完成'); + } catch (error) { + console.error(error); + } finally { + options.isSubmitting.value = false; + } + } + + async function submitStoreSync() { + if (!options.selectedStoreId.value) { + return; + } + + if (options.syncTargetStoreIds.value.length === 0) { + message.warning('请至少选择一个目标门店'); + return; + } + + if (options.syncProductIds.value.length === 0) { + message.warning('请至少选择一个商品'); + return; + } + + options.isSubmitting.value = true; + try { + const result = await batchSyncProductStoreApi({ + sourceStoreId: options.selectedStoreId.value, + targetStoreIds: options.syncTargetStoreIds.value, + productIds: options.syncProductIds.value, + syncPrice: options.syncPrice.value, + syncStock: options.syncStock.value, + syncStatus: options.syncStatus.value, + }); + options.latestResultText.value = `同步完成:成功 ${result.successCount} / 总计 ${result.totalCount},失败 ${result.failedCount}`; + message.success('批量同步门店已完成'); + } catch (error) { + console.error(error); + } finally { + options.isSubmitting.value = false; + } + } + + async function downloadImportTemplate() { + if (!options.selectedStoreId.value) { + return; + } + + options.isSubmitting.value = true; + try { + const result = await batchDownloadProductImportTemplateApi( + options.selectedStoreId.value, + ); + downloadBase64File(result.fileName, result.fileContentBase64); + message.success('导入模板已下载'); + } catch (error) { + console.error(error); + } finally { + options.isSubmitting.value = false; + } + } + + async function submitImport() { + if (!options.selectedStoreId.value) { + return; + } + + if (!options.importFile.value) { + message.warning('请先选择导入文件'); + return; + } + + options.isSubmitting.value = true; + try { + const result = await batchImportProductApi({ + storeId: options.selectedStoreId.value, + file: options.importFile.value, + }); + options.latestResultText.value = `导入完成:成功 ${result.successCount} / 总计 ${result.totalCount},失败 ${result.failedCount}`; + message.success('批量导入已完成'); + } catch (error) { + console.error(error); + } finally { + options.isSubmitting.value = false; + } + } + + async function submitExport() { + if (!options.selectedStoreId.value) { + return; + } + + const scope = buildScope( + options.exportScopeType.value, + options.exportCategoryIds.value, + [], + ); + if ( + options.exportScopeType.value === 'category' && + options.exportCategoryIds.value.length === 0 + ) { + message.warning('导出按分类时请至少选择一个分类'); + return; + } + + options.isSubmitting.value = true; + try { + const result = await batchExportProductApi({ + storeId: options.selectedStoreId.value, + scope, + }); + downloadBase64File(result.fileName, result.fileContentBase64); + options.latestResultText.value = `导出完成:导出 ${result.successCount} / 总计 ${result.totalCount}`; + message.success('批量导出已完成'); + } catch (error) { + console.error(error); + } finally { + options.isSubmitting.value = false; + } + } + + return { + buildScope, + downloadImportTemplate, + previewPriceAdjust, + submitExport, + submitImport, + submitMoveCategory, + submitPriceAdjust, + submitSaleSwitch, + submitStoreSync, + }; +} diff --git a/apps/web-antd/src/views/product/batch/composables/useProductBatchPage.ts b/apps/web-antd/src/views/product/batch/composables/useProductBatchPage.ts new file mode 100644 index 0000000..c838d04 --- /dev/null +++ b/apps/web-antd/src/views/product/batch/composables/useProductBatchPage.ts @@ -0,0 +1,259 @@ +import { computed, onMounted, ref, watch } from 'vue'; + +import type { StoreListItemDto } from '#/api/store'; + +import { BATCH_TOOL_CARDS } from './batch-page/constants'; +import { createBatchDataActions } from './batch-page/data-actions'; +import { createBatchToolActions } from './batch-page/tool-actions'; +import type { + BatchCategoryOption, + BatchScopeType, + BatchToolCard, + BatchToolKey, +} from '../types'; + +/** + * 文件职责:批量工具页面状态编排。 + * 1. 管理门店、范围、卡片展开状态。 + * 2. 组合批量工具动作并对外暴露给视图组件。 + */ +export function useProductBatchPage() { + const stores = ref([]); + const selectedStoreId = ref(''); + const isStoreLoading = ref(false); + + const categoryOptions = ref([]); + const productOptions = ref>([]); + const isCategoryLoading = ref(false); + const isProductLoading = ref(false); + + const activeTool = ref(null); + const latestResultText = ref(''); + const scopeType = ref('all'); + const scopeCategoryIds = ref([]); + const scopeProductIds = ref([]); + + const priceDirection = ref<'down' | 'up'>('up'); + const priceAmountType = ref<'fixed' | 'percent'>('fixed'); + const priceAmount = ref(2); + const pricePreview = ref({ + totalCount: 0, + items: [], + }); + const pricePreviewLoading = ref(false); + + const saleAction = ref<'off' | 'on'>('on'); + + const moveSourceCategoryId = ref(''); + const moveTargetCategoryId = ref(''); + + const syncTargetStoreIds = ref([]); + const syncProductIds = ref([]); + const syncPrice = ref(true); + const syncStock = ref(true); + const syncStatus = ref(false); + + const importFile = ref(null); + const exportScopeType = ref<'all' | 'category'>('all'); + const exportCategoryIds = ref([]); + + const isSubmitting = ref(false); + + const { loadStoreScopedData, loadStores } = createBatchDataActions({ + stores, + selectedStoreId, + isStoreLoading, + categoryOptions, + productOptions, + isCategoryLoading, + isProductLoading, + }); + + const { + downloadImportTemplate, + previewPriceAdjust, + submitExport, + submitImport, + submitMoveCategory, + submitPriceAdjust, + submitSaleSwitch, + submitStoreSync, + } = createBatchToolActions({ + selectedStoreId, + scopeType, + scopeCategoryIds, + scopeProductIds, + priceDirection, + priceAmountType, + priceAmount, + pricePreview, + pricePreviewLoading, + saleAction, + moveSourceCategoryId, + moveTargetCategoryId, + syncTargetStoreIds, + syncProductIds, + syncPrice, + syncStock, + syncStatus, + importFile, + exportScopeType, + exportCategoryIds, + latestResultText, + isSubmitting, + }); + + const cards = computed(() => BATCH_TOOL_CARDS); + + const storeOptions = computed(() => + stores.value.map((item) => ({ + label: item.name, + value: item.id, + })), + ); + + const targetStoreOptions = computed(() => + stores.value + .filter((item) => item.id !== selectedStoreId.value) + .map((item) => ({ + label: item.name, + value: item.id, + })), + ); + + const selectedStoreLabel = computed(() => { + return stores.value.find((item) => item.id === selectedStoreId.value)?.name || ''; + }); + + const scopeEstimatedCount = computed(() => { + if (scopeType.value === 'all') { + return productOptions.value.length; + } + + if (scopeType.value === 'manual') { + return scopeProductIds.value.length; + } + + const selected = new Set(scopeCategoryIds.value); + return categoryOptions.value + .filter((item) => selected.has(item.value)) + .reduce((sum, item) => sum + item.productCount, 0); + }); + + const moveEstimatedCount = computed(() => { + if (moveSourceCategoryId.value) { + return ( + categoryOptions.value.find((item) => item.value === moveSourceCategoryId.value) + ?.productCount ?? 0 + ); + } + + return scopeEstimatedCount.value; + }); + + function toggleTool(key: BatchToolKey) { + activeTool.value = activeTool.value === key ? null : key; + } + + function closeTool() { + activeTool.value = null; + } + + function setSelectedStoreId(value: string) { + selectedStoreId.value = value; + } + + function setScopeType(value: BatchScopeType) { + scopeType.value = value; + } + + function setImportFile(file: File | null) { + importFile.value = file; + } + + watch(selectedStoreId, () => { + scopeType.value = 'all'; + scopeCategoryIds.value = []; + scopeProductIds.value = []; + moveSourceCategoryId.value = ''; + moveTargetCategoryId.value = ''; + syncTargetStoreIds.value = []; + syncProductIds.value = []; + exportScopeType.value = 'all'; + exportCategoryIds.value = []; + importFile.value = null; + pricePreview.value = { + totalCount: 0, + items: [], + }; + void loadStoreScopedData(); + }); + + watch([scopeType, scopeCategoryIds, scopeProductIds], () => { + if (scopeType.value !== 'category') { + scopeCategoryIds.value = []; + } + + if (scopeType.value !== 'manual') { + scopeProductIds.value = []; + } + }); + + watch(activeTool, (value) => { + if (value === 'price' && selectedStoreId.value) { + void previewPriceAdjust(); + } + }); + + onMounted(loadStores); + + return { + activeTool, + cards, + categoryOptions, + closeTool, + downloadImportTemplate, + exportCategoryIds, + exportScopeType, + importFile, + isCategoryLoading, + isProductLoading, + isStoreLoading, + isSubmitting, + latestResultText, + moveEstimatedCount, + moveSourceCategoryId, + moveTargetCategoryId, + previewPriceAdjust, + priceAmount, + priceAmountType, + priceDirection, + pricePreview, + pricePreviewLoading, + productOptions, + saleAction, + scopeCategoryIds, + scopeEstimatedCount, + scopeProductIds, + scopeType, + selectedStoreId, + selectedStoreLabel, + setImportFile, + setScopeType, + setSelectedStoreId, + storeOptions, + submitExport, + submitImport, + submitMoveCategory, + submitPriceAdjust, + submitSaleSwitch, + submitStoreSync, + syncPrice, + syncProductIds, + syncStatus, + syncStock, + syncTargetStoreIds, + targetStoreOptions, + toggleTool, + }; +} diff --git a/apps/web-antd/src/views/product/batch/index.vue b/apps/web-antd/src/views/product/batch/index.vue index db8bc8d..ec1e1c5 100644 --- a/apps/web-antd/src/views/product/batch/index.vue +++ b/apps/web-antd/src/views/product/batch/index.vue @@ -1,522 +1,251 @@ - diff --git a/apps/web-antd/src/views/product/batch/styles/base.less b/apps/web-antd/src/views/product/batch/styles/base.less new file mode 100644 index 0000000..66d6d5c --- /dev/null +++ b/apps/web-antd/src/views/product/batch/styles/base.less @@ -0,0 +1,5 @@ +:root { + --pbt-shadow-sm: 0 1px 2px rgb(0 0 0 / 5%); + --pbt-shadow-md: 0 8px 24px rgb(0 0 0 / 8%); + --pbt-transition: 220ms cubic-bezier(0.4, 0, 0.2, 1); +} diff --git a/apps/web-antd/src/views/product/batch/styles/card.less b/apps/web-antd/src/views/product/batch/styles/card.less new file mode 100644 index 0000000..0ff3d62 --- /dev/null +++ b/apps/web-antd/src/views/product/batch/styles/card.less @@ -0,0 +1,73 @@ +.page-product-batch { + .pbt-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + } + + .pbt-card { + display: flex; + flex-direction: column; + gap: 10px; + padding: 18px; + background: #fff; + border-radius: 12px; + box-shadow: var(--pbt-shadow-sm); + transition: var(--pbt-transition); + } + + .pbt-card:hover, + .pbt-card.active { + box-shadow: var(--pbt-shadow-md); + transform: translateY(-1px); + } + + .pbt-card-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + font-size: 20px; + border-radius: 10px; + } + + .pbt-card-name { + margin: 0; + font-size: 15px; + font-weight: 600; + color: #1a1a2e; + } + + .pbt-card-desc { + margin: 0; + font-size: 13px; + line-height: 1.5; + color: #6b7280; + } + + .pbt-card.is-orange .pbt-card-icon { + color: #fa8c16; + background: #fff7e6; + } + + .pbt-card.is-green .pbt-card-icon { + color: #52c41a; + background: #f6ffed; + } + + .pbt-card.is-blue .pbt-card-icon { + color: #1890ff; + background: #e6f7ff; + } + + .pbt-card.is-purple .pbt-card-icon { + color: #722ed1; + background: #f9f0ff; + } + + .pbt-card.is-cyan .pbt-card-icon { + color: #13c2c2; + background: #e6fffb; + } +} diff --git a/apps/web-antd/src/views/product/batch/styles/index.less b/apps/web-antd/src/views/product/batch/styles/index.less new file mode 100644 index 0000000..1079c0d --- /dev/null +++ b/apps/web-antd/src/views/product/batch/styles/index.less @@ -0,0 +1,5 @@ +@import './base.less'; +@import './layout.less'; +@import './card.less'; +@import './panel.less'; +@import './responsive.less'; diff --git a/apps/web-antd/src/views/product/batch/styles/layout.less b/apps/web-antd/src/views/product/batch/styles/layout.less new file mode 100644 index 0000000..e4b85b0 --- /dev/null +++ b/apps/web-antd/src/views/product/batch/styles/layout.less @@ -0,0 +1,51 @@ +.page-product-batch { + .pbt-page { + display: flex; + flex-direction: column; + gap: 16px; + max-width: 1120px; + } + + .pbt-toolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: #fff; + border-radius: 10px; + box-shadow: var(--pbt-shadow-sm); + } + + .pbt-toolbar-main { + display: flex; + gap: 10px; + align-items: center; + } + + .pbt-toolbar-label { + font-size: 13px; + color: #4b5563; + } + + .pbt-toolbar-store { + width: 240px; + } + + .pbt-toolbar-tip { + font-size: 13px; + color: #9ca3af; + } + + .pbt-empty-wrap { + padding: 24px; + background: #fff; + border-radius: 10px; + box-shadow: var(--pbt-shadow-sm); + } + + .pbt-result { + border-radius: 10px; + } +} diff --git a/apps/web-antd/src/views/product/batch/styles/panel.less b/apps/web-antd/src/views/product/batch/styles/panel.less new file mode 100644 index 0000000..59f7d01 --- /dev/null +++ b/apps/web-antd/src/views/product/batch/styles/panel.less @@ -0,0 +1,187 @@ +.page-product-batch { + .pbt-panel { + padding: 20px; + background: #fff; + border-radius: 12px; + box-shadow: var(--pbt-shadow-sm); + } + + .pbt-panel-hd { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; + } + + .pbt-panel-hd h3 { + margin: 0; + font-size: 15px; + font-weight: 600; + color: #1a1a2e; + } + + .pbt-close { + width: 28px; + height: 28px; + font-size: 16px; + color: #9ca3af; + cursor: pointer; + background: none; + border: none; + border-radius: 8px; + } + + .pbt-close:hover { + color: #111827; + background: #f3f4f6; + } + + .pbt-step { + margin-bottom: 16px; + } + + .pbt-step-label { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 8px; + font-size: 13px; + font-weight: 500; + color: #4b5563; + } + + .pbt-step-label .num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + font-size: 11px; + color: #fff; + background: #1677ff; + border-radius: 50%; + } + + .pbt-pills { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; + } + + .pbt-pill { + height: 34px; + padding: 0 14px; + font-size: 13px; + color: #4b5563; + cursor: pointer; + background: #fff; + border: 1px solid #d1d5db; + border-radius: 8px; + transition: var(--pbt-transition); + } + + .pbt-pill:hover { + color: #1677ff; + border-color: #1677ff; + } + + .pbt-pill.active { + color: #fff; + background: #1677ff; + border-color: #1677ff; + } + + .pbt-adjust-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + } + + .pbt-number { + width: 130px; + } + + .pbt-select-wide { + width: min(680px, 100%); + } + + .pbt-select-narrow { + width: min(280px, 100%); + } + + .pbt-table .up { + font-weight: 600; + color: #ef4444; + } + + .pbt-table .down { + font-weight: 600; + color: #22c55e; + } + + .pbt-info { + padding: 8px 12px; + margin: 8px 0 16px; + font-size: 13px; + color: #1f2937; + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 8px; + } + + .pbt-actions { + display: flex; + gap: 8px; + align-items: center; + padding-top: 12px; + border-top: 1px solid #f3f4f6; + } + + .pbt-sub-cards { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + } + + .pbt-sub-card { + padding: 16px; + border: 1px solid #f0f0f0; + border-radius: 10px; + } + + .pbt-sub-card h4 { + margin: 0 0 12px; + font-size: 14px; + font-weight: 600; + color: #1a1a2e; + } + + .pbt-upload-icon { + margin: 0; + font-size: 22px; + } + + .pbt-upload-title { + margin: 2px 0; + font-size: 13px; + color: #4b5563; + } + + .pbt-upload-tip { + margin: 0; + font-size: 12px; + color: #9ca3af; + } + + .pbt-upload-file { + margin-top: 10px; + font-size: 12px; + color: #4b5563; + } + + .pbt-upload-actions { + margin-top: 10px; + } +} diff --git a/apps/web-antd/src/views/product/batch/styles/responsive.less b/apps/web-antd/src/views/product/batch/styles/responsive.less new file mode 100644 index 0000000..daed81d --- /dev/null +++ b/apps/web-antd/src/views/product/batch/styles/responsive.less @@ -0,0 +1,25 @@ +@media (width <= 960px) { + .page-product-batch { + .pbt-grid { + grid-template-columns: 1fr; + } + + .pbt-sub-cards { + grid-template-columns: 1fr; + } + + .pbt-toolbar { + align-items: flex-start; + } + + .pbt-toolbar-main, + .pbt-toolbar-store, + .pbt-toolbar-tip { + width: 100%; + } + + .pbt-actions { + flex-wrap: wrap; + } + } +} diff --git a/apps/web-antd/src/views/product/batch/types.ts b/apps/web-antd/src/views/product/batch/types.ts new file mode 100644 index 0000000..bb41573 --- /dev/null +++ b/apps/web-antd/src/views/product/batch/types.ts @@ -0,0 +1,89 @@ +import type { + ProductBatchPricePreviewDto, + ProductBatchPricePreviewItemDto, + ProductCategoryManageDto, + ProductPickerItemDto, +} from '#/api/product'; + +/** 批量工具卡片标识。 */ +export type BatchToolKey = 'category' | 'excel' | 'price' | 'sale' | 'sync'; + +/** 批量范围类型。 */ +export type BatchScopeType = 'all' | 'category' | 'manual'; + +/** 批量工具卡片。 */ +export interface BatchToolCard { + description: string; + icon: string; + key: BatchToolKey; + title: string; + tone: 'blue' | 'cyan' | 'green' | 'orange' | 'purple'; +} + +/** 分类选项。 */ +export interface BatchCategoryOption { + label: string; + productCount: number; + value: string; +} + +/** 商品选项。 */ +export interface BatchProductOption { + label: string; + price: number; + spuCode: string; + value: string; +} + +/** 批量页共享状态。 */ +export interface BatchPageState { + activeTool: BatchToolKey | null; + latestResultText: string; + scopeType: BatchScopeType; +} + +/** 调价状态。 */ +export interface BatchPriceState { + amount: number; + amountType: 'fixed' | 'percent'; + direction: 'down' | 'up'; + preview: ProductBatchPricePreviewDto; +} + +/** 同步状态。 */ +export interface BatchSyncState { + productIds: string[]; + syncPrice: boolean; + syncStatus: boolean; + syncStock: boolean; + targetStoreIds: string[]; +} + +/** 导入导出状态。 */ +export interface BatchExcelState { + exportCategoryIds: string[]; + exportScopeType: 'all' | 'category'; + importFile: File | null; +} + +/** 批量页分类 DTO 转选项。 */ +export function toBatchCategoryOptions(rows: ProductCategoryManageDto[]) { + return rows.map((item) => ({ + label: item.name, + productCount: item.productCount, + value: item.id, + })); +} + +/** 批量页商品 DTO 转选项。 */ +export function toBatchProductOptions(rows: ProductPickerItemDto[]) { + return rows.map((item) => ({ + label: `${item.name}(${item.spuCode})`, + price: item.price, + spuCode: item.spuCode, + value: item.id, + })); +} + +/** 调价预览行。 */ +export type BatchPricePreviewRow = ProductBatchPricePreviewItemDto;