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