feat: 重构商品批量工具页面并接入真实接口
All checks were successful
Build and Deploy TenantUI / build-and-deploy (push) Successful in 54s

This commit is contained in:
2026-02-26 12:09:26 +08:00
parent 75c15ec17b
commit 9e869e6788
20 changed files with 2302 additions and 492 deletions

View File

@@ -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<ProductCategoryDto[]>('/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<ProductBatchPricePreviewDto>(
'/product/batch/price-adjust/preview',
data,
);
}
/** 批量调价。 */
export async function batchAdjustProductPriceApi(
data: ProductBatchAdjustPriceDto,
data: ProductBatchPriceAdjustDto,
) {
return requestClient.post<ProductBatchToolResultDto>(
'/product/batch/price-adjust',
@@ -950,16 +1008,29 @@ export async function batchSyncProductStoreApi(data: ProductBatchSyncStoreDto) {
}
/** 批量导入。 */
export async function batchImportProductApi(data: ProductBatchImportExportDto) {
return requestClient.post<ProductBatchImportExportResultDto>(
export async function batchImportProductApi(data: ProductBatchImportDto) {
const formData = new FormData();
formData.append('storeId', data.storeId);
formData.append('file', data.file);
return requestClient.post<ProductBatchImportResultDto>(
'/product/batch/import',
data,
formData,
);
}
/** 下载导入模板。 */
export async function batchDownloadProductImportTemplateApi(storeId: string) {
return requestClient.get<ProductBatchExcelFileDto>(
'/product/batch/import/template',
{
params: { storeId },
},
);
}
/** 批量导出。 */
export async function batchExportProductApi(data: ProductBatchImportExportDto) {
return requestClient.post<ProductBatchImportExportResultDto>(
export async function batchExportProductApi(data: ProductBatchExportDto) {
return requestClient.post<ProductBatchExcelFileDto>(
'/product/batch/export',
data,
);

View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
import type { UploadProps } from 'ant-design-vue';
interface Props {
categoryOptions: Array<{ label: string; value: string }>;
exportCategoryIds: string[];
exportScopeType: 'all' | 'category';
open: boolean;
selectedFileName: string;
submitting: boolean;
}
interface Emits {
(event: 'close'): void;
(event: 'downloadTemplate'): void;
(event: 'selectFile', file: File): void;
(event: 'submitExport'): void;
(event: 'submitImport'): void;
(event: 'update:exportCategoryIds', value: string[]): void;
(event: 'update:exportScopeType', value: 'all' | 'category'): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
emit('selectFile', file as File);
return false;
};
function setExportCategoryIds(value: unknown) {
if (!Array.isArray(value)) {
emit('update:exportCategoryIds', []);
return;
}
emit('update:exportCategoryIds', value.map(String));
}
</script>
<template>
<section v-if="props.open" class="pbt-panel">
<header class="pbt-panel-hd">
<h3>导入导出</h3>
<button type="button" class="pbt-close" @click="emit('close')">×</button>
</header>
<div class="pbt-sub-cards">
<article class="pbt-sub-card">
<h4>导入商品</h4>
<a-upload-dragger
:show-upload-list="false"
accept=".xlsx"
:before-upload="beforeUpload"
>
<p class="pbt-upload-icon">XLSX</p>
<p class="pbt-upload-title">点击或拖拽 Excel 文件到此处上传</p>
<p class="pbt-upload-tip">仅支持 .xlsx建议不超过 10MB</p>
</a-upload-dragger>
<div class="pbt-upload-file" v-if="props.selectedFileName">
已选择{{ props.selectedFileName }}
</div>
<a-space class="pbt-upload-actions">
<a-button
:loading="props.submitting"
@click="emit('downloadTemplate')"
>
下载导入模板
</a-button>
<a-button
type="primary"
:loading="props.submitting"
@click="emit('submitImport')"
>
开始导入
</a-button>
</a-space>
</article>
<article class="pbt-sub-card">
<h4>导出商品</h4>
<div class="pbt-step" style="margin-bottom: 12px">
<div class="pbt-pills">
<button
type="button"
class="pbt-pill"
:class="{ active: props.exportScopeType === 'all' }"
@click="emit('update:exportScopeType', 'all')"
>
全部
</button>
<button
type="button"
class="pbt-pill"
:class="{ active: props.exportScopeType === 'category' }"
@click="emit('update:exportScopeType', 'category')"
>
按分类
</button>
</div>
<a-select
v-if="props.exportScopeType === 'category'"
mode="multiple"
class="pbt-select-wide"
:value="props.exportCategoryIds"
:options="props.categoryOptions"
placeholder="选择导出分类"
@update:value="setExportCategoryIds"
/>
</div>
<a-button
type="primary"
:loading="props.submitting"
@click="emit('submitExport')"
>
导出 Excel
</a-button>
</article>
</div>
<footer class="pbt-actions">
<a-button @click="emit('close')">关闭</a-button>
</footer>
</section>
</template>

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
interface Props {
categoryOptions: Array<{ label: string; value: string }>;
estimatedCount: number;
open: boolean;
sourceCategoryId: string;
submitting: boolean;
targetCategoryId: string;
}
interface Emits {
(event: 'close'): void;
(event: 'submit'): void;
(event: 'update:sourceCategoryId', value: string): void;
(event: 'update:targetCategoryId', value: string): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
function setSourceCategory(value: unknown) {
emit('update:sourceCategoryId', String(value ?? ''));
}
function setTargetCategory(value: unknown) {
emit('update:targetCategoryId', String(value ?? ''));
}
</script>
<template>
<section v-if="props.open" class="pbt-panel">
<header class="pbt-panel-hd">
<h3>批量移动分类</h3>
<button type="button" class="pbt-close" @click="emit('close')">×</button>
</header>
<div class="pbt-step">
<div class="pbt-step-label">
<span class="num">1</span> 源分类可选
</div>
<a-select
:value="props.sourceCategoryId"
allow-clear
class="pbt-select-narrow"
:options="props.categoryOptions"
placeholder="请选择源分类"
@update:value="setSourceCategory"
/>
</div>
<div class="pbt-step">
<div class="pbt-step-label"><span class="num">2</span> 目标分类</div>
<a-select
:value="props.targetCategoryId"
class="pbt-select-narrow"
:options="props.categoryOptions"
placeholder="请选择目标分类"
@update:value="setTargetCategory"
/>
</div>
<div class="pbt-info">
预计移动 <strong>{{ props.estimatedCount }}</strong> 个商品
</div>
<footer class="pbt-actions">
<a-button
type="primary"
:loading="props.submitting"
@click="emit('submit')"
>
确认移动
</a-button>
<a-button @click="emit('close')">取消</a-button>
</footer>
</section>
</template>

View File

@@ -0,0 +1,216 @@
<script setup lang="ts">
import type { BatchPricePreviewRow, BatchScopeType } from '../types';
import { computed } from 'vue';
interface Props {
amount: number;
amountType: 'fixed' | 'percent';
categoryOptions: Array<{ label: string; value: string }>;
direction: 'down' | 'up';
open: boolean;
previewItems: BatchPricePreviewRow[];
previewLoading: boolean;
previewTotal: number;
productOptions: Array<{ label: string; value: string }>;
scopeCategoryIds: string[];
scopeProductIds: string[];
scopeType: BatchScopeType;
submitting: boolean;
}
interface Emits {
(event: 'close'): void;
(event: 'preview'): void;
(event: 'submit'): void;
(event: 'update:amount', value: number): void;
(event: 'update:amountType', value: 'fixed' | 'percent'): void;
(event: 'update:direction', value: 'down' | 'up'): void;
(event: 'update:scopeCategoryIds', value: string[]): void;
(event: 'update:scopeProductIds', value: string[]): void;
(event: 'update:scopeType', value: BatchScopeType): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const scopePills = [
{ label: '全部商品', value: 'all' as const },
{ label: '按分类', value: 'category' as const },
{ label: '手动选择', value: 'manual' as const },
];
const directionPills = [
{ label: '涨价', value: 'up' as const },
{ label: '降价', value: 'down' as const },
];
const amountTypePills = [
{ label: '固定金额', value: 'fixed' as const },
{ label: '百分比', value: 'percent' as const },
];
const columns = computed(() => [
{ title: '商品名', dataIndex: 'productName', key: 'productName' },
{
title: '原价',
dataIndex: 'originalPrice',
key: 'originalPrice',
width: 130,
},
{ title: '新价', dataIndex: 'newPrice', key: 'newPrice', width: 130 },
{ title: '变动', dataIndex: 'deltaPrice', key: 'deltaPrice', width: 130 },
]);
function formatMoney(value: number) {
return `¥${Number(value || 0).toFixed(2)}`;
}
function setScopeType(value: BatchScopeType) {
emit('update:scopeType', value);
}
function normalizeArray(value: unknown) {
if (!Array.isArray(value)) {
return [];
}
return value.map(String);
}
function setScopeCategoryIds(value: unknown) {
emit('update:scopeCategoryIds', normalizeArray(value));
}
function setScopeProductIds(value: unknown) {
emit('update:scopeProductIds', normalizeArray(value));
}
function setAmount(value: null | number) {
emit('update:amount', Number(value ?? 0));
}
</script>
<template>
<section v-if="props.open" class="pbt-panel">
<header class="pbt-panel-hd">
<h3>批量调价</h3>
<button type="button" class="pbt-close" @click="emit('close')">×</button>
</header>
<div class="pbt-step">
<div class="pbt-step-label"><span class="num">1</span> 选择范围</div>
<div class="pbt-pills">
<button
v-for="item in scopePills"
:key="item.value"
type="button"
class="pbt-pill"
:class="{ active: props.scopeType === item.value }"
@click="setScopeType(item.value)"
>
{{ item.label }}
</button>
</div>
<a-select
v-if="props.scopeType === 'category'"
mode="multiple"
:value="props.scopeCategoryIds"
:options="props.categoryOptions"
placeholder="选择分类"
class="pbt-select-wide"
@update:value="setScopeCategoryIds"
/>
<a-select
v-if="props.scopeType === 'manual'"
mode="multiple"
:value="props.scopeProductIds"
:options="props.productOptions"
placeholder="选择商品"
class="pbt-select-wide"
@update:value="setScopeProductIds"
/>
</div>
<div class="pbt-step">
<div class="pbt-step-label"><span class="num">2</span> 调价方式</div>
<div class="pbt-adjust-row">
<div class="pbt-pills">
<button
v-for="item in directionPills"
:key="item.value"
type="button"
class="pbt-pill"
:class="{ active: props.direction === item.value }"
@click="emit('update:direction', item.value)"
>
{{ item.label }}
</button>
</div>
<div class="pbt-pills">
<button
v-for="item in amountTypePills"
:key="item.value"
type="button"
class="pbt-pill"
:class="{ active: props.amountType === item.value }"
@click="emit('update:amountType', item.value)"
>
{{ item.label }}
</button>
</div>
<a-input-number
:value="props.amount"
:min="0"
:step="0.1"
class="pbt-number"
@update:value="setAmount"
/>
<a-button :loading="props.previewLoading" @click="emit('preview')">
更新预览
</a-button>
</div>
</div>
<div class="pbt-step">
<div class="pbt-step-label">
<span class="num">3</span>
预览 {{ props.previewTotal }} 个商品展示前 50
</div>
<a-table
class="pbt-table"
size="small"
:columns="columns"
:data-source="props.previewItems"
:pagination="false"
row-key="productId"
:loading="props.previewLoading"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'originalPrice'">
{{ formatMoney(record.originalPrice) }}
</template>
<template v-else-if="column.key === 'newPrice'">
{{ formatMoney(record.newPrice) }}
</template>
<template v-else-if="column.key === 'deltaPrice'">
<span :class="record.deltaPrice >= 0 ? 'up' : 'down'">
{{ record.deltaPrice >= 0 ? '+' : ''
}}{{ formatMoney(record.deltaPrice) }}
</span>
</template>
</template>
</a-table>
</div>
<footer class="pbt-actions">
<a-button
type="primary"
:loading="props.submitting"
@click="emit('submit')"
>
确认调价
</a-button>
<a-button @click="emit('close')">取消</a-button>
</footer>
</section>
</template>

View File

@@ -0,0 +1,129 @@
<script setup lang="ts">
import type { BatchScopeType } from '../types';
interface Props {
action: 'off' | 'on';
categoryOptions: Array<{ label: string; value: string }>;
estimatedCount: number;
open: boolean;
productOptions: Array<{ label: string; value: string }>;
scopeCategoryIds: string[];
scopeProductIds: string[];
scopeType: BatchScopeType;
submitting: boolean;
}
interface Emits {
(event: 'close'): void;
(event: 'submit'): void;
(event: 'update:action', value: 'off' | 'on'): void;
(event: 'update:scopeCategoryIds', value: string[]): void;
(event: 'update:scopeProductIds', value: string[]): void;
(event: 'update:scopeType', value: BatchScopeType): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const scopePills = [
{ label: '全部商品', value: 'all' as const },
{ label: '按分类', value: 'category' as const },
{ label: '手动选择', value: 'manual' as const },
];
const actionPills = [
{ label: '上架', value: 'on' as const },
{ label: '下架', value: 'off' as const },
];
function normalizeArray(value: unknown) {
if (!Array.isArray(value)) {
return [];
}
return value.map(String);
}
function setScopeCategoryIds(value: unknown) {
emit('update:scopeCategoryIds', normalizeArray(value));
}
function setScopeProductIds(value: unknown) {
emit('update:scopeProductIds', normalizeArray(value));
}
</script>
<template>
<section v-if="props.open" class="pbt-panel">
<header class="pbt-panel-hd">
<h3>批量上下架</h3>
<button type="button" class="pbt-close" @click="emit('close')">×</button>
</header>
<div class="pbt-step">
<div class="pbt-step-label"><span class="num">1</span> 选择范围</div>
<div class="pbt-pills">
<button
v-for="item in scopePills"
:key="item.value"
type="button"
class="pbt-pill"
:class="{ active: props.scopeType === item.value }"
@click="emit('update:scopeType', item.value)"
>
{{ item.label }}
</button>
</div>
<a-select
v-if="props.scopeType === 'category'"
mode="multiple"
:value="props.scopeCategoryIds"
:options="props.categoryOptions"
class="pbt-select-wide"
placeholder="选择分类"
@update:value="setScopeCategoryIds"
/>
<a-select
v-if="props.scopeType === 'manual'"
mode="multiple"
:value="props.scopeProductIds"
:options="props.productOptions"
class="pbt-select-wide"
placeholder="选择商品"
@update:value="setScopeProductIds"
/>
</div>
<div class="pbt-step">
<div class="pbt-step-label"><span class="num">2</span> 操作类型</div>
<div class="pbt-pills">
<button
v-for="item in actionPills"
:key="item.value"
type="button"
class="pbt-pill"
:class="{ active: props.action === item.value }"
@click="emit('update:action', item.value)"
>
{{ item.label }}
</button>
</div>
</div>
<div class="pbt-info">
预计影响 <strong>{{ props.estimatedCount }}</strong> 个商品
</div>
<footer class="pbt-actions">
<a-button
type="primary"
:loading="props.submitting"
@click="emit('submit')"
>
确认执行
</a-button>
<a-button @click="emit('close')">取消</a-button>
</footer>
</section>
</template>

View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
interface Props {
open: boolean;
productIds: string[];
productOptions: Array<{ label: string; value: string }>;
sourceStoreName: string;
submitting: boolean;
syncPrice: boolean;
syncStatus: boolean;
syncStock: boolean;
targetStoreIds: string[];
targetStoreOptions: Array<{ label: string; value: string }>;
}
interface Emits {
(event: 'close'): void;
(event: 'submit'): void;
(event: 'update:productIds', value: string[]): void;
(event: 'update:syncPrice', value: boolean): void;
(event: 'update:syncStatus', value: boolean): void;
(event: 'update:syncStock', value: boolean): void;
(event: 'update:targetStoreIds', value: string[]): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
function normalizeArray(value: unknown) {
if (!Array.isArray(value)) {
return [];
}
return value.map(String);
}
function setTargetStoreIds(value: unknown) {
emit('update:targetStoreIds', normalizeArray(value));
}
function setProductIds(value: unknown) {
emit('update:productIds', normalizeArray(value));
}
function setSyncPrice(value: boolean | string | undefined) {
emit('update:syncPrice', Boolean(value));
}
function setSyncStock(value: boolean | string | undefined) {
emit('update:syncStock', Boolean(value));
}
function setSyncStatus(value: boolean | string | undefined) {
emit('update:syncStatus', Boolean(value));
}
</script>
<template>
<section v-if="props.open" class="pbt-panel">
<header class="pbt-panel-hd">
<h3>批量同步门店</h3>
<button type="button" class="pbt-close" @click="emit('close')">×</button>
</header>
<div class="pbt-step">
<div class="pbt-step-label"><span class="num">1</span> 源门店</div>
<a-input
:value="props.sourceStoreName"
disabled
class="pbt-select-narrow"
/>
</div>
<div class="pbt-step">
<div class="pbt-step-label"><span class="num">2</span> 目标门店</div>
<a-select
mode="multiple"
:value="props.targetStoreIds"
:options="props.targetStoreOptions"
class="pbt-select-wide"
placeholder="请选择目标门店"
@update:value="setTargetStoreIds"
/>
</div>
<div class="pbt-step">
<div class="pbt-step-label"><span class="num">3</span> 同步商品</div>
<a-select
mode="multiple"
:value="props.productIds"
:options="props.productOptions"
class="pbt-select-wide"
placeholder="请选择需要同步的商品"
@update:value="setProductIds"
/>
</div>
<div class="pbt-step">
<div class="pbt-step-label"><span class="num">4</span> 同步选项</div>
<a-space>
<a-checkbox :checked="props.syncPrice" @update:checked="setSyncPrice">
价格
</a-checkbox>
<a-checkbox :checked="props.syncStock" @update:checked="setSyncStock">
库存
</a-checkbox>
<a-checkbox :checked="props.syncStatus" @update:checked="setSyncStatus">
状态
</a-checkbox>
</a-space>
</div>
<footer class="pbt-actions">
<a-button
type="primary"
:loading="props.submitting"
@click="emit('submit')"
>
确认同步
</a-button>
<a-button @click="emit('close')">取消</a-button>
</footer>
</section>
</template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import type { BatchToolCard, BatchToolKey } from '../types';
import { IconifyIcon } from '@vben/icons';
interface Props {
activeTool: BatchToolKey | null;
cards: BatchToolCard[];
}
interface Emits {
(event: 'toggle', key: BatchToolKey): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
</script>
<template>
<div class="pbt-grid">
<article
v-for="card in props.cards"
:key="card.key"
class="pbt-card"
:class="[`is-${card.tone}`, { active: props.activeTool === card.key }]"
>
<div class="pbt-card-icon">
<IconifyIcon :icon="card.icon" />
</div>
<h3 class="pbt-card-name">{{ card.title }}</h3>
<p class="pbt-card-desc">{{ card.description }}</p>
<a-button type="primary" size="small" @click="emit('toggle', card.key)">
{{ props.activeTool === card.key ? '收起' : '开始操作' }}
</a-button>
</article>
</div>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
interface Props {
isStoreLoading: boolean;
selectedStoreId: string;
storeOptions: Array<{ label: string; value: string }>;
}
interface Emits {
(event: 'update:selectedStoreId', value: string): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
function setStore(value: string | undefined) {
emit('update:selectedStoreId', value ?? '');
}
</script>
<template>
<div class="pbt-toolbar">
<div class="pbt-toolbar-main">
<span class="pbt-toolbar-label">门店</span>
<a-select
class="pbt-toolbar-store"
:value="props.selectedStoreId"
:options="props.storeOptions"
:loading="props.isStoreLoading"
placeholder="请选择门店"
@update:value="setStore"
/>
</div>
<div class="pbt-toolbar-tip">选择门店后工具仅作用于当前门店商品</div>
</div>
</template>

View File

@@ -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 },
];

View File

@@ -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<BatchCategoryOption[]>;
isCategoryLoading: Ref<boolean>;
isProductLoading: Ref<boolean>;
isStoreLoading: Ref<boolean>;
productOptions: Ref<BatchProductOption[]>;
selectedStoreId: Ref<string>;
stores: Ref<StoreListItemDto[]>;
}
/**
* 文件职责:批量工具页数据加载。
* 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,
};
}

View File

@@ -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<string[]>;
exportScopeType: Ref<'all' | 'category'>;
importFile: Ref<File | null>;
isSubmitting: Ref<boolean>;
latestResultText: Ref<string>;
moveSourceCategoryId: Ref<string>;
moveTargetCategoryId: Ref<string>;
priceAmount: Ref<number>;
priceAmountType: Ref<'fixed' | 'percent'>;
priceDirection: Ref<'down' | 'up'>;
pricePreview: Ref<ProductBatchPricePreviewDto>;
pricePreviewLoading: Ref<boolean>;
saleAction: Ref<'off' | 'on'>;
scopeCategoryIds: Ref<string[]>;
scopeProductIds: Ref<string[]>;
scopeType: Ref<BatchScopeType>;
selectedStoreId: Ref<string>;
syncProductIds: Ref<string[]>;
syncPrice: Ref<boolean>;
syncStatus: Ref<boolean>;
syncStock: Ref<boolean>;
syncTargetStoreIds: Ref<string[]>;
}
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,
};
}

View File

@@ -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<StoreListItemDto[]>([]);
const selectedStoreId = ref('');
const isStoreLoading = ref(false);
const categoryOptions = ref<BatchCategoryOption[]>([]);
const productOptions = ref<Array<{ label: string; price: number; spuCode: string; value: string }>>([]);
const isCategoryLoading = ref(false);
const isProductLoading = ref(false);
const activeTool = ref<BatchToolKey | null>(null);
const latestResultText = ref('');
const scopeType = ref<BatchScopeType>('all');
const scopeCategoryIds = ref<string[]>([]);
const scopeProductIds = ref<string[]>([]);
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<string[]>([]);
const syncProductIds = ref<string[]>([]);
const syncPrice = ref(true);
const syncStock = ref(true);
const syncStatus = ref(false);
const importFile = ref<File | null>(null);
const exportScopeType = ref<'all' | 'category'>('all');
const exportCategoryIds = ref<string[]>([]);
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<BatchToolCard[]>(() => 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,
};
}

View File

@@ -1,522 +1,251 @@
<script setup lang="ts">
/**
* 文件职责:商品批量工具页面。
* 1. 提供批量调价、批量上下架、批量移动分类能力。
* 2. 提供批量同步门店、导入导出能力Mock 回执)。
*/
import type { ProductBatchScopeDto } from '#/api/product';
import type { StoreListItemDto } from '#/api/store';
import { computed, onMounted, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import {
Alert,
Button,
Card,
Empty,
InputNumber,
message,
Select,
Space,
Tabs,
} from 'ant-design-vue';
import { Alert, Empty } from 'ant-design-vue';
import {
batchAdjustProductPriceApi,
batchExportProductApi,
batchImportProductApi,
batchMoveProductCategoryApi,
batchSwitchProductSaleApi,
batchSyncProductStoreApi,
getProductCategoryManageListApi,
searchProductPickerApi,
} from '#/api/product';
import { getStoreListApi } from '#/api/store';
import BatchImportExportPanel from './components/BatchImportExportPanel.vue';
import BatchMoveCategoryPanel from './components/BatchMoveCategoryPanel.vue';
import BatchPriceAdjustPanel from './components/BatchPriceAdjustPanel.vue';
import BatchSaleSwitchPanel from './components/BatchSaleSwitchPanel.vue';
import BatchStoreSyncPanel from './components/BatchStoreSyncPanel.vue';
import BatchToolbar from './components/BatchToolbar.vue';
import BatchToolCards from './components/BatchToolCards.vue';
import { useProductBatchPage } from './composables/useProductBatchPage';
type ScopeType = 'all' | 'category' | 'selected';
const {
activeTool,
cards,
categoryOptions,
closeTool,
downloadImportTemplate,
exportCategoryIds,
exportScopeType,
importFile,
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,
} = useProductBatchPage();
interface CategoryOption {
label: string;
value: string;
function updateScopeCategoryIds(value: string[]) {
scopeCategoryIds.value = value;
}
interface ProductOption {
label: string;
value: string;
function updateScopeProductIds(value: string[]) {
scopeProductIds.value = value;
}
const stores = ref<StoreListItemDto[]>([]);
const selectedStoreId = ref('');
const isStoreLoading = ref(false);
const isCategoryLoading = ref(false);
const categoryOptions = ref<CategoryOption[]>([]);
const productOptions = ref<ProductOption[]>([]);
const isProductLoading = ref(false);
const scopeType = ref<ScopeType>('all');
const scopeCategoryId = ref('');
const scopeProductIds = ref<string[]>([]);
const priceMode = ref<'decrease' | 'increase' | 'set'>('increase');
const priceAmount = ref(1);
const saleAction = ref<'off' | 'on'>('off');
const moveTargetCategoryId = ref('');
const syncTargetStoreIds = ref<string[]>([]);
const syncProductIds = ref<string[]>([]);
const activeTab = ref('price');
const isSubmitting = ref(false);
const latestResultText = ref('');
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,
})),
);
/** 加载门店列表。 */
async function loadStores() {
isStoreLoading.value = true;
try {
const result = await getStoreListApi({
page: 1,
pageSize: 200,
});
stores.value = result.items ?? [];
if (stores.value.length === 0) {
selectedStoreId.value = '';
return;
}
const hasSelected = stores.value.some(
(item) => item.id === selectedStoreId.value,
);
if (!hasSelected) {
selectedStoreId.value = stores.value[0]?.id ?? '';
}
} catch (error) {
console.error(error);
message.error('加载门店失败');
} finally {
isStoreLoading.value = false;
}
function updatePriceDirection(value: 'down' | 'up') {
priceDirection.value = value;
}
/** 加载分类与商品选择器数据。 */
async function reloadScopeData() {
if (!selectedStoreId.value) {
categoryOptions.value = [];
productOptions.value = [];
return;
}
isCategoryLoading.value = true;
isProductLoading.value = true;
try {
const [categories, products] = await Promise.all([
getProductCategoryManageListApi({
storeId: selectedStoreId.value,
}),
searchProductPickerApi({
storeId: selectedStoreId.value,
limit: 500,
}),
]);
categoryOptions.value = categories.map((item) => ({
label: item.name,
value: item.id,
}));
productOptions.value = products.map((item) => ({
label: `${item.name}${item.spuCode}`,
value: item.id,
}));
if (
moveTargetCategoryId.value &&
!categoryOptions.value.some(
(item) => item.value === moveTargetCategoryId.value,
)
) {
moveTargetCategoryId.value = '';
}
} catch (error) {
console.error(error);
categoryOptions.value = [];
productOptions.value = [];
message.error('加载批量工具数据失败');
} finally {
isCategoryLoading.value = false;
isProductLoading.value = false;
}
function updatePriceAmountType(value: 'fixed' | 'percent') {
priceAmountType.value = value;
}
/** 构建批量范围。 */
function buildScope(): ProductBatchScopeDto {
if (scopeType.value === 'category') {
return {
type: 'category',
categoryId: scopeCategoryId.value || undefined,
};
}
if (scopeType.value === 'selected') {
return {
type: 'selected',
productIds: [...scopeProductIds.value],
};
}
return { type: 'all' };
function updatePriceAmount(value: number) {
priceAmount.value = value;
}
/** 校验范围入参是否可用。 */
function validateScope() {
if (scopeType.value === 'category' && !scopeCategoryId.value) {
message.warning('请选择范围分类');
return false;
}
if (scopeType.value === 'selected' && scopeProductIds.value.length === 0) {
message.warning('请选择至少一个商品');
return false;
}
return true;
function updateSaleAction(value: 'off' | 'on') {
saleAction.value = value;
}
/** 批量调价。 */
async function submitPriceAdjust() {
if (!selectedStoreId.value) return;
if (!validateScope()) return;
if (priceAmount.value < 0) {
message.warning('调价金额不能小于 0');
return;
}
isSubmitting.value = true;
try {
const result = await batchAdjustProductPriceApi({
storeId: selectedStoreId.value,
scope: buildScope(),
mode: priceMode.value,
amount: priceAmount.value,
});
latestResultText.value = `调价完成:成功 ${result.successCount} / 总计 ${result.totalCount}`;
message.success('批量调价成功');
} catch (error) {
console.error(error);
} finally {
isSubmitting.value = false;
}
function updateMoveSourceCategoryId(value: string) {
moveSourceCategoryId.value = value;
}
/** 批量上下架。 */
async function submitSaleSwitch() {
if (!selectedStoreId.value) return;
if (!validateScope()) return;
isSubmitting.value = true;
try {
const result = await batchSwitchProductSaleApi({
storeId: selectedStoreId.value,
scope: buildScope(),
action: saleAction.value,
});
latestResultText.value = `上下架完成:成功 ${result.successCount} / 总计 ${result.totalCount}`;
message.success('批量上下架成功');
} catch (error) {
console.error(error);
} finally {
isSubmitting.value = false;
}
function updateMoveTargetCategoryId(value: string) {
moveTargetCategoryId.value = value;
}
/** 批量移动分类。 */
async function submitMoveCategory() {
if (!selectedStoreId.value) return;
if (!validateScope()) return;
if (!moveTargetCategoryId.value) {
message.warning('请选择目标分类');
return;
}
isSubmitting.value = true;
try {
const result = await batchMoveProductCategoryApi({
storeId: selectedStoreId.value,
scope: buildScope(),
targetCategoryId: moveTargetCategoryId.value,
});
latestResultText.value = `移动分类完成:成功 ${result.successCount} / 总计 ${result.totalCount}`;
message.success('批量移动分类成功');
} catch (error) {
console.error(error);
} finally {
isSubmitting.value = false;
}
function updateSyncTargetStoreIds(value: string[]) {
syncTargetStoreIds.value = value;
}
/** 批量同步门店。 */
async function submitSyncStore() {
if (!selectedStoreId.value) return;
if (syncTargetStoreIds.value.length === 0) {
message.warning('请选择目标门店');
return;
}
if (syncProductIds.value.length === 0) {
message.warning('请选择要同步的商品');
return;
}
isSubmitting.value = true;
try {
const result = await batchSyncProductStoreApi({
sourceStoreId: selectedStoreId.value,
targetStoreIds: [...syncTargetStoreIds.value],
productIds: [...syncProductIds.value],
});
latestResultText.value = `同步完成:成功 ${result.successCount} / 总计 ${result.totalCount}`;
message.success('批量同步成功');
} catch (error) {
console.error(error);
} finally {
isSubmitting.value = false;
}
function updateSyncProductIds(value: string[]) {
syncProductIds.value = value;
}
/** 批量导入。 */
async function submitImport() {
if (!selectedStoreId.value) return;
if (!validateScope()) return;
isSubmitting.value = true;
try {
const result = await batchImportProductApi({
storeId: selectedStoreId.value,
scope: buildScope(),
});
latestResultText.value = `导入完成:文件 ${result.fileName},成功 ${result.successCount} / 总计 ${result.totalCount}`;
message.success('批量导入成功');
} catch (error) {
console.error(error);
} finally {
isSubmitting.value = false;
}
function updateSyncPrice(value: boolean) {
syncPrice.value = value;
}
/** 批量导出。 */
async function submitExport() {
if (!selectedStoreId.value) return;
if (!validateScope()) return;
isSubmitting.value = true;
try {
const result = await batchExportProductApi({
storeId: selectedStoreId.value,
scope: buildScope(),
});
latestResultText.value = `导出完成:文件 ${result.fileName},导出 ${result.successCount} / 总计 ${result.totalCount}`;
message.success('批量导出成功');
} catch (error) {
console.error(error);
} finally {
isSubmitting.value = false;
}
function updateSyncStock(value: boolean) {
syncStock.value = value;
}
watch(selectedStoreId, () => {
scopeCategoryId.value = '';
scopeProductIds.value = [];
moveTargetCategoryId.value = '';
syncTargetStoreIds.value = [];
syncProductIds.value = [];
reloadScopeData();
});
function updateSyncStatus(value: boolean) {
syncStatus.value = value;
}
onMounted(loadStores);
function updateExportScopeType(value: 'all' | 'category') {
exportScopeType.value = value;
}
function updateExportCategoryIds(value: string[]) {
exportCategoryIds.value = value;
}
</script>
<template>
<Page title="批量工具" content-class="space-y-4 page-product-batch-tools">
<Card :bordered="false">
<Space direction="vertical" style="width: 100%">
<Space wrap>
<Select
v-model:value="selectedStoreId"
:options="storeOptions"
:loading="isStoreLoading"
style="width: 240px"
placeholder="请选择门店"
<Page title="批量工具" content-class="page-product-batch">
<div class="pbt-page">
<BatchToolbar
:selected-store-id="selectedStoreId"
:store-options="storeOptions"
:is-store-loading="isStoreLoading"
@update:selected-store-id="setSelectedStoreId"
/>
<Select
v-model:value="scopeType"
style="width: 160px"
:options="[
{ label: '全部商品', value: 'all' },
{ label: '按分类', value: 'category' },
{ label: '手动选择', value: 'selected' },
]"
/>
<Select
v-if="scopeType === 'category'"
v-model:value="scopeCategoryId"
:options="categoryOptions"
:loading="isCategoryLoading"
style="width: 200px"
placeholder="选择分类"
/>
<Select
v-if="scopeType === 'selected'"
v-model:value="scopeProductIds"
mode="multiple"
:options="productOptions"
:loading="isProductLoading"
style="min-width: 360px"
placeholder="选择商品"
/>
</Space>
<Alert
type="info"
show-icon
message="先设定批量范围,再选择具体工具执行。所有结果均为 Mock 回执。"
/>
</Space>
</Card>
<Card v-if="!selectedStoreId" :bordered="false">
<div v-if="!selectedStoreId" class="pbt-empty-wrap">
<Empty description="暂无门店,请先创建门店" />
</Card>
</div>
<template v-else>
<Card :bordered="false">
<Tabs v-model:active-key="activeTab">
<Tabs.TabPane key="price" tab="批量调价">
<Space wrap>
<Select
v-model:value="priceMode"
style="width: 160px"
:options="[
{ label: '统一设价', value: 'set' },
{ label: '统一涨价', value: 'increase' },
{ label: '统一降价', value: 'decrease' },
]"
<BatchToolCards
:cards="cards"
:active-tool="activeTool"
@toggle="toggleTool"
/>
<InputNumber v-model:value="priceAmount" :min="0" :step="0.5" />
<Button
type="primary"
:loading="isSubmitting"
@click="submitPriceAdjust"
>
执行调价
</Button>
</Space>
</Tabs.TabPane>
<Tabs.TabPane key="sale" tab="批量上下架">
<Space wrap>
<Select
v-model:value="saleAction"
style="width: 160px"
:options="[
{ label: '批量上架', value: 'on' },
{ label: '批量下架', value: 'off' },
]"
<BatchPriceAdjustPanel
:open="activeTool === 'price'"
:scope-type="scopeType"
:scope-category-ids="scopeCategoryIds"
:scope-product-ids="scopeProductIds"
:category-options="categoryOptions"
:product-options="productOptions"
:direction="priceDirection"
:amount-type="priceAmountType"
:amount="priceAmount"
:preview-items="pricePreview.items"
:preview-total="pricePreview.totalCount"
:preview-loading="pricePreviewLoading"
:submitting="isSubmitting"
@update:scope-type="setScopeType"
@update:scope-category-ids="updateScopeCategoryIds"
@update:scope-product-ids="updateScopeProductIds"
@update:direction="updatePriceDirection"
@update:amount-type="updatePriceAmountType"
@update:amount="updatePriceAmount"
@preview="previewPriceAdjust"
@submit="submitPriceAdjust"
@close="closeTool"
/>
<Button
type="primary"
:loading="isSubmitting"
@click="submitSaleSwitch"
>
执行上下架
</Button>
</Space>
</Tabs.TabPane>
<Tabs.TabPane key="category" tab="批量移动分类">
<Space wrap>
<Select
v-model:value="moveTargetCategoryId"
:options="categoryOptions"
:loading="isCategoryLoading"
style="width: 220px"
placeholder="目标分类"
<BatchSaleSwitchPanel
:open="activeTool === 'sale'"
:scope-type="scopeType"
:scope-category-ids="scopeCategoryIds"
:scope-product-ids="scopeProductIds"
:category-options="categoryOptions"
:product-options="productOptions"
:estimated-count="scopeEstimatedCount"
:action="saleAction"
:submitting="isSubmitting"
@update:scope-type="setScopeType"
@update:scope-category-ids="updateScopeCategoryIds"
@update:scope-product-ids="updateScopeProductIds"
@update:action="updateSaleAction"
@submit="submitSaleSwitch"
@close="closeTool"
/>
<Button
type="primary"
:loading="isSubmitting"
@click="submitMoveCategory"
>
执行移动
</Button>
</Space>
</Tabs.TabPane>
<Tabs.TabPane key="sync" tab="同步到其他门店">
<Space direction="vertical" style="width: 100%">
<Space wrap>
<Select
v-model:value="syncTargetStoreIds"
mode="multiple"
:options="targetStoreOptions"
style="min-width: 300px"
placeholder="选择目标门店"
<BatchMoveCategoryPanel
:open="activeTool === 'category'"
:category-options="categoryOptions"
:source-category-id="moveSourceCategoryId"
:target-category-id="moveTargetCategoryId"
:estimated-count="moveEstimatedCount"
:submitting="isSubmitting"
@update:source-category-id="updateMoveSourceCategoryId"
@update:target-category-id="updateMoveTargetCategoryId"
@submit="submitMoveCategory"
@close="closeTool"
/>
</Space>
<Select
v-model:value="syncProductIds"
mode="multiple"
:options="productOptions"
:loading="isProductLoading"
placeholder="选择需要同步的商品"
<BatchStoreSyncPanel
:open="activeTool === 'sync'"
:source-store-name="selectedStoreLabel"
:target-store-ids="syncTargetStoreIds"
:target-store-options="targetStoreOptions"
:product-ids="syncProductIds"
:product-options="productOptions"
:sync-price="syncPrice"
:sync-stock="syncStock"
:sync-status="syncStatus"
:submitting="isSubmitting"
@update:target-store-ids="updateSyncTargetStoreIds"
@update:product-ids="updateSyncProductIds"
@update:sync-price="updateSyncPrice"
@update:sync-stock="updateSyncStock"
@update:sync-status="updateSyncStatus"
@submit="submitStoreSync"
@close="closeTool"
/>
<Button
type="primary"
:loading="isSubmitting"
@click="submitSyncStore"
>
执行同步
</Button>
</Space>
</Tabs.TabPane>
<Tabs.TabPane key="excel" tab="导入导出">
<Space wrap>
<Button :loading="isSubmitting" @click="submitImport">
导入商品
</Button>
<Button
type="primary"
:loading="isSubmitting"
@click="submitExport"
>
导出商品
</Button>
</Space>
</Tabs.TabPane>
</Tabs>
</Card>
<Card v-if="latestResultText" :bordered="false">
<Alert type="success" show-icon :message="latestResultText" />
</Card>
<BatchImportExportPanel
:open="activeTool === 'excel'"
:selected-file-name="importFile?.name || ''"
:export-scope-type="exportScopeType"
:export-category-ids="exportCategoryIds"
:category-options="categoryOptions"
:submitting="isSubmitting"
@select-file="setImportFile"
@update:export-scope-type="updateExportScopeType"
@update:export-category-ids="updateExportCategoryIds"
@download-template="downloadImportTemplate"
@submit-import="submitImport"
@submit-export="submitExport"
@close="closeTool"
/>
</template>
<Alert
v-if="latestResultText"
class="pbt-result"
type="success"
show-icon
:message="latestResultText"
/>
</div>
</Page>
</template>
<style scoped lang="less">
/* 文件职责:批量工具页面样式。 */
.page-product-batch-tools {
:deep(.ant-tabs-nav) {
margin-bottom: 18px;
}
}
<style lang="less">
@import './styles/index.less';
</style>

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,5 @@
@import './base.less';
@import './layout.less';
@import './card.less';
@import './panel.less';
@import './responsive.less';

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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<BatchCategoryOption>((item) => ({
label: item.name,
productCount: item.productCount,
value: item.id,
}));
}
/** 批量页商品 DTO 转选项。 */
export function toBatchProductOptions(rows: ProductPickerItemDto[]) {
return rows.map<BatchProductOption>((item) => ({
label: `${item.name}${item.spuCode}`,
price: item.price,
spuCode: item.spuCode,
value: item.id,
}));
}
/** 调价预览行。 */
export type BatchPricePreviewRow = ProductBatchPricePreviewItemDto;