feat: 重构商品批量工具页面并接入真实接口
All checks were successful
Build and Deploy TenantUI / build-and-deploy (push) Successful in 54s
All checks were successful
Build and Deploy TenantUI / build-and-deploy (push) Successful in 54s
This commit is contained in:
@@ -611,21 +611,48 @@ export interface ChangeProductScheduleStatusDto {
|
|||||||
/** 批量范围。 */
|
/** 批量范围。 */
|
||||||
export interface ProductBatchScopeDto {
|
export interface ProductBatchScopeDto {
|
||||||
categoryId?: string;
|
categoryId?: string;
|
||||||
|
categoryIds?: string[];
|
||||||
productIds?: 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;
|
amount: number;
|
||||||
mode: 'decrease' | 'increase' | 'set';
|
amountType: 'fixed' | 'percent';
|
||||||
|
direction: 'down' | 'up';
|
||||||
scope: ProductBatchScopeDto;
|
scope: ProductBatchScopeDto;
|
||||||
storeId: string;
|
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 {
|
export interface ProductBatchMoveCategoryDto {
|
||||||
scope: ProductBatchScopeDto;
|
scope: ProductBatchScopeDto;
|
||||||
|
sourceCategoryId?: string;
|
||||||
storeId: string;
|
storeId: string;
|
||||||
targetCategoryId: string;
|
targetCategoryId: string;
|
||||||
}
|
}
|
||||||
@@ -641,32 +668,53 @@ export interface ProductBatchSaleSwitchDto {
|
|||||||
export interface ProductBatchSyncStoreDto {
|
export interface ProductBatchSyncStoreDto {
|
||||||
productIds: string[];
|
productIds: string[];
|
||||||
sourceStoreId: string;
|
sourceStoreId: string;
|
||||||
|
syncPrice?: boolean;
|
||||||
|
syncStatus?: boolean;
|
||||||
|
syncStock?: boolean;
|
||||||
targetStoreIds: string[];
|
targetStoreIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 批量工具通用结果。 */
|
/** 批量工具通用结果。 */
|
||||||
export interface ProductBatchToolResultDto {
|
export interface ProductBatchToolResultDto {
|
||||||
failedCount: number;
|
failedCount: number;
|
||||||
|
skippedCount: number;
|
||||||
successCount: number;
|
successCount: number;
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 导入导出请求参数。 */
|
/** 批量导出请求参数。 */
|
||||||
export interface ProductBatchImportExportDto {
|
export interface ProductBatchExportDto {
|
||||||
scope: ProductBatchScopeDto;
|
scope: ProductBatchScopeDto;
|
||||||
storeId: string;
|
storeId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 导入导出回执。 */
|
/** Excel 文件回执。 */
|
||||||
export interface ProductBatchImportExportResultDto {
|
export interface ProductBatchExcelFileDto {
|
||||||
exportedCount?: number;
|
|
||||||
failedCount: number;
|
failedCount: number;
|
||||||
|
fileContentBase64: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
skippedCount?: number;
|
|
||||||
successCount: number;
|
successCount: number;
|
||||||
totalCount: 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) {
|
export async function getProductCategoryListApi(storeId: string) {
|
||||||
return requestClient.get<ProductCategoryDto[]>('/product/category/list', {
|
return requestClient.get<ProductCategoryDto[]>('/product/category/list', {
|
||||||
@@ -911,9 +959,19 @@ export async function changeProductScheduleStatusApi(
|
|||||||
return requestClient.post('/product/schedule/status', data);
|
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(
|
export async function batchAdjustProductPriceApi(
|
||||||
data: ProductBatchAdjustPriceDto,
|
data: ProductBatchPriceAdjustDto,
|
||||||
) {
|
) {
|
||||||
return requestClient.post<ProductBatchToolResultDto>(
|
return requestClient.post<ProductBatchToolResultDto>(
|
||||||
'/product/batch/price-adjust',
|
'/product/batch/price-adjust',
|
||||||
@@ -950,16 +1008,29 @@ export async function batchSyncProductStoreApi(data: ProductBatchSyncStoreDto) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 批量导入。 */
|
/** 批量导入。 */
|
||||||
export async function batchImportProductApi(data: ProductBatchImportExportDto) {
|
export async function batchImportProductApi(data: ProductBatchImportDto) {
|
||||||
return requestClient.post<ProductBatchImportExportResultDto>(
|
const formData = new FormData();
|
||||||
|
formData.append('storeId', data.storeId);
|
||||||
|
formData.append('file', data.file);
|
||||||
|
return requestClient.post<ProductBatchImportResultDto>(
|
||||||
'/product/batch/import',
|
'/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) {
|
export async function batchExportProductApi(data: ProductBatchExportDto) {
|
||||||
return requestClient.post<ProductBatchImportExportResultDto>(
|
return requestClient.post<ProductBatchExcelFileDto>(
|
||||||
'/product/batch/export',
|
'/product/batch/export',
|
||||||
data,
|
data,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 },
|
||||||
|
];
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,522 +1,251 @@
|
|||||||
<script setup lang="ts">
|
<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 { Page } from '@vben/common-ui';
|
||||||
|
|
||||||
import {
|
import { Alert, Empty } from 'ant-design-vue';
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Empty,
|
|
||||||
InputNumber,
|
|
||||||
message,
|
|
||||||
Select,
|
|
||||||
Space,
|
|
||||||
Tabs,
|
|
||||||
} from 'ant-design-vue';
|
|
||||||
|
|
||||||
import {
|
import BatchImportExportPanel from './components/BatchImportExportPanel.vue';
|
||||||
batchAdjustProductPriceApi,
|
import BatchMoveCategoryPanel from './components/BatchMoveCategoryPanel.vue';
|
||||||
batchExportProductApi,
|
import BatchPriceAdjustPanel from './components/BatchPriceAdjustPanel.vue';
|
||||||
batchImportProductApi,
|
import BatchSaleSwitchPanel from './components/BatchSaleSwitchPanel.vue';
|
||||||
batchMoveProductCategoryApi,
|
import BatchStoreSyncPanel from './components/BatchStoreSyncPanel.vue';
|
||||||
batchSwitchProductSaleApi,
|
import BatchToolbar from './components/BatchToolbar.vue';
|
||||||
batchSyncProductStoreApi,
|
import BatchToolCards from './components/BatchToolCards.vue';
|
||||||
getProductCategoryManageListApi,
|
import { useProductBatchPage } from './composables/useProductBatchPage';
|
||||||
searchProductPickerApi,
|
|
||||||
} from '#/api/product';
|
|
||||||
import { getStoreListApi } from '#/api/store';
|
|
||||||
|
|
||||||
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 {
|
function updateScopeCategoryIds(value: string[]) {
|
||||||
label: string;
|
scopeCategoryIds.value = value;
|
||||||
value: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductOption {
|
function updateScopeProductIds(value: string[]) {
|
||||||
label: string;
|
scopeProductIds.value = value;
|
||||||
value: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const stores = ref<StoreListItemDto[]>([]);
|
function updatePriceDirection(value: 'down' | 'up') {
|
||||||
const selectedStoreId = ref('');
|
priceDirection.value = value;
|
||||||
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 updatePriceAmountType(value: 'fixed' | 'percent') {
|
||||||
async function reloadScopeData() {
|
priceAmountType.value = value;
|
||||||
if (!selectedStoreId.value) {
|
|
||||||
categoryOptions.value = [];
|
|
||||||
productOptions.value = [];
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isCategoryLoading.value = true;
|
function updatePriceAmount(value: number) {
|
||||||
isProductLoading.value = true;
|
priceAmount.value = value;
|
||||||
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 updateSaleAction(value: 'off' | 'on') {
|
||||||
function buildScope(): ProductBatchScopeDto {
|
saleAction.value = value;
|
||||||
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 updateMoveSourceCategoryId(value: string) {
|
||||||
function validateScope() {
|
moveSourceCategoryId.value = value;
|
||||||
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 updateMoveTargetCategoryId(value: string) {
|
||||||
async function submitPriceAdjust() {
|
moveTargetCategoryId.value = value;
|
||||||
if (!selectedStoreId.value) return;
|
|
||||||
if (!validateScope()) return;
|
|
||||||
if (priceAmount.value < 0) {
|
|
||||||
message.warning('调价金额不能小于 0');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isSubmitting.value = true;
|
function updateSyncTargetStoreIds(value: string[]) {
|
||||||
try {
|
syncTargetStoreIds.value = value;
|
||||||
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 updateSyncProductIds(value: string[]) {
|
||||||
async function submitSaleSwitch() {
|
syncProductIds.value = value;
|
||||||
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 updateSyncPrice(value: boolean) {
|
||||||
async function submitMoveCategory() {
|
syncPrice.value = value;
|
||||||
if (!selectedStoreId.value) return;
|
|
||||||
if (!validateScope()) return;
|
|
||||||
if (!moveTargetCategoryId.value) {
|
|
||||||
message.warning('请选择目标分类');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isSubmitting.value = true;
|
function updateSyncStock(value: boolean) {
|
||||||
try {
|
syncStock.value = value;
|
||||||
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 updateSyncStatus(value: boolean) {
|
||||||
async function submitSyncStore() {
|
syncStatus.value = value;
|
||||||
if (!selectedStoreId.value) return;
|
|
||||||
if (syncTargetStoreIds.value.length === 0) {
|
|
||||||
message.warning('请选择目标门店');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (syncProductIds.value.length === 0) {
|
|
||||||
message.warning('请选择要同步的商品');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isSubmitting.value = true;
|
function updateExportScopeType(value: 'all' | 'category') {
|
||||||
try {
|
exportScopeType.value = value;
|
||||||
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 updateExportCategoryIds(value: string[]) {
|
||||||
async function submitImport() {
|
exportCategoryIds.value = value;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** 批量导出。 */
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(selectedStoreId, () => {
|
|
||||||
scopeCategoryId.value = '';
|
|
||||||
scopeProductIds.value = [];
|
|
||||||
moveTargetCategoryId.value = '';
|
|
||||||
syncTargetStoreIds.value = [];
|
|
||||||
syncProductIds.value = [];
|
|
||||||
reloadScopeData();
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(loadStores);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Page title="批量工具" content-class="space-y-4 page-product-batch-tools">
|
<Page title="批量工具" content-class="page-product-batch">
|
||||||
<Card :bordered="false">
|
<div class="pbt-page">
|
||||||
<Space direction="vertical" style="width: 100%">
|
<BatchToolbar
|
||||||
<Space wrap>
|
:selected-store-id="selectedStoreId"
|
||||||
<Select
|
:store-options="storeOptions"
|
||||||
v-model:value="selectedStoreId"
|
:is-store-loading="isStoreLoading"
|
||||||
:options="storeOptions"
|
@update:selected-store-id="setSelectedStoreId"
|
||||||
:loading="isStoreLoading"
|
|
||||||
style="width: 240px"
|
|
||||||
placeholder="请选择门店"
|
|
||||||
/>
|
/>
|
||||||
<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="暂无门店,请先创建门店" />
|
<Empty description="暂无门店,请先创建门店" />
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<Card :bordered="false">
|
<BatchToolCards
|
||||||
<Tabs v-model:active-key="activeTab">
|
:cards="cards"
|
||||||
<Tabs.TabPane key="price" tab="批量调价">
|
:active-tool="activeTool"
|
||||||
<Space wrap>
|
@toggle="toggleTool"
|
||||||
<Select
|
|
||||||
v-model:value="priceMode"
|
|
||||||
style="width: 160px"
|
|
||||||
:options="[
|
|
||||||
{ label: '统一设价', value: 'set' },
|
|
||||||
{ label: '统一涨价', value: 'increase' },
|
|
||||||
{ label: '统一降价', value: 'decrease' },
|
|
||||||
]"
|
|
||||||
/>
|
/>
|
||||||
<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="批量上下架">
|
<BatchPriceAdjustPanel
|
||||||
<Space wrap>
|
:open="activeTool === 'price'"
|
||||||
<Select
|
:scope-type="scopeType"
|
||||||
v-model:value="saleAction"
|
:scope-category-ids="scopeCategoryIds"
|
||||||
style="width: 160px"
|
:scope-product-ids="scopeProductIds"
|
||||||
:options="[
|
:category-options="categoryOptions"
|
||||||
{ label: '批量上架', value: 'on' },
|
:product-options="productOptions"
|
||||||
{ label: '批量下架', value: 'off' },
|
: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="批量移动分类">
|
<BatchSaleSwitchPanel
|
||||||
<Space wrap>
|
:open="activeTool === 'sale'"
|
||||||
<Select
|
:scope-type="scopeType"
|
||||||
v-model:value="moveTargetCategoryId"
|
:scope-category-ids="scopeCategoryIds"
|
||||||
:options="categoryOptions"
|
:scope-product-ids="scopeProductIds"
|
||||||
:loading="isCategoryLoading"
|
:category-options="categoryOptions"
|
||||||
style="width: 220px"
|
:product-options="productOptions"
|
||||||
placeholder="目标分类"
|
: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="同步到其他门店">
|
<BatchMoveCategoryPanel
|
||||||
<Space direction="vertical" style="width: 100%">
|
:open="activeTool === 'category'"
|
||||||
<Space wrap>
|
:category-options="categoryOptions"
|
||||||
<Select
|
:source-category-id="moveSourceCategoryId"
|
||||||
v-model:value="syncTargetStoreIds"
|
:target-category-id="moveTargetCategoryId"
|
||||||
mode="multiple"
|
:estimated-count="moveEstimatedCount"
|
||||||
:options="targetStoreOptions"
|
:submitting="isSubmitting"
|
||||||
style="min-width: 300px"
|
@update:source-category-id="updateMoveSourceCategoryId"
|
||||||
placeholder="选择目标门店"
|
@update:target-category-id="updateMoveTargetCategoryId"
|
||||||
|
@submit="submitMoveCategory"
|
||||||
|
@close="closeTool"
|
||||||
/>
|
/>
|
||||||
</Space>
|
|
||||||
<Select
|
<BatchStoreSyncPanel
|
||||||
v-model:value="syncProductIds"
|
:open="activeTool === 'sync'"
|
||||||
mode="multiple"
|
:source-store-name="selectedStoreLabel"
|
||||||
:options="productOptions"
|
:target-store-ids="syncTargetStoreIds"
|
||||||
:loading="isProductLoading"
|
:target-store-options="targetStoreOptions"
|
||||||
placeholder="选择需要同步的商品"
|
: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="导入导出">
|
<BatchImportExportPanel
|
||||||
<Space wrap>
|
:open="activeTool === 'excel'"
|
||||||
<Button :loading="isSubmitting" @click="submitImport">
|
:selected-file-name="importFile?.name || ''"
|
||||||
导入商品
|
:export-scope-type="exportScopeType"
|
||||||
</Button>
|
:export-category-ids="exportCategoryIds"
|
||||||
<Button
|
:category-options="categoryOptions"
|
||||||
type="primary"
|
:submitting="isSubmitting"
|
||||||
:loading="isSubmitting"
|
@select-file="setImportFile"
|
||||||
@click="submitExport"
|
@update:export-scope-type="updateExportScopeType"
|
||||||
>
|
@update:export-category-ids="updateExportCategoryIds"
|
||||||
导出商品
|
@download-template="downloadImportTemplate"
|
||||||
</Button>
|
@submit-import="submitImport"
|
||||||
</Space>
|
@submit-export="submitExport"
|
||||||
</Tabs.TabPane>
|
@close="closeTool"
|
||||||
</Tabs>
|
/>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card v-if="latestResultText" :bordered="false">
|
|
||||||
<Alert type="success" show-icon :message="latestResultText" />
|
|
||||||
</Card>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
v-if="latestResultText"
|
||||||
|
class="pbt-result"
|
||||||
|
type="success"
|
||||||
|
show-icon
|
||||||
|
:message="latestResultText"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Page>
|
</Page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style lang="less">
|
||||||
/* 文件职责:批量工具页面样式。 */
|
@import './styles/index.less';
|
||||||
.page-product-batch-tools {
|
|
||||||
:deep(.ant-tabs-nav) {
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
5
apps/web-antd/src/views/product/batch/styles/base.less
Normal file
5
apps/web-antd/src/views/product/batch/styles/base.less
Normal 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);
|
||||||
|
}
|
||||||
73
apps/web-antd/src/views/product/batch/styles/card.less
Normal file
73
apps/web-antd/src/views/product/batch/styles/card.less
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
5
apps/web-antd/src/views/product/batch/styles/index.less
Normal file
5
apps/web-antd/src/views/product/batch/styles/index.less
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
@import './base.less';
|
||||||
|
@import './layout.less';
|
||||||
|
@import './card.less';
|
||||||
|
@import './panel.less';
|
||||||
|
@import './responsive.less';
|
||||||
51
apps/web-antd/src/views/product/batch/styles/layout.less
Normal file
51
apps/web-antd/src/views/product/batch/styles/layout.less
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
187
apps/web-antd/src/views/product/batch/styles/panel.less
Normal file
187
apps/web-antd/src/views/product/batch/styles/panel.less
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
apps/web-antd/src/views/product/batch/styles/responsive.less
Normal file
25
apps/web-antd/src/views/product/batch/styles/responsive.less
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
apps/web-antd/src/views/product/batch/types.ts
Normal file
89
apps/web-antd/src/views/product/batch/types.ts
Normal 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;
|
||||||
Reference in New Issue
Block a user