feat(project): sync all pending tenant ui changes
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 文件职责:商品管理模块 API 与 DTO 定义。
|
||||
* 1. 维护商品列表、分类、详情、批量操作契约。
|
||||
* 2. 提供商品查询、保存、状态变更、沽清与批量动作接口。
|
||||
* 2. 提供分类、规格、加料、标签、时段供应等扩展模块接口。
|
||||
*/
|
||||
import type { PaginatedResult } from '#/api/store';
|
||||
|
||||
@@ -16,7 +16,32 @@ export type ProductKind = 'combo' | 'single';
|
||||
/** 沽清模式。 */
|
||||
export type ProductSoldoutMode = 'permanent' | 'timed' | 'today';
|
||||
|
||||
/** 分类信息。 */
|
||||
/** 分类展示渠道。 */
|
||||
export type ProductCategoryChannel = 'dine_in' | 'pickup' | 'wm';
|
||||
|
||||
/** 通用启停状态。 */
|
||||
export type ProductSwitchStatus = 'disabled' | 'enabled';
|
||||
|
||||
/** 商品选择器项。 */
|
||||
export interface ProductPickerItemDto {
|
||||
categoryId: string;
|
||||
categoryName: string;
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
spuCode: string;
|
||||
status: ProductStatus;
|
||||
}
|
||||
|
||||
/** 商品选择器查询参数。 */
|
||||
export interface ProductPickerQuery {
|
||||
categoryId?: string;
|
||||
keyword?: string;
|
||||
limit?: number;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 分类信息(列表页侧栏)。 */
|
||||
export interface ProductCategoryDto {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -24,6 +49,72 @@ export interface ProductCategoryDto {
|
||||
sort: number;
|
||||
}
|
||||
|
||||
/** 分类管理项。 */
|
||||
export interface ProductCategoryManageDto extends ProductCategoryDto {
|
||||
channels: ProductCategoryChannel[];
|
||||
description: string;
|
||||
icon: string;
|
||||
status: ProductSwitchStatus;
|
||||
}
|
||||
|
||||
/** 分类管理查询参数。 */
|
||||
export interface ProductCategoryManageQuery {
|
||||
keyword?: string;
|
||||
status?: ProductSwitchStatus;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 保存分类参数。 */
|
||||
export interface SaveProductCategoryDto {
|
||||
channels: ProductCategoryChannel[];
|
||||
description: string;
|
||||
icon: string;
|
||||
id?: string;
|
||||
name: string;
|
||||
sort: number;
|
||||
status: ProductSwitchStatus;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 删除分类参数。 */
|
||||
export interface DeleteProductCategoryDto {
|
||||
categoryId: string;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 分类状态变更参数。 */
|
||||
export interface ChangeProductCategoryStatusDto {
|
||||
categoryId: string;
|
||||
status: ProductSwitchStatus;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 分类排序项。 */
|
||||
export interface ProductCategorySortItemDto {
|
||||
categoryId: string;
|
||||
sort: number;
|
||||
}
|
||||
|
||||
/** 分类排序参数。 */
|
||||
export interface SortProductCategoryDto {
|
||||
items: ProductCategorySortItemDto[];
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 分类绑定商品参数。 */
|
||||
export interface BindCategoryProductsDto {
|
||||
categoryId: string;
|
||||
productIds: string[];
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 分类解绑商品参数。 */
|
||||
export interface UnbindCategoryProductDto {
|
||||
categoryId: string;
|
||||
productId: string;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 商品列表项。 */
|
||||
export interface ProductListItemDto {
|
||||
categoryId: string;
|
||||
@@ -141,13 +232,346 @@ export interface BatchProductActionResultDto {
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
/** 获取商品分类。 */
|
||||
/** 规格值。 */
|
||||
export interface ProductSpecValueDto {
|
||||
extraPrice: number;
|
||||
id: string;
|
||||
name: string;
|
||||
sort: number;
|
||||
}
|
||||
|
||||
/** 规格配置。 */
|
||||
export interface ProductSpecDto {
|
||||
description: string;
|
||||
id: string;
|
||||
name: string;
|
||||
productCount: number;
|
||||
productIds: string[];
|
||||
sort: number;
|
||||
status: ProductSwitchStatus;
|
||||
updatedAt: string;
|
||||
values: ProductSpecValueDto[];
|
||||
}
|
||||
|
||||
/** 规格查询参数。 */
|
||||
export interface ProductSpecQuery {
|
||||
keyword?: string;
|
||||
status?: ProductSwitchStatus;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 保存规格参数。 */
|
||||
export interface SaveProductSpecDto {
|
||||
description: string;
|
||||
id?: string;
|
||||
name: string;
|
||||
productIds: string[];
|
||||
sort: number;
|
||||
status: ProductSwitchStatus;
|
||||
storeId: string;
|
||||
values: Array<{
|
||||
extraPrice: number;
|
||||
id?: string;
|
||||
name: string;
|
||||
sort: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** 删除规格参数。 */
|
||||
export interface DeleteProductSpecDto {
|
||||
specId: string;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 规格状态变更参数。 */
|
||||
export interface ChangeProductSpecStatusDto {
|
||||
specId: string;
|
||||
status: ProductSwitchStatus;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 加料项。 */
|
||||
export interface ProductAddonItemDto {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
sort: number;
|
||||
status: ProductSwitchStatus;
|
||||
}
|
||||
|
||||
/** 加料组。 */
|
||||
export interface ProductAddonGroupDto {
|
||||
description: string;
|
||||
id: string;
|
||||
items: ProductAddonItemDto[];
|
||||
maxSelect: number;
|
||||
minSelect: number;
|
||||
name: string;
|
||||
productCount: number;
|
||||
productIds: string[];
|
||||
required: boolean;
|
||||
sort: number;
|
||||
status: ProductSwitchStatus;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 加料组查询参数。 */
|
||||
export interface ProductAddonQuery {
|
||||
keyword?: string;
|
||||
status?: ProductSwitchStatus;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 保存加料组参数。 */
|
||||
export interface SaveProductAddonGroupDto {
|
||||
description: string;
|
||||
id?: string;
|
||||
items: Array<{
|
||||
id?: string;
|
||||
name: string;
|
||||
price: number;
|
||||
sort: number;
|
||||
status: ProductSwitchStatus;
|
||||
}>;
|
||||
maxSelect: number;
|
||||
minSelect: number;
|
||||
name: string;
|
||||
productIds: string[];
|
||||
required: boolean;
|
||||
sort: number;
|
||||
status: ProductSwitchStatus;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 删除加料组参数。 */
|
||||
export interface DeleteProductAddonGroupDto {
|
||||
groupId: string;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 加料组状态变更参数。 */
|
||||
export interface ChangeProductAddonGroupStatusDto {
|
||||
groupId: string;
|
||||
status: ProductSwitchStatus;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 商品标签。 */
|
||||
export interface ProductLabelDto {
|
||||
color: string;
|
||||
description: string;
|
||||
id: string;
|
||||
name: string;
|
||||
productCount: number;
|
||||
productIds: string[];
|
||||
sort: number;
|
||||
status: ProductSwitchStatus;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 标签查询参数。 */
|
||||
export interface ProductLabelQuery {
|
||||
keyword?: string;
|
||||
status?: ProductSwitchStatus;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 保存标签参数。 */
|
||||
export interface SaveProductLabelDto {
|
||||
color: string;
|
||||
description: string;
|
||||
id?: string;
|
||||
name: string;
|
||||
productIds: string[];
|
||||
sort: number;
|
||||
status: ProductSwitchStatus;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 删除标签参数。 */
|
||||
export interface DeleteProductLabelDto {
|
||||
labelId: string;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 标签状态变更参数。 */
|
||||
export interface ChangeProductLabelStatusDto {
|
||||
labelId: string;
|
||||
status: ProductSwitchStatus;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 时段条目。 */
|
||||
export interface ProductScheduleSlotDto {
|
||||
endTime: string;
|
||||
id: string;
|
||||
startTime: string;
|
||||
weekDays: number[];
|
||||
}
|
||||
|
||||
/** 时段模板。 */
|
||||
export interface ProductScheduleDto {
|
||||
description: string;
|
||||
id: string;
|
||||
name: string;
|
||||
productCount: number;
|
||||
productIds: string[];
|
||||
slots: ProductScheduleSlotDto[];
|
||||
sort: number;
|
||||
status: ProductSwitchStatus;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 时段查询参数。 */
|
||||
export interface ProductScheduleQuery {
|
||||
keyword?: string;
|
||||
status?: ProductSwitchStatus;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 保存时段参数。 */
|
||||
export interface SaveProductScheduleDto {
|
||||
description: string;
|
||||
id?: string;
|
||||
name: string;
|
||||
productIds: string[];
|
||||
slots: Array<{
|
||||
endTime: string;
|
||||
id?: string;
|
||||
startTime: string;
|
||||
weekDays: number[];
|
||||
}>;
|
||||
sort: number;
|
||||
status: ProductSwitchStatus;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 删除时段参数。 */
|
||||
export interface DeleteProductScheduleDto {
|
||||
scheduleId: string;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 时段状态变更参数。 */
|
||||
export interface ChangeProductScheduleStatusDto {
|
||||
scheduleId: string;
|
||||
status: ProductSwitchStatus;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 批量范围。 */
|
||||
export interface ProductBatchScopeDto {
|
||||
categoryId?: string;
|
||||
productIds?: string[];
|
||||
type: 'all' | 'category' | 'selected';
|
||||
}
|
||||
|
||||
/** 批量调价参数。 */
|
||||
export interface ProductBatchAdjustPriceDto {
|
||||
amount: number;
|
||||
mode: 'decrease' | 'increase' | 'set';
|
||||
scope: ProductBatchScopeDto;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 批量移动分类参数。 */
|
||||
export interface ProductBatchMoveCategoryDto {
|
||||
scope: ProductBatchScopeDto;
|
||||
storeId: string;
|
||||
targetCategoryId: string;
|
||||
}
|
||||
|
||||
/** 批量上下架参数。 */
|
||||
export interface ProductBatchSaleSwitchDto {
|
||||
action: 'off' | 'on';
|
||||
scope: ProductBatchScopeDto;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 批量同步门店参数。 */
|
||||
export interface ProductBatchSyncStoreDto {
|
||||
productIds: string[];
|
||||
sourceStoreId: string;
|
||||
targetStoreIds: string[];
|
||||
}
|
||||
|
||||
/** 批量工具通用结果。 */
|
||||
export interface ProductBatchToolResultDto {
|
||||
failedCount: number;
|
||||
successCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
/** 导入导出请求参数。 */
|
||||
export interface ProductBatchImportExportDto {
|
||||
scope: ProductBatchScopeDto;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
/** 导入导出回执。 */
|
||||
export interface ProductBatchImportExportResultDto {
|
||||
exportedCount?: number;
|
||||
failedCount: number;
|
||||
fileName: string;
|
||||
skippedCount?: number;
|
||||
successCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
/** 获取商品分类(侧栏口径)。 */
|
||||
export async function getProductCategoryListApi(storeId: string) {
|
||||
return requestClient.get<ProductCategoryDto[]>('/product/category/list', {
|
||||
params: { storeId },
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取分类管理列表。 */
|
||||
export async function getProductCategoryManageListApi(
|
||||
params: ProductCategoryManageQuery,
|
||||
) {
|
||||
return requestClient.get<ProductCategoryManageDto[]>(
|
||||
'/product/category/manage/list',
|
||||
{
|
||||
params,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 保存分类。 */
|
||||
export async function saveProductCategoryApi(data: SaveProductCategoryDto) {
|
||||
return requestClient.post<ProductCategoryManageDto>(
|
||||
'/product/category/manage/save',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** 删除分类。 */
|
||||
export async function deleteProductCategoryApi(data: DeleteProductCategoryDto) {
|
||||
return requestClient.post('/product/category/manage/delete', data);
|
||||
}
|
||||
|
||||
/** 修改分类状态。 */
|
||||
export async function changeProductCategoryStatusApi(
|
||||
data: ChangeProductCategoryStatusDto,
|
||||
) {
|
||||
return requestClient.post('/product/category/manage/status', data);
|
||||
}
|
||||
|
||||
/** 批量排序分类。 */
|
||||
export async function sortProductCategoryApi(data: SortProductCategoryDto) {
|
||||
return requestClient.post('/product/category/manage/sort', data);
|
||||
}
|
||||
|
||||
/** 将商品绑定到分类。 */
|
||||
export async function bindCategoryProductsApi(data: BindCategoryProductsDto) {
|
||||
return requestClient.post('/product/category/manage/products/bind', data);
|
||||
}
|
||||
|
||||
/** 从分类解绑单个商品。 */
|
||||
export async function unbindCategoryProductApi(data: UnbindCategoryProductDto) {
|
||||
return requestClient.post('/product/category/manage/products/unbind', data);
|
||||
}
|
||||
|
||||
/** 获取商品列表。 */
|
||||
export async function getProductListApi(params: ProductListQuery) {
|
||||
return requestClient.get<PaginatedResult<ProductListItemDto>>(
|
||||
@@ -185,10 +609,175 @@ export async function soldoutProductApi(data: SoldoutProductDto) {
|
||||
return requestClient.post('/product/soldout', data);
|
||||
}
|
||||
|
||||
/** 批量商品操作。 */
|
||||
/** 批量商品操作(列表页)。 */
|
||||
export async function batchProductActionApi(data: BatchProductActionDto) {
|
||||
return requestClient.post<BatchProductActionResultDto>(
|
||||
'/product/batch',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取规格列表。 */
|
||||
export async function getProductSpecListApi(params: ProductSpecQuery) {
|
||||
return requestClient.get<ProductSpecDto[]>('/product/spec/list', {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/** 保存规格。 */
|
||||
export async function saveProductSpecApi(data: SaveProductSpecDto) {
|
||||
return requestClient.post<ProductSpecDto>('/product/spec/save', data);
|
||||
}
|
||||
|
||||
/** 删除规格。 */
|
||||
export async function deleteProductSpecApi(data: DeleteProductSpecDto) {
|
||||
return requestClient.post('/product/spec/delete', data);
|
||||
}
|
||||
|
||||
/** 修改规格状态。 */
|
||||
export async function changeProductSpecStatusApi(
|
||||
data: ChangeProductSpecStatusDto,
|
||||
) {
|
||||
return requestClient.post('/product/spec/status', data);
|
||||
}
|
||||
|
||||
/** 获取加料组列表。 */
|
||||
export async function getProductAddonGroupListApi(params: ProductAddonQuery) {
|
||||
return requestClient.get<ProductAddonGroupDto[]>(
|
||||
'/product/addon/group/list',
|
||||
{
|
||||
params,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 保存加料组。 */
|
||||
export async function saveProductAddonGroupApi(data: SaveProductAddonGroupDto) {
|
||||
return requestClient.post<ProductAddonGroupDto>(
|
||||
'/product/addon/group/save',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** 删除加料组。 */
|
||||
export async function deleteProductAddonGroupApi(
|
||||
data: DeleteProductAddonGroupDto,
|
||||
) {
|
||||
return requestClient.post('/product/addon/group/delete', data);
|
||||
}
|
||||
|
||||
/** 修改加料组状态。 */
|
||||
export async function changeProductAddonGroupStatusApi(
|
||||
data: ChangeProductAddonGroupStatusDto,
|
||||
) {
|
||||
return requestClient.post('/product/addon/group/status', data);
|
||||
}
|
||||
|
||||
/** 获取标签列表。 */
|
||||
export async function getProductLabelListApi(params: ProductLabelQuery) {
|
||||
return requestClient.get<ProductLabelDto[]>('/product/label/list', {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/** 保存标签。 */
|
||||
export async function saveProductLabelApi(data: SaveProductLabelDto) {
|
||||
return requestClient.post<ProductLabelDto>('/product/label/save', data);
|
||||
}
|
||||
|
||||
/** 删除标签。 */
|
||||
export async function deleteProductLabelApi(data: DeleteProductLabelDto) {
|
||||
return requestClient.post('/product/label/delete', data);
|
||||
}
|
||||
|
||||
/** 修改标签状态。 */
|
||||
export async function changeProductLabelStatusApi(
|
||||
data: ChangeProductLabelStatusDto,
|
||||
) {
|
||||
return requestClient.post('/product/label/status', data);
|
||||
}
|
||||
|
||||
/** 获取时段列表。 */
|
||||
export async function getProductScheduleListApi(params: ProductScheduleQuery) {
|
||||
return requestClient.get<ProductScheduleDto[]>('/product/schedule/list', {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/** 保存时段。 */
|
||||
export async function saveProductScheduleApi(data: SaveProductScheduleDto) {
|
||||
return requestClient.post<ProductScheduleDto>('/product/schedule/save', data);
|
||||
}
|
||||
|
||||
/** 删除时段。 */
|
||||
export async function deleteProductScheduleApi(data: DeleteProductScheduleDto) {
|
||||
return requestClient.post('/product/schedule/delete', data);
|
||||
}
|
||||
|
||||
/** 修改时段状态。 */
|
||||
export async function changeProductScheduleStatusApi(
|
||||
data: ChangeProductScheduleStatusDto,
|
||||
) {
|
||||
return requestClient.post('/product/schedule/status', data);
|
||||
}
|
||||
|
||||
/** 批量调价。 */
|
||||
export async function batchAdjustProductPriceApi(
|
||||
data: ProductBatchAdjustPriceDto,
|
||||
) {
|
||||
return requestClient.post<ProductBatchToolResultDto>(
|
||||
'/product/batch/price-adjust',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** 批量移动分类。 */
|
||||
export async function batchMoveProductCategoryApi(
|
||||
data: ProductBatchMoveCategoryDto,
|
||||
) {
|
||||
return requestClient.post<ProductBatchToolResultDto>(
|
||||
'/product/batch/move-category',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** 批量上下架。 */
|
||||
export async function batchSwitchProductSaleApi(
|
||||
data: ProductBatchSaleSwitchDto,
|
||||
) {
|
||||
return requestClient.post<ProductBatchToolResultDto>(
|
||||
'/product/batch/sale-switch',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** 批量同步门店。 */
|
||||
export async function batchSyncProductStoreApi(data: ProductBatchSyncStoreDto) {
|
||||
return requestClient.post<ProductBatchToolResultDto>(
|
||||
'/product/batch/store-sync',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** 批量导入。 */
|
||||
export async function batchImportProductApi(data: ProductBatchImportExportDto) {
|
||||
return requestClient.post<ProductBatchImportExportResultDto>(
|
||||
'/product/batch/import',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** 批量导出。 */
|
||||
export async function batchExportProductApi(data: ProductBatchImportExportDto) {
|
||||
return requestClient.post<ProductBatchImportExportResultDto>(
|
||||
'/product/batch/export',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** 商品选择器列表。 */
|
||||
export async function searchProductPickerApi(params: ProductPickerQuery) {
|
||||
return requestClient.get<ProductPickerItemDto[]>('/product/picker/list', {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Mock 数据入口,仅在开发环境下使用
|
||||
// 门店模块已切换真实 TenantApi,此处仅保留其他业务的 mock。
|
||||
import './product';
|
||||
import './product-extensions';
|
||||
|
||||
console.warn('[Mock] 非门店模块 Mock 数据已启用');
|
||||
|
||||
1625
apps/web-antd/src/mock/product-extensions.ts
Normal file
1625
apps/web-antd/src/mock/product-extensions.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,60 @@ const routes: RouteRecordRaw[] = [
|
||||
title: '商品列表',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ProductCategory',
|
||||
path: '/product/category',
|
||||
component: () => import('#/views/product/category/index.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:folders',
|
||||
title: '分类管理',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ProductSpecs',
|
||||
path: '/product/specs',
|
||||
component: () => import('#/views/product/specs/index.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:sliders-horizontal',
|
||||
title: '规格做法',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ProductAddons',
|
||||
path: '/product/addons',
|
||||
component: () => import('#/views/product/addons/index.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:plus-square',
|
||||
title: '加料管理',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ProductLabels',
|
||||
path: '/product/labels',
|
||||
component: () => import('#/views/product/labels/index.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:tags',
|
||||
title: '商品标签',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ProductSchedule',
|
||||
path: '/product/schedule',
|
||||
component: () => import('#/views/product/schedule/index.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:calendar-clock',
|
||||
title: '时段供应',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ProductBatchTools',
|
||||
path: '/product/batch',
|
||||
component: () => import('#/views/product/batch/index.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:wand-sparkles',
|
||||
title: '批量工具',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ProductDetail',
|
||||
path: '/product/detail',
|
||||
|
||||
564
apps/web-antd/src/views/product/addons/index.vue
Normal file
564
apps/web-antd/src/views/product/addons/index.vue
Normal file
@@ -0,0 +1,564 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:加料管理页面。
|
||||
* 1. 管理加料组、加料项配置。
|
||||
* 2. 管理加料组与商品关联。
|
||||
*/
|
||||
import type { ProductSwitchStatus } from '#/api/product';
|
||||
import type { StoreListItemDto } from '#/api/store';
|
||||
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Drawer,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
changeProductAddonGroupStatusApi,
|
||||
deleteProductAddonGroupApi,
|
||||
getProductAddonGroupListApi,
|
||||
saveProductAddonGroupApi,
|
||||
searchProductPickerApi,
|
||||
} from '#/api/product';
|
||||
import { getStoreListApi } from '#/api/store';
|
||||
|
||||
type StatusFilter = '' | ProductSwitchStatus;
|
||||
|
||||
interface AddonItemForm {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
sort: number;
|
||||
status: ProductSwitchStatus;
|
||||
}
|
||||
|
||||
interface AddonGroupRow {
|
||||
description: string;
|
||||
id: string;
|
||||
items: AddonItemForm[];
|
||||
maxSelect: number;
|
||||
minSelect: number;
|
||||
name: string;
|
||||
productCount: number;
|
||||
productIds: string[];
|
||||
required: boolean;
|
||||
sort: number;
|
||||
status: ProductSwitchStatus;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const stores = ref<StoreListItemDto[]>([]);
|
||||
const selectedStoreId = ref('');
|
||||
const isStoreLoading = ref(false);
|
||||
|
||||
const rows = ref<AddonGroupRow[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const keyword = ref('');
|
||||
const statusFilter = ref<StatusFilter>('');
|
||||
|
||||
const pickerOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||
const isDrawerOpen = ref(false);
|
||||
const isDrawerSubmitting = ref(false);
|
||||
const editingGroupId = ref('');
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
required: false,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
sort: 1,
|
||||
status: 'enabled' as ProductSwitchStatus,
|
||||
productIds: [] as string[],
|
||||
items: [] as AddonItemForm[],
|
||||
});
|
||||
|
||||
const storeOptions = computed(() =>
|
||||
stores.value.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})),
|
||||
);
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部状态', value: '' },
|
||||
{ label: '启用', value: 'enabled' },
|
||||
{ label: '停用', value: 'disabled' },
|
||||
];
|
||||
|
||||
const drawerTitle = computed(() =>
|
||||
editingGroupId.value ? '编辑加料组' : '新增加料组',
|
||||
);
|
||||
|
||||
/** 控制抽屉开关。 */
|
||||
function setDrawerOpen(value: boolean) {
|
||||
isDrawerOpen.value = value;
|
||||
}
|
||||
|
||||
/** 加载门店列表。 */
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载加料组列表。 */
|
||||
async function loadAddonGroups() {
|
||||
if (!selectedStoreId.value) {
|
||||
rows.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const result = await getProductAddonGroupListApi({
|
||||
storeId: selectedStoreId.value,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
status: (statusFilter.value || undefined) as
|
||||
| ProductSwitchStatus
|
||||
| undefined,
|
||||
});
|
||||
rows.value = result as AddonGroupRow[];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
rows.value = [];
|
||||
message.error('加载加料组失败');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载商品选择器。 */
|
||||
async function loadPickerOptions() {
|
||||
if (!selectedStoreId.value) {
|
||||
pickerOptions.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const list = await searchProductPickerApi({
|
||||
storeId: selectedStoreId.value,
|
||||
limit: 500,
|
||||
});
|
||||
pickerOptions.value = list.map((item) => ({
|
||||
label: `${item.name}(${item.spuCode})`,
|
||||
value: item.id,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
pickerOptions.value = [];
|
||||
message.error('加载商品失败');
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置表单。 */
|
||||
function resetForm() {
|
||||
editingGroupId.value = '';
|
||||
form.name = '';
|
||||
form.description = '';
|
||||
form.required = false;
|
||||
form.minSelect = 0;
|
||||
form.maxSelect = 1;
|
||||
form.sort = rows.value.length + 1;
|
||||
form.status = 'enabled';
|
||||
form.productIds = [];
|
||||
form.items = [
|
||||
{
|
||||
id: '',
|
||||
name: '',
|
||||
price: 0,
|
||||
sort: 1,
|
||||
status: 'enabled',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 打开新增抽屉。 */
|
||||
async function openCreateDrawer() {
|
||||
resetForm();
|
||||
await loadPickerOptions();
|
||||
isDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
/** 打开编辑抽屉。 */
|
||||
async function openEditDrawer(row: AddonGroupRow) {
|
||||
editingGroupId.value = row.id;
|
||||
form.name = row.name;
|
||||
form.description = row.description;
|
||||
form.required = row.required;
|
||||
form.minSelect = row.minSelect;
|
||||
form.maxSelect = row.maxSelect;
|
||||
form.sort = row.sort;
|
||||
form.status = row.status;
|
||||
form.productIds = [...row.productIds];
|
||||
form.items = row.items.map((item) => ({ ...item }));
|
||||
if (form.items.length === 0) {
|
||||
form.items = [{ id: '', name: '', price: 0, sort: 1, status: 'enabled' }];
|
||||
}
|
||||
await loadPickerOptions();
|
||||
isDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
/** 添加加料项。 */
|
||||
function addAddonItem() {
|
||||
form.items.push({
|
||||
id: '',
|
||||
name: '',
|
||||
price: 0,
|
||||
sort: form.items.length + 1,
|
||||
status: 'enabled',
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除加料项。 */
|
||||
function removeAddonItem(index: number) {
|
||||
if (form.items.length <= 1) {
|
||||
message.warning('至少保留一个加料项');
|
||||
return;
|
||||
}
|
||||
form.items.splice(index, 1);
|
||||
form.items.forEach((item, idx) => {
|
||||
item.sort = idx + 1;
|
||||
});
|
||||
}
|
||||
|
||||
/** 保存加料组。 */
|
||||
async function submitDrawer() {
|
||||
if (!selectedStoreId.value) return;
|
||||
if (!form.name.trim()) {
|
||||
message.warning('请输入加料组名称');
|
||||
return;
|
||||
}
|
||||
if (form.items.some((item) => !item.name.trim())) {
|
||||
message.warning('加料项名称不能为空');
|
||||
return;
|
||||
}
|
||||
if (form.maxSelect < form.minSelect) {
|
||||
message.warning('最大可选数量不能小于最小可选数量');
|
||||
return;
|
||||
}
|
||||
|
||||
isDrawerSubmitting.value = true;
|
||||
try {
|
||||
await saveProductAddonGroupApi({
|
||||
storeId: selectedStoreId.value,
|
||||
id: editingGroupId.value || undefined,
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
required: form.required,
|
||||
minSelect: form.minSelect,
|
||||
maxSelect: form.maxSelect,
|
||||
sort: form.sort,
|
||||
status: form.status,
|
||||
productIds: [...form.productIds],
|
||||
items: form.items.map((item) => ({
|
||||
id: item.id || undefined,
|
||||
name: item.name.trim(),
|
||||
price: item.price,
|
||||
sort: item.sort,
|
||||
status: item.status,
|
||||
})),
|
||||
});
|
||||
message.success(editingGroupId.value ? '加料组已更新' : '加料组已创建');
|
||||
isDrawerOpen.value = false;
|
||||
await loadAddonGroups();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
isDrawerSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除加料组。 */
|
||||
function removeAddonGroup(row: AddonGroupRow) {
|
||||
if (!selectedStoreId.value) return;
|
||||
Modal.confirm({
|
||||
title: `确认删除加料组「${row.name}」吗?`,
|
||||
async onOk() {
|
||||
await deleteProductAddonGroupApi({
|
||||
storeId: selectedStoreId.value,
|
||||
groupId: row.id,
|
||||
});
|
||||
message.success('加料组已删除');
|
||||
await loadAddonGroups();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 切换加料组状态。 */
|
||||
async function toggleAddonStatus(row: AddonGroupRow, checked: boolean) {
|
||||
if (!selectedStoreId.value) return;
|
||||
await changeProductAddonGroupStatusApi({
|
||||
storeId: selectedStoreId.value,
|
||||
groupId: row.id,
|
||||
status: checked ? 'enabled' : 'disabled',
|
||||
});
|
||||
row.status = checked ? 'enabled' : 'disabled';
|
||||
message.success('状态已更新');
|
||||
}
|
||||
|
||||
/** 重置筛选。 */
|
||||
function resetFilters() {
|
||||
keyword.value = '';
|
||||
statusFilter.value = '';
|
||||
loadAddonGroups();
|
||||
}
|
||||
|
||||
watch(selectedStoreId, loadAddonGroups);
|
||||
|
||||
onMounted(loadStores);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page title="加料管理" content-class="space-y-4 page-product-addons">
|
||||
<Card :bordered="false">
|
||||
<Space wrap>
|
||||
<Select
|
||||
v-model:value="selectedStoreId"
|
||||
:options="storeOptions"
|
||||
:loading="isStoreLoading"
|
||||
style="width: 240px"
|
||||
placeholder="请选择门店"
|
||||
/>
|
||||
<Input
|
||||
v-model:value="keyword"
|
||||
style="width: 220px"
|
||||
placeholder="搜索加料组名称"
|
||||
/>
|
||||
<Select
|
||||
v-model:value="statusFilter"
|
||||
:options="statusOptions"
|
||||
style="width: 140px"
|
||||
/>
|
||||
<Button type="primary" @click="loadAddonGroups">查询</Button>
|
||||
<Button @click="resetFilters">重置</Button>
|
||||
<Button type="primary" @click="openCreateDrawer">新增加料组</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card v-if="!selectedStoreId" :bordered="false">
|
||||
<Empty description="暂无门店,请先创建门店" />
|
||||
</Card>
|
||||
|
||||
<Card v-else :bordered="false">
|
||||
<Table
|
||||
row-key="id"
|
||||
:data-source="rows"
|
||||
:loading="isLoading"
|
||||
:pagination="false"
|
||||
size="middle"
|
||||
>
|
||||
<Table.Column
|
||||
title="加料组名称"
|
||||
data-index="name"
|
||||
key="name"
|
||||
:width="180"
|
||||
/>
|
||||
<Table.Column title="加料项" key="items">
|
||||
<template #default="{ record }">
|
||||
<Space wrap size="small">
|
||||
<Tag v-for="item in record.items" :key="item.id || item.name">
|
||||
{{ item.name }}(+{{ item.price }})
|
||||
</Tag>
|
||||
</Space>
|
||||
</template>
|
||||
</Table.Column>
|
||||
<Table.Column title="选择规则" key="rule" :width="160">
|
||||
<template #default="{ record }">
|
||||
{{ record.required ? '必选' : '非必选' }},{{ record.minSelect }}~{{
|
||||
record.maxSelect
|
||||
}}
|
||||
</template>
|
||||
</Table.Column>
|
||||
<Table.Column
|
||||
title="关联商品数"
|
||||
data-index="productCount"
|
||||
key="productCount"
|
||||
:width="100"
|
||||
/>
|
||||
<Table.Column title="状态" key="status" :width="90">
|
||||
<template #default="{ record }">
|
||||
<Switch
|
||||
:checked="record.status === 'enabled'"
|
||||
size="small"
|
||||
@change="(checked) => toggleAddonStatus(record, checked === true)"
|
||||
/>
|
||||
</template>
|
||||
</Table.Column>
|
||||
<Table.Column
|
||||
title="更新时间"
|
||||
data-index="updatedAt"
|
||||
key="updatedAt"
|
||||
:width="170"
|
||||
/>
|
||||
<Table.Column title="操作" key="action" :width="170">
|
||||
<template #default="{ record }">
|
||||
<Space size="small">
|
||||
<Button size="small" @click="openEditDrawer(record)">编辑</Button>
|
||||
<Button danger size="small" @click="removeAddonGroup(record)">
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</Table.Column>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
:open="isDrawerOpen"
|
||||
:title="drawerTitle"
|
||||
width="620"
|
||||
:destroy-on-close="true"
|
||||
@update:open="setDrawerOpen"
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="加料组名称" required>
|
||||
<Input v-model:value="form.name" :maxlength="30" show-count />
|
||||
</Form.Item>
|
||||
<Form.Item label="描述">
|
||||
<Input v-model:value="form.description" :maxlength="100" show-count />
|
||||
</Form.Item>
|
||||
<Form.Item label="关联商品">
|
||||
<Select
|
||||
v-model:value="form.productIds"
|
||||
mode="multiple"
|
||||
:options="pickerOptions"
|
||||
placeholder="请选择商品"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="是否必选">
|
||||
<Switch v-model:checked="form.required" />
|
||||
</Form.Item>
|
||||
|
||||
<Space style="display: flex; width: 100%">
|
||||
<Form.Item label="最少可选" style="flex: 1">
|
||||
<InputNumber
|
||||
v-model:value="form.minSelect"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="最多可选" style="flex: 1">
|
||||
<InputNumber
|
||||
v-model:value="form.maxSelect"
|
||||
:min="1"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Divider orientation="left">加料项</Divider>
|
||||
<div
|
||||
v-for="(item, index) in form.items"
|
||||
:key="index"
|
||||
class="addon-item-row"
|
||||
>
|
||||
<Input v-model:value="item.name" placeholder="加料项名称,如:珍珠" />
|
||||
<InputNumber
|
||||
v-model:value="item.price"
|
||||
:min="0"
|
||||
:step="0.5"
|
||||
style="width: 120px"
|
||||
placeholder="加价"
|
||||
/>
|
||||
<Select
|
||||
v-model:value="item.status"
|
||||
style="width: 110px"
|
||||
:options="[
|
||||
{ label: '启用', value: 'enabled' },
|
||||
{ label: '停用', value: 'disabled' },
|
||||
]"
|
||||
/>
|
||||
<Button danger @click="removeAddonItem(index)">删除</Button>
|
||||
</div>
|
||||
<Button @click="addAddonItem">新增加料项</Button>
|
||||
|
||||
<Divider />
|
||||
<Space style="display: flex; width: 100%">
|
||||
<Form.Item label="排序" style="flex: 1">
|
||||
<InputNumber
|
||||
v-model:value="form.sort"
|
||||
:min="1"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" style="flex: 1">
|
||||
<Select
|
||||
v-model:value="form.status"
|
||||
:options="[
|
||||
{ label: '启用', value: 'enabled' },
|
||||
{ label: '停用', value: 'disabled' },
|
||||
]"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form>
|
||||
|
||||
<template #footer>
|
||||
<Space>
|
||||
<Button @click="setDrawerOpen(false)">取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="isDrawerSubmitting"
|
||||
@click="submitDrawer"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</Drawer>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
/* 文件职责:加料管理页面样式。 */
|
||||
.page-product-addons {
|
||||
.addon-item-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-table-cell) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
522
apps/web-antd/src/views/product/batch/index.vue
Normal file
522
apps/web-antd/src/views/product/batch/index.vue
Normal file
@@ -0,0 +1,522 @@
|
||||
<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 {
|
||||
batchAdjustProductPriceApi,
|
||||
batchExportProductApi,
|
||||
batchImportProductApi,
|
||||
batchMoveProductCategoryApi,
|
||||
batchSwitchProductSaleApi,
|
||||
batchSyncProductStoreApi,
|
||||
getProductCategoryManageListApi,
|
||||
searchProductPickerApi,
|
||||
} from '#/api/product';
|
||||
import { getStoreListApi } from '#/api/store';
|
||||
|
||||
type ScopeType = 'all' | 'category' | 'selected';
|
||||
|
||||
interface CategoryOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ProductOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载分类与商品选择器数据。 */
|
||||
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 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 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;
|
||||
}
|
||||
|
||||
/** 批量调价。 */
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量上下架。 */
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量移动分类。 */
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量同步门店。 */
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量导入。 */
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量导出。 */
|
||||
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>
|
||||
|
||||
<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 回执。"
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card v-if="!selectedStoreId" :bordered="false">
|
||||
<Empty description="暂无门店,请先创建门店" />
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
/* 文件职责:批量工具页面样式。 */
|
||||
.page-product-batch-tools {
|
||||
:deep(.ant-tabs-nav) {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,207 @@
|
||||
<script setup lang="ts">
|
||||
import type { ProductPreviewItem } from '../types';
|
||||
|
||||
/**
|
||||
* 文件职责:分类详情与商品预览面板。
|
||||
*/
|
||||
import type { ProductCategoryManageDto } from '#/api/product';
|
||||
|
||||
import {
|
||||
CATEGORY_CHANNEL_META,
|
||||
CATEGORY_CHANNEL_ORDER,
|
||||
formatPrice,
|
||||
getProductStatusClass,
|
||||
getProductStatusText,
|
||||
} from '../types';
|
||||
|
||||
interface Props {
|
||||
categoryProducts: ProductPreviewItem[];
|
||||
isCategoryProductsLoading: boolean;
|
||||
selectedCategory: ProductCategoryManageDto | undefined;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(event: 'edit'): void;
|
||||
(event: 'openPicker'): void;
|
||||
(event: 'remove'): void;
|
||||
(event: 'unbind', item: ProductPreviewItem): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pcat-right">
|
||||
<template v-if="props.selectedCategory">
|
||||
<div class="pcat-info-card">
|
||||
<div class="pcat-info-hd">
|
||||
<div>
|
||||
<div class="pcat-info-title">{{ props.selectedCategory.name }}</div>
|
||||
<div class="pcat-info-desc">
|
||||
{{ props.selectedCategory.description || '暂无分类描述' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pcat-info-actions">
|
||||
<button type="button" class="pcat-btn" @click="emit('edit')">
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pcat-btn pcat-btn-danger"
|
||||
@click="emit('remove')"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pcat-attr-grid">
|
||||
<div class="pcat-attr-item">
|
||||
<span class="pcat-attr-label">商品数量</span>
|
||||
<span class="pcat-attr-value">
|
||||
{{ props.selectedCategory.productCount }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="pcat-attr-item">
|
||||
<span class="pcat-attr-label">排序权重</span>
|
||||
<span class="pcat-attr-value">{{
|
||||
props.selectedCategory.sort
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="pcat-attr-item">
|
||||
<span class="pcat-attr-label">启用状态</span>
|
||||
<span class="pcat-attr-value">
|
||||
<span
|
||||
class="pcat-status-tag"
|
||||
:class="
|
||||
props.selectedCategory.status === 'enabled'
|
||||
? 'enabled'
|
||||
: 'disabled'
|
||||
"
|
||||
>
|
||||
{{
|
||||
props.selectedCategory.status === 'enabled'
|
||||
? '已启用'
|
||||
: '已停用'
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="pcat-attr-item">
|
||||
<span class="pcat-attr-label">分类图标</span>
|
||||
<span class="pcat-attr-value icon-value">
|
||||
{{ props.selectedCategory.icon || '未设置' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pcat-channel-card">
|
||||
<div class="pcat-channel-hd">渠道可见性</div>
|
||||
<div class="pcat-channel-list">
|
||||
<div
|
||||
v-for="channel in CATEGORY_CHANNEL_ORDER"
|
||||
:key="`vis-${channel}`"
|
||||
class="pcat-channel-item"
|
||||
>
|
||||
<div
|
||||
class="pcat-ch-icon"
|
||||
:class="CATEGORY_CHANNEL_META[channel].cardClass"
|
||||
>
|
||||
{{ CATEGORY_CHANNEL_META[channel].shortLabel }}
|
||||
</div>
|
||||
<div class="pcat-ch-info">
|
||||
<div class="pcat-ch-name">
|
||||
{{ CATEGORY_CHANNEL_META[channel].label }}
|
||||
</div>
|
||||
<div class="pcat-ch-sub">
|
||||
{{ CATEGORY_CHANNEL_META[channel].subText }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="pcat-ch-status"
|
||||
:class="
|
||||
props.selectedCategory.channels.includes(channel) ? 'on' : 'off'
|
||||
"
|
||||
>
|
||||
<span class="pcat-ch-status-dot"></span>
|
||||
{{
|
||||
props.selectedCategory.channels.includes(channel)
|
||||
? '已开启'
|
||||
: '未开启'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pcat-prod-card">
|
||||
<div class="pcat-prod-hd">
|
||||
<div class="pcat-prod-title">
|
||||
分类商品
|
||||
<span>{{ props.categoryProducts.length }} 个商品</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="pcat-btn pcat-btn-sm"
|
||||
@click="emit('openPicker')"
|
||||
>
|
||||
添加商品到此分类
|
||||
</button>
|
||||
</div>
|
||||
<table class="pcat-prod-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>商品</th>
|
||||
<th>价格</th>
|
||||
<th>月销</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="props.isCategoryProductsLoading">
|
||||
<td colspan="5" class="pcat-table-empty">商品加载中...</td>
|
||||
</tr>
|
||||
<tr v-else-if="props.categoryProducts.length === 0">
|
||||
<td colspan="5" class="pcat-table-empty">该分类暂无商品</td>
|
||||
</tr>
|
||||
<tr v-for="item in props.categoryProducts" v-else :key="item.id">
|
||||
<td>
|
||||
<div class="pcat-prod-name">
|
||||
<div class="pcat-prod-thumb">图</div>
|
||||
<div class="pcat-prod-info">
|
||||
<div class="name">{{ item.name }}</div>
|
||||
<div class="spu">{{ item.spuCode }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="pcat-prod-price">{{ formatPrice(item.price) }}</td>
|
||||
<td>{{ item.monthlySales }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="pcat-status-tag"
|
||||
:class="getProductStatusClass(item.status)"
|
||||
>
|
||||
{{ getProductStatusText(item.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="pcat-link-btn"
|
||||
@click="emit('unbind', item)"
|
||||
>
|
||||
移出
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="pcat-empty-card">暂无分类数据</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:分类商品选择弹窗。
|
||||
*/
|
||||
import type { ProductPickerItemDto } from '#/api/product';
|
||||
|
||||
import { Input, Modal } from 'ant-design-vue';
|
||||
|
||||
import { formatPrice } from '../types';
|
||||
|
||||
interface Props {
|
||||
keyword: string;
|
||||
loading: boolean;
|
||||
open: boolean;
|
||||
products: ProductPickerItemDto[];
|
||||
selectedIds: string[];
|
||||
submitting: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(event: 'close'): void;
|
||||
(event: 'search'): void;
|
||||
(event: 'setKeyword', value: string): void;
|
||||
(event: 'submit'): void;
|
||||
(event: 'toggleProduct', id: string): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
function setKeyword(value: string) {
|
||||
emit('setKeyword', value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:open="props.open"
|
||||
title="添加商品到分类"
|
||||
ok-text="确认添加"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="props.submitting"
|
||||
:ok-button-props="{ disabled: props.selectedIds.length === 0 }"
|
||||
@ok="emit('submit')"
|
||||
@cancel="emit('close')"
|
||||
>
|
||||
<div class="pcat-picker-search">
|
||||
<Input
|
||||
:value="props.keyword"
|
||||
placeholder="搜索商品名称/SPU"
|
||||
allow-clear
|
||||
@update:value="setKeyword"
|
||||
@press-enter="emit('search')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="pcat-btn pcat-btn-sm"
|
||||
:disabled="props.loading"
|
||||
@click="emit('search')"
|
||||
>
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="pcat-picker-list">
|
||||
<div v-if="props.loading" class="pcat-picker-empty">加载中...</div>
|
||||
<div v-else-if="props.products.length === 0" class="pcat-picker-empty">
|
||||
暂无可选商品
|
||||
</div>
|
||||
<label
|
||||
v-for="item in props.products"
|
||||
v-else
|
||||
:key="`picker-${item.id}`"
|
||||
class="pcat-picker-item"
|
||||
>
|
||||
<input
|
||||
:checked="props.selectedIds.includes(item.id)"
|
||||
type="checkbox"
|
||||
@change="emit('toggleProduct', item.id)"
|
||||
/>
|
||||
<span class="name">{{ item.name }}</span>
|
||||
<span class="spu">{{ item.spuCode }}</span>
|
||||
<span class="price">{{ formatPrice(item.price) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChannelFilter } from '../types';
|
||||
|
||||
/**
|
||||
* 文件职责:分类左侧列表组件。
|
||||
*/
|
||||
import type { ProductCategoryManageDto } from '#/api/product';
|
||||
|
||||
import { CATEGORY_CHANNEL_META, CATEGORY_CHANNEL_ORDER } from '../types';
|
||||
|
||||
interface Props {
|
||||
categories: ProductCategoryManageDto[];
|
||||
channelFilter: ChannelFilter;
|
||||
keyword: string;
|
||||
loading: boolean;
|
||||
selectedCategoryId: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(event: 'select', id: string): void;
|
||||
(event: 'update:channelFilter', value: ChannelFilter): void;
|
||||
(event: 'update:keyword', value: string): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
function setKeyword(event: Event) {
|
||||
const target = event.target as HTMLInputElement | null;
|
||||
emit('update:keyword', target?.value ?? '');
|
||||
}
|
||||
|
||||
function getDisplayChannels(channels: ProductCategoryManageDto['channels']) {
|
||||
return CATEGORY_CHANNEL_ORDER.filter((channel) => channels.includes(channel));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pcat-left">
|
||||
<div class="pcat-left-hd">
|
||||
<div class="pcat-left-title">
|
||||
全部分类
|
||||
<span>共 {{ props.categories.length }} 个</span>
|
||||
</div>
|
||||
<div class="pcat-ch-filter">
|
||||
<span
|
||||
class="pcat-ch-pill"
|
||||
:class="{ active: props.channelFilter === 'all' }"
|
||||
@click="emit('update:channelFilter', 'all')"
|
||||
>
|
||||
全部
|
||||
</span>
|
||||
<span
|
||||
class="pcat-ch-pill"
|
||||
:class="{ active: props.channelFilter === 'wm' }"
|
||||
@click="emit('update:channelFilter', 'wm')"
|
||||
>
|
||||
外卖
|
||||
</span>
|
||||
<span
|
||||
class="pcat-ch-pill"
|
||||
:class="{ active: props.channelFilter === 'pickup' }"
|
||||
@click="emit('update:channelFilter', 'pickup')"
|
||||
>
|
||||
自提
|
||||
</span>
|
||||
<span
|
||||
class="pcat-ch-pill"
|
||||
:class="{ active: props.channelFilter === 'dine_in' }"
|
||||
@click="emit('update:channelFilter', 'dine_in')"
|
||||
>
|
||||
堂食
|
||||
</span>
|
||||
</div>
|
||||
<div class="pcat-search">
|
||||
<input
|
||||
:value="props.keyword"
|
||||
type="text"
|
||||
placeholder="搜索分类名称…"
|
||||
@input="setKeyword"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pcat-left-list">
|
||||
<div v-if="props.loading" class="pcat-left-empty">分类加载中...</div>
|
||||
<div v-else-if="props.categories.length === 0" class="pcat-left-empty">
|
||||
暂无分类
|
||||
</div>
|
||||
<div
|
||||
v-for="item in props.categories"
|
||||
v-else
|
||||
:key="item.id"
|
||||
class="pcat-cat-item"
|
||||
:class="{
|
||||
active: item.id === props.selectedCategoryId,
|
||||
disabled: item.status === 'disabled',
|
||||
}"
|
||||
@click="emit('select', item.id)"
|
||||
>
|
||||
<span class="pcat-drag-handle">⋮⋮</span>
|
||||
<span class="pcat-cat-name">{{ item.name }}</span>
|
||||
<span class="pcat-cat-tags">
|
||||
<span
|
||||
v-for="channel in getDisplayChannels(item.channels)"
|
||||
:key="`${item.id}-${channel}`"
|
||||
class="pcat-ch-mini"
|
||||
:class="CATEGORY_CHANNEL_META[channel].filterClass"
|
||||
>
|
||||
{{ CATEGORY_CHANNEL_META[channel].shortLabel }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="pcat-cat-badge">{{ item.productCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:分类统计条展示组件。
|
||||
*/
|
||||
import type { CategoryStatsViewModel } from '../types';
|
||||
|
||||
interface Props {
|
||||
stats: CategoryStatsViewModel;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pcat-stats">
|
||||
<span class="pcat-stat-item">
|
||||
<span>分类总数</span>
|
||||
<strong>{{ stats.totalCategories }}</strong>
|
||||
</span>
|
||||
<span class="pcat-stat-item">
|
||||
<span>已启用</span>
|
||||
<strong>{{ stats.enabledCategories }}</strong>
|
||||
</span>
|
||||
<span class="pcat-stat-item">
|
||||
<span>商品总数</span>
|
||||
<strong>{{ stats.totalProducts }}</strong>
|
||||
</span>
|
||||
<span>
|
||||
渠道覆盖
|
||||
<span class="pcat-ch-stat">
|
||||
<span class="pcat-ch-dot wm"></span>外卖
|
||||
<strong>{{ stats.wmCount }}</strong>
|
||||
</span>
|
||||
<span class="pcat-ch-stat">
|
||||
<span class="pcat-ch-dot zt"></span>自提
|
||||
<strong>{{ stats.pickupCount }}</strong>
|
||||
</span>
|
||||
<span class="pcat-ch-stat">
|
||||
<span class="pcat-ch-dot ts"></span>堂食
|
||||
<strong>{{ stats.dineInCount }}</strong>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,626 @@
|
||||
import type {
|
||||
CategoryFormModel,
|
||||
CategoryStatsViewModel,
|
||||
ChannelFilter,
|
||||
DrawerMode,
|
||||
ProductPreviewItem,
|
||||
StoreOption,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* 文件职责:分类管理页面状态与行为聚合。
|
||||
* 1. 管理门店、分类、商品预览、抽屉与弹窗状态。
|
||||
* 2. 统一封装分类 CRUD 与商品绑定/解绑流程。
|
||||
*/
|
||||
import type {
|
||||
ProductCategoryManageDto,
|
||||
ProductPickerItemDto,
|
||||
} from '#/api/product';
|
||||
import type { StoreListItemDto } from '#/api/store';
|
||||
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
bindCategoryProductsApi,
|
||||
deleteProductCategoryApi,
|
||||
getProductCategoryManageListApi,
|
||||
saveProductCategoryApi,
|
||||
searchProductPickerApi,
|
||||
unbindCategoryProductApi,
|
||||
} from '#/api/product';
|
||||
import { getStoreListApi } from '#/api/store';
|
||||
|
||||
import { CATEGORY_CHANNEL_ORDER, deriveMonthlySales } from '../types';
|
||||
|
||||
/** 分类管理页面组合式状态。 */
|
||||
export function useProductCategoryPage() {
|
||||
const stores = ref<StoreListItemDto[]>([]);
|
||||
const selectedStoreId = ref('');
|
||||
const isStoreLoading = ref(false);
|
||||
|
||||
const categories = ref<ProductCategoryManageDto[]>([]);
|
||||
const isCategoryLoading = ref(false);
|
||||
const selectedCategoryId = ref('');
|
||||
const categoryKeyword = ref('');
|
||||
const channelFilter = ref<ChannelFilter>('all');
|
||||
|
||||
const categoryProducts = ref<ProductPreviewItem[]>([]);
|
||||
const isCategoryProductsLoading = ref(false);
|
||||
|
||||
const isDrawerOpen = ref(false);
|
||||
const drawerMode = ref<DrawerMode>('create');
|
||||
const isDrawerSubmitting = ref(false);
|
||||
const form = reactive<CategoryFormModel>({
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
icon: '',
|
||||
channels: [...CATEGORY_CHANNEL_ORDER],
|
||||
sort: 1,
|
||||
status: 'enabled',
|
||||
});
|
||||
|
||||
const isPickerOpen = ref(false);
|
||||
const pickerKeyword = ref('');
|
||||
const pickerProducts = ref<ProductPickerItemDto[]>([]);
|
||||
const pickerSelectedIds = ref<string[]>([]);
|
||||
const isPickerLoading = ref(false);
|
||||
const isPickerSubmitting = ref(false);
|
||||
|
||||
const isCopyModalOpen = ref(false);
|
||||
const isCopySubmitting = ref(false);
|
||||
const copyTargetStoreIds = ref<string[]>([]);
|
||||
|
||||
const storeOptions = computed<StoreOption[]>(() =>
|
||||
stores.value.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})),
|
||||
);
|
||||
|
||||
const selectedCategory = computed(() =>
|
||||
categories.value.find((item) => item.id === selectedCategoryId.value),
|
||||
);
|
||||
|
||||
const stats = computed<CategoryStatsViewModel>(() => {
|
||||
const totalCategories = categories.value.length;
|
||||
const enabledCategories = categories.value.filter(
|
||||
(item) => item.status === 'enabled',
|
||||
).length;
|
||||
const totalProducts = categories.value.reduce(
|
||||
(sum, item) => sum + item.productCount,
|
||||
0,
|
||||
);
|
||||
const wmCount = categories.value.filter((item) =>
|
||||
item.channels.includes('wm'),
|
||||
).length;
|
||||
const pickupCount = categories.value.filter((item) =>
|
||||
item.channels.includes('pickup'),
|
||||
).length;
|
||||
const dineInCount = categories.value.filter((item) =>
|
||||
item.channels.includes('dine_in'),
|
||||
).length;
|
||||
|
||||
return {
|
||||
totalCategories,
|
||||
enabledCategories,
|
||||
totalProducts,
|
||||
wmCount,
|
||||
pickupCount,
|
||||
dineInCount,
|
||||
};
|
||||
});
|
||||
|
||||
const filteredCategories = computed(() => {
|
||||
const keyword = categoryKeyword.value.trim().toLowerCase();
|
||||
return categories.value.filter((item) => {
|
||||
if (
|
||||
channelFilter.value !== 'all' &&
|
||||
!item.channels.includes(channelFilter.value)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!keyword) return true;
|
||||
return (
|
||||
item.name.toLowerCase().includes(keyword) ||
|
||||
item.description.toLowerCase().includes(keyword)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const drawerTitle = computed(() =>
|
||||
drawerMode.value === 'create' ? '添加分类' : '编辑分类',
|
||||
);
|
||||
const drawerSubmitText = computed(() =>
|
||||
drawerMode.value === 'create' ? '确认添加' : '保存修改',
|
||||
);
|
||||
|
||||
const selectedStoreName = computed(
|
||||
() =>
|
||||
stores.value.find((item) => item.id === selectedStoreId.value)?.name ??
|
||||
'',
|
||||
);
|
||||
|
||||
const copyCandidates = computed(() =>
|
||||
stores.value.filter((item) => item.id !== selectedStoreId.value),
|
||||
);
|
||||
|
||||
const isCopyAllChecked = computed(
|
||||
() =>
|
||||
copyCandidates.value.length > 0 &&
|
||||
copyTargetStoreIds.value.length === copyCandidates.value.length,
|
||||
);
|
||||
|
||||
const isCopyIndeterminate = computed(
|
||||
() =>
|
||||
copyTargetStoreIds.value.length > 0 &&
|
||||
copyTargetStoreIds.value.length < copyCandidates.value.length,
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCategories(preferredCategoryId = '') {
|
||||
if (!selectedStoreId.value) {
|
||||
categories.value = [];
|
||||
selectedCategoryId.value = '';
|
||||
categoryProducts.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isCategoryLoading.value = true;
|
||||
const previousCategoryId = selectedCategoryId.value;
|
||||
try {
|
||||
const result = await getProductCategoryManageListApi({
|
||||
storeId: selectedStoreId.value,
|
||||
});
|
||||
categories.value = [...result].toSorted((a, b) => a.sort - b.sort);
|
||||
|
||||
const nextCategoryId =
|
||||
(preferredCategoryId &&
|
||||
categories.value.some((item) => item.id === preferredCategoryId)
|
||||
? preferredCategoryId
|
||||
: '') ||
|
||||
(previousCategoryId &&
|
||||
categories.value.some((item) => item.id === previousCategoryId)
|
||||
? previousCategoryId
|
||||
: '') ||
|
||||
categories.value[0]?.id ||
|
||||
'';
|
||||
selectedCategoryId.value = nextCategoryId;
|
||||
|
||||
if (nextCategoryId && nextCategoryId === previousCategoryId) {
|
||||
await loadCategoryProducts();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
categories.value = [];
|
||||
selectedCategoryId.value = '';
|
||||
categoryProducts.value = [];
|
||||
message.error('加载分类失败');
|
||||
} finally {
|
||||
isCategoryLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCategoryProducts() {
|
||||
if (!selectedStoreId.value || !selectedCategoryId.value) {
|
||||
categoryProducts.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isCategoryProductsLoading.value = true;
|
||||
try {
|
||||
const list = await searchProductPickerApi({
|
||||
storeId: selectedStoreId.value,
|
||||
categoryId: selectedCategoryId.value,
|
||||
limit: 500,
|
||||
});
|
||||
categoryProducts.value = list.map((item) => ({
|
||||
...item,
|
||||
monthlySales: deriveMonthlySales(item.id),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
categoryProducts.value = [];
|
||||
message.error('加载分类商品失败');
|
||||
} finally {
|
||||
isCategoryProductsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setSelectedStoreId(value: string) {
|
||||
selectedStoreId.value = value;
|
||||
}
|
||||
|
||||
function setCategoryKeyword(value: string) {
|
||||
categoryKeyword.value = value;
|
||||
}
|
||||
|
||||
function setChannelFilter(value: ChannelFilter) {
|
||||
channelFilter.value = value;
|
||||
}
|
||||
|
||||
function selectCategory(id: string) {
|
||||
if (selectedCategoryId.value === id) return;
|
||||
selectedCategoryId.value = id;
|
||||
}
|
||||
|
||||
function openCopyModal() {
|
||||
if (!selectedStoreId.value) {
|
||||
message.warning('请先选择门店');
|
||||
return;
|
||||
}
|
||||
copyTargetStoreIds.value = [];
|
||||
isCopyModalOpen.value = true;
|
||||
}
|
||||
|
||||
function setCopyModalOpen(value: boolean) {
|
||||
isCopyModalOpen.value = value;
|
||||
}
|
||||
|
||||
function handleCopyCheckAll(checked: boolean) {
|
||||
copyTargetStoreIds.value = checked
|
||||
? copyCandidates.value.map((item) => item.id)
|
||||
: [];
|
||||
}
|
||||
|
||||
function toggleCopyStore(storeId: string, checked: boolean) {
|
||||
if (checked) {
|
||||
copyTargetStoreIds.value = [
|
||||
...new Set([storeId, ...copyTargetStoreIds.value]),
|
||||
];
|
||||
return;
|
||||
}
|
||||
copyTargetStoreIds.value = copyTargetStoreIds.value.filter(
|
||||
(item) => item !== storeId,
|
||||
);
|
||||
}
|
||||
|
||||
async function handleCopySubmit() {
|
||||
if (copyTargetStoreIds.value.length === 0) {
|
||||
message.warning('请至少选择一个目标门店');
|
||||
return;
|
||||
}
|
||||
isCopySubmitting.value = true;
|
||||
try {
|
||||
message.info('复制到其他门店功能开发中');
|
||||
isCopyModalOpen.value = false;
|
||||
copyTargetStoreIds.value = [];
|
||||
} finally {
|
||||
isCopySubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetDrawerForm() {
|
||||
form.id = '';
|
||||
form.name = '';
|
||||
form.description = '';
|
||||
form.icon = '';
|
||||
form.channels = [...CATEGORY_CHANNEL_ORDER];
|
||||
form.sort = categories.value.length + 1;
|
||||
form.status = 'enabled';
|
||||
}
|
||||
|
||||
function setFormName(value: string) {
|
||||
form.name = value;
|
||||
}
|
||||
|
||||
function setFormDescription(value: string) {
|
||||
form.description = value;
|
||||
}
|
||||
|
||||
function setFormIcon(value: string) {
|
||||
form.icon = value;
|
||||
}
|
||||
|
||||
function setFormSort(value: number) {
|
||||
form.sort = Number.isFinite(value) && value > 0 ? Math.floor(value) : 1;
|
||||
}
|
||||
|
||||
function openCreateDrawer() {
|
||||
drawerMode.value = 'create';
|
||||
resetDrawerForm();
|
||||
isDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
function openEditDrawer() {
|
||||
const current = selectedCategory.value;
|
||||
if (!current) {
|
||||
message.warning('请先选择分类');
|
||||
return;
|
||||
}
|
||||
drawerMode.value = 'edit';
|
||||
form.id = current.id;
|
||||
form.name = current.name;
|
||||
form.description = current.description;
|
||||
form.icon = current.icon;
|
||||
form.channels = [...current.channels];
|
||||
form.sort = current.sort;
|
||||
form.status = current.status;
|
||||
isDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
function setDrawerOpen(value: boolean) {
|
||||
isDrawerOpen.value = value;
|
||||
}
|
||||
|
||||
function toggleDrawerChannel(channel: CategoryFormModel['channels'][number]) {
|
||||
if (form.channels.includes(channel)) {
|
||||
form.channels = form.channels.filter((item) => item !== channel);
|
||||
return;
|
||||
}
|
||||
form.channels = [...form.channels, channel];
|
||||
}
|
||||
|
||||
function toggleDrawerStatus() {
|
||||
form.status = form.status === 'enabled' ? 'disabled' : 'enabled';
|
||||
}
|
||||
|
||||
async function submitDrawer() {
|
||||
if (!selectedStoreId.value) return;
|
||||
if (!form.name.trim()) {
|
||||
message.warning('请输入分类名称');
|
||||
return;
|
||||
}
|
||||
if (form.channels.length === 0) {
|
||||
message.warning('请至少选择一个销售渠道');
|
||||
return;
|
||||
}
|
||||
|
||||
isDrawerSubmitting.value = true;
|
||||
try {
|
||||
const result = await saveProductCategoryApi({
|
||||
storeId: selectedStoreId.value,
|
||||
id: form.id || undefined,
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
icon: form.icon.trim() || 'lucide:folder',
|
||||
channels: [...form.channels],
|
||||
sort: form.sort,
|
||||
status: form.status,
|
||||
});
|
||||
message.success(
|
||||
drawerMode.value === 'create' ? '分类添加成功' : '保存成功',
|
||||
);
|
||||
isDrawerOpen.value = false;
|
||||
await loadCategories(result.id);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
isDrawerSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function removeSelectedCategory() {
|
||||
const current = selectedCategory.value;
|
||||
if (!selectedStoreId.value || !current) return;
|
||||
|
||||
Modal.confirm({
|
||||
title: `确认删除分类「${current.name}」吗?`,
|
||||
content: '若分类下还有商品会删除失败。',
|
||||
okText: '确认删除',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
await deleteProductCategoryApi({
|
||||
storeId: selectedStoreId.value,
|
||||
categoryId: current.id,
|
||||
});
|
||||
message.success('分类已删除');
|
||||
await loadCategories();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function openProductPicker() {
|
||||
if (!selectedStoreId.value || !selectedCategory.value) return;
|
||||
pickerKeyword.value = '';
|
||||
pickerProducts.value = [];
|
||||
pickerSelectedIds.value = [];
|
||||
isPickerOpen.value = true;
|
||||
await loadPickerProducts();
|
||||
}
|
||||
|
||||
function setPickerOpen(value: boolean) {
|
||||
isPickerOpen.value = value;
|
||||
}
|
||||
|
||||
function setPickerKeyword(value: string) {
|
||||
pickerKeyword.value = value;
|
||||
}
|
||||
|
||||
async function loadPickerProducts() {
|
||||
if (!selectedStoreId.value || !selectedCategory.value) {
|
||||
pickerProducts.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isPickerLoading.value = true;
|
||||
try {
|
||||
const list = await searchProductPickerApi({
|
||||
storeId: selectedStoreId.value,
|
||||
keyword: pickerKeyword.value.trim() || undefined,
|
||||
limit: 500,
|
||||
});
|
||||
const currentIds = new Set(categoryProducts.value.map((item) => item.id));
|
||||
pickerProducts.value = list.filter((item) => !currentIds.has(item.id));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
pickerProducts.value = [];
|
||||
message.error('加载可选商品失败');
|
||||
} finally {
|
||||
isPickerLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function togglePickerProduct(id: string) {
|
||||
if (pickerSelectedIds.value.includes(id)) {
|
||||
pickerSelectedIds.value = pickerSelectedIds.value.filter(
|
||||
(item) => item !== id,
|
||||
);
|
||||
return;
|
||||
}
|
||||
pickerSelectedIds.value = [...pickerSelectedIds.value, id];
|
||||
}
|
||||
|
||||
async function submitProductPicker() {
|
||||
const current = selectedCategory.value;
|
||||
if (!selectedStoreId.value || !current) return;
|
||||
if (pickerSelectedIds.value.length === 0) {
|
||||
message.warning('请至少选择一个商品');
|
||||
return;
|
||||
}
|
||||
|
||||
isPickerSubmitting.value = true;
|
||||
try {
|
||||
await bindCategoryProductsApi({
|
||||
storeId: selectedStoreId.value,
|
||||
categoryId: current.id,
|
||||
productIds: [...pickerSelectedIds.value],
|
||||
});
|
||||
message.success('商品已添加到分类');
|
||||
isPickerOpen.value = false;
|
||||
await loadCategories(current.id);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
isPickerSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function unbindProduct(item: ProductPreviewItem) {
|
||||
const current = selectedCategory.value;
|
||||
if (!selectedStoreId.value || !current) return;
|
||||
|
||||
Modal.confirm({
|
||||
title: `确认将「${item.name}」移出当前分类吗?`,
|
||||
content: '移出后商品会自动归入其他分类。',
|
||||
okText: '确认移出',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
await unbindCategoryProductApi({
|
||||
storeId: selectedStoreId.value,
|
||||
categoryId: current.id,
|
||||
productId: item.id,
|
||||
});
|
||||
message.success('商品已移出');
|
||||
await loadCategories(current.id);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
watch(selectedStoreId, () => {
|
||||
categoryKeyword.value = '';
|
||||
channelFilter.value = 'all';
|
||||
isCopyModalOpen.value = false;
|
||||
copyTargetStoreIds.value = [];
|
||||
void loadCategories();
|
||||
});
|
||||
|
||||
watch(filteredCategories, (list) => {
|
||||
if (list.length === 0) {
|
||||
selectedCategoryId.value = '';
|
||||
categoryProducts.value = [];
|
||||
return;
|
||||
}
|
||||
const hasSelected = list.some(
|
||||
(item) => item.id === selectedCategoryId.value,
|
||||
);
|
||||
if (!hasSelected) {
|
||||
selectedCategoryId.value = list[0]?.id ?? '';
|
||||
}
|
||||
});
|
||||
|
||||
watch(selectedCategoryId, () => {
|
||||
void loadCategoryProducts();
|
||||
});
|
||||
|
||||
onMounted(loadStores);
|
||||
|
||||
return {
|
||||
categoryKeyword,
|
||||
categoryProducts,
|
||||
categories,
|
||||
channelFilter,
|
||||
copyCandidates,
|
||||
copyTargetStoreIds,
|
||||
drawerMode,
|
||||
drawerSubmitText,
|
||||
drawerTitle,
|
||||
filteredCategories,
|
||||
form,
|
||||
isCategoryLoading,
|
||||
isCategoryProductsLoading,
|
||||
isCopyAllChecked,
|
||||
isCopyIndeterminate,
|
||||
isCopyModalOpen,
|
||||
isCopySubmitting,
|
||||
isDrawerOpen,
|
||||
isDrawerSubmitting,
|
||||
isPickerLoading,
|
||||
isPickerOpen,
|
||||
isPickerSubmitting,
|
||||
isStoreLoading,
|
||||
pickerKeyword,
|
||||
pickerProducts,
|
||||
pickerSelectedIds,
|
||||
selectedCategory,
|
||||
selectedCategoryId,
|
||||
selectedStoreId,
|
||||
selectedStoreName,
|
||||
stats,
|
||||
storeOptions,
|
||||
handleCopyCheckAll,
|
||||
handleCopySubmit,
|
||||
loadPickerProducts,
|
||||
openCopyModal,
|
||||
openCreateDrawer,
|
||||
openEditDrawer,
|
||||
openProductPicker,
|
||||
removeSelectedCategory,
|
||||
selectCategory,
|
||||
setCategoryKeyword,
|
||||
setChannelFilter,
|
||||
setCopyModalOpen,
|
||||
setDrawerOpen,
|
||||
setFormDescription,
|
||||
setFormIcon,
|
||||
setFormName,
|
||||
setFormSort,
|
||||
setPickerKeyword,
|
||||
setPickerOpen,
|
||||
setSelectedStoreId,
|
||||
submitDrawer,
|
||||
submitProductPicker,
|
||||
toggleCopyStore,
|
||||
toggleDrawerChannel,
|
||||
toggleDrawerStatus,
|
||||
togglePickerProduct,
|
||||
unbindProduct,
|
||||
};
|
||||
}
|
||||
189
apps/web-antd/src/views/product/category/index.vue
Normal file
189
apps/web-antd/src/views/product/category/index.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:分类管理页面主视图。
|
||||
* 1. 组合分类统计、左侧列表、右侧详情、编辑抽屉与商品选择弹窗。
|
||||
* 2. 复用门店维度工具栏与复制弹窗组件。
|
||||
*/
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Card, Empty } from 'ant-design-vue';
|
||||
|
||||
import CopyToStoresModal from '../../shared/components/CopyToStoresModal.vue';
|
||||
import StoreScopeToolbar from '../../shared/components/StoreScopeToolbar.vue';
|
||||
import CategoryDetailPanel from './components/CategoryDetailPanel.vue';
|
||||
import CategoryEditorDrawer from './components/CategoryEditorDrawer.vue';
|
||||
import CategoryProductPickerModal from './components/CategoryProductPickerModal.vue';
|
||||
import CategorySidebar from './components/CategorySidebar.vue';
|
||||
import CategoryStatsBar from './components/CategoryStatsBar.vue';
|
||||
import { useProductCategoryPage } from './composables/useProductCategoryPage';
|
||||
|
||||
const {
|
||||
categoryKeyword,
|
||||
categoryProducts,
|
||||
channelFilter,
|
||||
copyCandidates,
|
||||
copyTargetStoreIds,
|
||||
drawerSubmitText,
|
||||
drawerTitle,
|
||||
filteredCategories,
|
||||
form,
|
||||
isCategoryLoading,
|
||||
isCategoryProductsLoading,
|
||||
isCopyAllChecked,
|
||||
isCopyIndeterminate,
|
||||
isCopyModalOpen,
|
||||
isCopySubmitting,
|
||||
isDrawerOpen,
|
||||
isDrawerSubmitting,
|
||||
isPickerLoading,
|
||||
isPickerOpen,
|
||||
isPickerSubmitting,
|
||||
isStoreLoading,
|
||||
pickerKeyword,
|
||||
pickerProducts,
|
||||
pickerSelectedIds,
|
||||
selectedCategory,
|
||||
selectedCategoryId,
|
||||
selectedStoreId,
|
||||
selectedStoreName,
|
||||
stats,
|
||||
storeOptions,
|
||||
handleCopyCheckAll,
|
||||
handleCopySubmit,
|
||||
loadPickerProducts,
|
||||
openCopyModal,
|
||||
openCreateDrawer,
|
||||
openEditDrawer,
|
||||
openProductPicker,
|
||||
removeSelectedCategory,
|
||||
selectCategory,
|
||||
setCategoryKeyword,
|
||||
setChannelFilter,
|
||||
setCopyModalOpen,
|
||||
setDrawerOpen,
|
||||
setFormDescription,
|
||||
setFormIcon,
|
||||
setFormName,
|
||||
setFormSort,
|
||||
setPickerKeyword,
|
||||
setPickerOpen,
|
||||
setSelectedStoreId,
|
||||
submitDrawer,
|
||||
submitProductPicker,
|
||||
toggleCopyStore,
|
||||
toggleDrawerChannel,
|
||||
toggleDrawerStatus,
|
||||
togglePickerProduct,
|
||||
unbindProduct,
|
||||
} = useProductCategoryPage();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page title="分类管理" content-class="page-product-category">
|
||||
<div class="pcat-page">
|
||||
<StoreScopeToolbar
|
||||
class="pcat-toolbar-card"
|
||||
:selected-store-id="selectedStoreId"
|
||||
:store-options="storeOptions"
|
||||
:is-store-loading="isStoreLoading"
|
||||
:copy-disabled="storeOptions.length <= 1"
|
||||
copy-position="left"
|
||||
@update:selected-store-id="setSelectedStoreId"
|
||||
@copy="openCopyModal"
|
||||
>
|
||||
<template #actions>
|
||||
<button
|
||||
type="button"
|
||||
class="pcat-btn pcat-btn-primary pcat-btn-lg"
|
||||
@click="openCreateDrawer"
|
||||
>
|
||||
添加分类
|
||||
</button>
|
||||
</template>
|
||||
</StoreScopeToolbar>
|
||||
|
||||
<Card v-if="storeOptions.length === 0" :bordered="false">
|
||||
<Empty description="暂无门店,请先创建门店" />
|
||||
</Card>
|
||||
|
||||
<template v-else>
|
||||
<CategoryStatsBar :stats="stats" />
|
||||
|
||||
<div class="pcat-main">
|
||||
<CategorySidebar
|
||||
:loading="isCategoryLoading"
|
||||
:categories="filteredCategories"
|
||||
:selected-category-id="selectedCategoryId"
|
||||
:keyword="categoryKeyword"
|
||||
:channel-filter="channelFilter"
|
||||
@select="selectCategory"
|
||||
@update:keyword="setCategoryKeyword"
|
||||
@update:channel-filter="setChannelFilter"
|
||||
/>
|
||||
|
||||
<CategoryDetailPanel
|
||||
:selected-category="selectedCategory"
|
||||
:category-products="categoryProducts"
|
||||
:is-category-products-loading="isCategoryProductsLoading"
|
||||
@edit="openEditDrawer"
|
||||
@remove="removeSelectedCategory"
|
||||
@open-picker="openProductPicker"
|
||||
@unbind="unbindProduct"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<CategoryProductPickerModal
|
||||
:open="isPickerOpen"
|
||||
:loading="isPickerLoading"
|
||||
:submitting="isPickerSubmitting"
|
||||
:products="pickerProducts"
|
||||
:keyword="pickerKeyword"
|
||||
:selected-ids="pickerSelectedIds"
|
||||
@close="setPickerOpen(false)"
|
||||
@set-keyword="setPickerKeyword"
|
||||
@search="loadPickerProducts"
|
||||
@toggle-product="togglePickerProduct"
|
||||
@submit="submitProductPicker"
|
||||
/>
|
||||
|
||||
<CategoryEditorDrawer
|
||||
:open="isDrawerOpen"
|
||||
:title="drawerTitle"
|
||||
:submit-text="drawerSubmitText"
|
||||
:submitting="isDrawerSubmitting"
|
||||
:form="form"
|
||||
@close="setDrawerOpen(false)"
|
||||
@set-name="setFormName"
|
||||
@set-description="setFormDescription"
|
||||
@set-icon="setFormIcon"
|
||||
@set-sort="setFormSort"
|
||||
@toggle-channel="toggleDrawerChannel"
|
||||
@toggle-status="toggleDrawerStatus"
|
||||
@submit="submitDrawer"
|
||||
/>
|
||||
|
||||
<CopyToStoresModal
|
||||
:open="isCopyModalOpen"
|
||||
:copy-candidates="copyCandidates"
|
||||
:target-store-ids="copyTargetStoreIds"
|
||||
:is-all-checked="isCopyAllChecked"
|
||||
:is-indeterminate="isCopyIndeterminate"
|
||||
:is-submitting="isCopySubmitting"
|
||||
:selected-store-name="selectedStoreName"
|
||||
title="复制分类设置到其他门店"
|
||||
confirm-text="确认复制"
|
||||
@update:open="setCopyModalOpen"
|
||||
@check-all="handleCopyCheckAll"
|
||||
@submit="handleCopySubmit"
|
||||
@toggle-store="
|
||||
({ storeId, checked }) => toggleCopyStore(storeId, checked)
|
||||
"
|
||||
/>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style lang="less">
|
||||
@import './styles/index.less';
|
||||
</style>
|
||||
135
apps/web-antd/src/views/product/category/styles/base.less
Normal file
135
apps/web-antd/src/views/product/category/styles/base.less
Normal file
@@ -0,0 +1,135 @@
|
||||
.page-product-category {
|
||||
--pcat-primary: #1677ff;
|
||||
--pcat-shadow: 0 6px 16px rgb(15 23 42 / 6%);
|
||||
--pcat-radius: 10px;
|
||||
|
||||
.pcat-page {
|
||||
min-height: calc(100vh - 208px);
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.pcat-toolbar-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pcat-btn {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: #fff;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.pcat-btn:hover {
|
||||
color: var(--pcat-primary);
|
||||
border-color: var(--pcat-primary);
|
||||
}
|
||||
|
||||
.pcat-btn:disabled {
|
||||
color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.pcat-btn-primary {
|
||||
color: #fff;
|
||||
background: var(--pcat-primary);
|
||||
border-color: var(--pcat-primary);
|
||||
}
|
||||
|
||||
.pcat-btn-primary:hover {
|
||||
color: #fff;
|
||||
filter: brightness(1.04);
|
||||
}
|
||||
|
||||
.pcat-btn-danger {
|
||||
color: #ff4d4f;
|
||||
background: #fff2f0;
|
||||
border-color: #ffccc7;
|
||||
}
|
||||
|
||||
.pcat-btn-danger:hover {
|
||||
color: #ff4d4f;
|
||||
background: #fff1f0;
|
||||
border-color: #ff4d4f;
|
||||
}
|
||||
|
||||
.pcat-btn-sm {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pcat-btn-lg {
|
||||
height: 36px;
|
||||
padding: 0 18px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.pcat-main {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.pcat-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
padding: 10px 16px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
background: #fff;
|
||||
border-radius: var(--pcat-radius);
|
||||
box-shadow: var(--pcat-shadow);
|
||||
}
|
||||
|
||||
.pcat-stats strong {
|
||||
margin-left: 2px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.pcat-stat-item {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pcat-ch-stat {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.pcat-ch-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.pcat-ch-dot.wm {
|
||||
background: #1890ff;
|
||||
}
|
||||
|
||||
.pcat-ch-dot.zt {
|
||||
background: #52c41a;
|
||||
}
|
||||
|
||||
.pcat-ch-dot.ts {
|
||||
background: #fa8c16;
|
||||
}
|
||||
}
|
||||
319
apps/web-antd/src/views/product/category/styles/detail.less
Normal file
319
apps/web-antd/src/views/product/category/styles/detail.less
Normal file
@@ -0,0 +1,319 @@
|
||||
.page-product-category {
|
||||
.pcat-right {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pcat-info-card,
|
||||
.pcat-channel-card,
|
||||
.pcat-prod-card,
|
||||
.pcat-empty-card {
|
||||
background: #fff;
|
||||
border-radius: var(--pcat-radius);
|
||||
box-shadow: var(--pcat-shadow);
|
||||
}
|
||||
|
||||
.pcat-info-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.pcat-info-hd {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pcat-info-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.pcat-info-desc {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.pcat-info-actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pcat-attr-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.pcat-attr-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
background: #f8f9fb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.pcat-attr-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.pcat-attr-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.pcat-attr-value.icon-value {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.pcat-status-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.pcat-status-tag.enabled,
|
||||
.pcat-status-tag.on-sale {
|
||||
color: #52c41a;
|
||||
background: rgb(82 196 26 / 12%);
|
||||
}
|
||||
|
||||
.pcat-status-tag.disabled,
|
||||
.pcat-status-tag.off-shelf {
|
||||
color: #9ca3af;
|
||||
background: rgb(148 163 184 / 16%);
|
||||
}
|
||||
|
||||
.pcat-status-tag.sold-out {
|
||||
color: #fa8c16;
|
||||
background: rgb(250 140 22 / 14%);
|
||||
}
|
||||
|
||||
.pcat-channel-card {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.pcat-channel-hd {
|
||||
padding-left: 10px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
border-left: 3px solid var(--pcat-primary);
|
||||
}
|
||||
|
||||
.pcat-channel-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pcat-channel-item {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-width: 160px;
|
||||
padding: 10px 16px;
|
||||
background: #f8f9fb;
|
||||
border: 1px solid #f3f4f6;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.pcat-ch-icon {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.pcat-ch-icon.wm {
|
||||
color: #1890ff;
|
||||
background: rgb(24 144 255 / 12%);
|
||||
}
|
||||
|
||||
.pcat-ch-icon.zt {
|
||||
color: #52c41a;
|
||||
background: rgb(82 196 26 / 12%);
|
||||
}
|
||||
|
||||
.pcat-ch-icon.ts {
|
||||
color: #fa8c16;
|
||||
background: rgb(250 140 22 / 12%);
|
||||
}
|
||||
|
||||
.pcat-ch-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pcat-ch-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.pcat-ch-sub {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.pcat-ch-status {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pcat-ch-status.on {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.pcat-ch-status.off {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.pcat-ch-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: currentcolor;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.pcat-prod-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pcat-prod-hd {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.pcat-prod-title {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.pcat-prod-title span {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.pcat-prod-table {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.pcat-prod-table th {
|
||||
padding: 10px 18px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
text-align: left;
|
||||
background: #f8f9fb;
|
||||
}
|
||||
|
||||
.pcat-prod-table td {
|
||||
padding: 10px 18px;
|
||||
color: #1a1a2e;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.pcat-prod-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.pcat-prod-table tr:hover td {
|
||||
background: rgb(22 119 255 / 4%);
|
||||
}
|
||||
|
||||
.pcat-prod-name {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pcat-prod-thumb {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 11px;
|
||||
color: #bbb;
|
||||
background: #f3f4f6;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.pcat-prod-info .name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pcat-prod-info .spu {
|
||||
margin-top: 1px;
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.pcat-prod-price {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pcat-link-btn {
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
color: var(--pcat-primary);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.pcat-link-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.pcat-table-empty {
|
||||
padding: 30px 18px !important;
|
||||
color: #9ca3af !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pcat-empty-card {
|
||||
padding: 60px 24px;
|
||||
color: #9ca3af;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
291
apps/web-antd/src/views/product/category/styles/drawer.less
Normal file
291
apps/web-antd/src/views/product/category/styles/drawer.less
Normal file
@@ -0,0 +1,291 @@
|
||||
.page-product-category {
|
||||
.pcat-drawer-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
background: rgb(15 23 42 / 28%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.pcat-drawer-mask.open {
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.pcat-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 1001;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(480px, 100vw);
|
||||
height: 100vh;
|
||||
background: #fff;
|
||||
box-shadow: -8px 0 24px rgb(15 23 42 / 16%);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.24s ease;
|
||||
}
|
||||
|
||||
.pcat-drawer.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.pcat-drawer-hd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 56px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.pcat-drawer-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.pcat-drawer-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.pcat-drawer-close:hover {
|
||||
color: #4b5563;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.pcat-drawer-bd {
|
||||
flex: 1;
|
||||
padding: 16px 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pcat-drawer-ft {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.pcat-form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pcat-form-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.pcat-form-label.required::before {
|
||||
margin-right: 4px;
|
||||
color: #ff4d4f;
|
||||
content: '*';
|
||||
}
|
||||
|
||||
.pcat-input,
|
||||
.pcat-textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
color: #1a1a2e;
|
||||
outline: none;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.pcat-input:focus,
|
||||
.pcat-textarea:focus {
|
||||
border-color: var(--pcat-primary);
|
||||
box-shadow: 0 0 0 3px rgb(22 119 255 / 10%);
|
||||
}
|
||||
|
||||
.pcat-input-small {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.pcat-textarea {
|
||||
min-height: 72px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.pcat-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.pcat-hint.mb-8 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pcat-icon-upload-wrap {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pcat-icon-upload-wrap .ant-upload {
|
||||
display: block;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.pcat-icon-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
color: #bbb;
|
||||
cursor: pointer;
|
||||
border: 1px dashed #e5e7eb;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.pcat-icon-upload:hover {
|
||||
color: var(--pcat-primary);
|
||||
border-color: var(--pcat-primary);
|
||||
}
|
||||
|
||||
.pcat-icon-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.pcat-icon-upload-sign {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.pcat-icon-upload-text {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.pcat-drawer-channels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pcat-drawer-ch {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: #fff;
|
||||
border: 1.5px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.pcat-drawer-ch-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.pcat-drawer-ch:hover {
|
||||
border-color: var(--pcat-primary);
|
||||
}
|
||||
|
||||
.pcat-drawer-ch.checked {
|
||||
font-weight: 500;
|
||||
color: var(--pcat-primary);
|
||||
background: rgb(22 119 255 / 6%);
|
||||
border-color: var(--pcat-primary);
|
||||
}
|
||||
|
||||
.pcat-ch-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 10px;
|
||||
color: transparent;
|
||||
border: 1.5px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.pcat-drawer-ch.checked .pcat-ch-check {
|
||||
color: #fff;
|
||||
background: var(--pcat-primary);
|
||||
border-color: var(--pcat-primary);
|
||||
}
|
||||
|
||||
.pcat-toggle-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pcat-toggle {
|
||||
position: relative;
|
||||
width: 42px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
background: #d9d9d9;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.pcat-toggle::after {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
content: '';
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.pcat-toggle.on {
|
||||
background: var(--pcat-primary);
|
||||
}
|
||||
|
||||
.pcat-toggle.on::after {
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.pcat-toggle-label {
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.pcat-cropper-wrap {
|
||||
height: 360px;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.pcat-crop-hint {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
@import './base.less';
|
||||
@import './sidebar.less';
|
||||
@import './detail.less';
|
||||
@import './drawer.less';
|
||||
@import './modal.less';
|
||||
@import './responsive.less';
|
||||
55
apps/web-antd/src/views/product/category/styles/modal.less
Normal file
55
apps/web-antd/src/views/product/category/styles/modal.less
Normal file
@@ -0,0 +1,55 @@
|
||||
.page-product-category {
|
||||
.pcat-picker-search {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.pcat-picker-list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.pcat-picker-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr 140px auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.pcat-picker-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.pcat-picker-item:hover {
|
||||
background: #fafcff;
|
||||
}
|
||||
|
||||
.pcat-picker-item .name {
|
||||
font-size: 13px;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.pcat-picker-item .spu {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.pcat-picker-item .price {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.pcat-picker-empty {
|
||||
padding: 28px 14px;
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
.page-product-category {
|
||||
@media (width <= 1280px) {
|
||||
.pcat-main {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pcat-left {
|
||||
width: 100%;
|
||||
max-height: 380px;
|
||||
}
|
||||
|
||||
.pcat-attr-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.pcat-info-hd {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pcat-info-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pcat-attr-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pcat-prod-table {
|
||||
min-width: 680px;
|
||||
}
|
||||
|
||||
.pcat-prod-card {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.pcat-picker-item {
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 6px 10px;
|
||||
}
|
||||
|
||||
.pcat-picker-item .spu,
|
||||
.pcat-picker-item .price {
|
||||
grid-column: 2 / 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
173
apps/web-antd/src/views/product/category/styles/sidebar.less
Normal file
173
apps/web-antd/src/views/product/category/styles/sidebar.less
Normal file
@@ -0,0 +1,173 @@
|
||||
.page-product-category {
|
||||
.pcat-left {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
width: 280px;
|
||||
max-height: 640px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border-radius: var(--pcat-radius);
|
||||
box-shadow: var(--pcat-shadow);
|
||||
}
|
||||
|
||||
.pcat-left-hd {
|
||||
flex-shrink: 0;
|
||||
padding: 14px 16px 12px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.pcat-left-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.pcat-left-title span {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.pcat-ch-filter {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.pcat-ch-pill {
|
||||
padding: 3px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.pcat-ch-pill:hover {
|
||||
color: var(--pcat-primary);
|
||||
border-color: var(--pcat-primary);
|
||||
}
|
||||
|
||||
.pcat-ch-pill.active {
|
||||
color: #fff;
|
||||
background: var(--pcat-primary);
|
||||
border-color: var(--pcat-primary);
|
||||
}
|
||||
|
||||
.pcat-search input {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
color: #1a1a2e;
|
||||
outline: none;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.pcat-search input:focus {
|
||||
border-color: var(--pcat-primary);
|
||||
box-shadow: 0 0 0 3px rgb(22 119 255 / 10%);
|
||||
}
|
||||
|
||||
.pcat-left-list {
|
||||
flex: 1;
|
||||
padding: 6px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pcat-left-empty {
|
||||
padding: 30px 16px;
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pcat-cat-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 9px 16px;
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.pcat-cat-item:hover {
|
||||
background: rgb(22 119 255 / 4%);
|
||||
}
|
||||
|
||||
.pcat-cat-item.active {
|
||||
font-weight: 600;
|
||||
color: var(--pcat-primary);
|
||||
background: rgb(22 119 255 / 10%);
|
||||
}
|
||||
|
||||
.pcat-cat-item.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.pcat-drag-handle {
|
||||
flex-shrink: 0;
|
||||
color: #bbb;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.pcat-cat-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pcat-cat-tags {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.pcat-ch-mini {
|
||||
padding: 1px 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 14px;
|
||||
color: #fff;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.pcat-ch-mini.wm {
|
||||
background: #1890ff;
|
||||
}
|
||||
|
||||
.pcat-ch-mini.zt {
|
||||
background: #52c41a;
|
||||
}
|
||||
|
||||
.pcat-ch-mini.ts {
|
||||
background: #fa8c16;
|
||||
}
|
||||
|
||||
.pcat-cat-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
background: #f8f9fb;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.pcat-cat-item.active .pcat-cat-badge {
|
||||
color: var(--pcat-primary);
|
||||
background: rgb(22 119 255 / 18%);
|
||||
}
|
||||
}
|
||||
111
apps/web-antd/src/views/product/category/types.ts
Normal file
111
apps/web-antd/src/views/product/category/types.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 文件职责:分类管理页面类型与常量定义。
|
||||
* 1. 统一页面、组件与组合式函数共享的类型。
|
||||
* 2. 维护渠道展示元数据与格式化工具。
|
||||
*/
|
||||
import type {
|
||||
ProductCategoryChannel,
|
||||
ProductPickerItemDto,
|
||||
ProductStatus,
|
||||
ProductSwitchStatus,
|
||||
} from '#/api/product';
|
||||
|
||||
export type ChannelFilter = 'all' | ProductCategoryChannel;
|
||||
export type DrawerMode = 'create' | 'edit';
|
||||
|
||||
export interface CategoryFormModel {
|
||||
channels: ProductCategoryChannel[];
|
||||
description: string;
|
||||
icon: string;
|
||||
id: string;
|
||||
name: string;
|
||||
sort: number;
|
||||
status: ProductSwitchStatus;
|
||||
}
|
||||
|
||||
export interface CategoryStatsViewModel {
|
||||
dineInCount: number;
|
||||
enabledCategories: number;
|
||||
pickupCount: number;
|
||||
totalCategories: number;
|
||||
totalProducts: number;
|
||||
wmCount: number;
|
||||
}
|
||||
|
||||
export interface CategoryChannelMeta {
|
||||
cardClass: 'ts' | 'wm' | 'zt';
|
||||
filterClass: 'ts' | 'wm' | 'zt';
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
subText: string;
|
||||
}
|
||||
|
||||
export interface ProductPreviewItem extends ProductPickerItemDto {
|
||||
monthlySales: number;
|
||||
}
|
||||
|
||||
export interface StoreOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const CATEGORY_CHANNEL_ORDER: ProductCategoryChannel[] = [
|
||||
'wm',
|
||||
'pickup',
|
||||
'dine_in',
|
||||
];
|
||||
|
||||
export const CATEGORY_CHANNEL_META: Record<
|
||||
ProductCategoryChannel,
|
||||
CategoryChannelMeta
|
||||
> = {
|
||||
wm: {
|
||||
label: '外卖',
|
||||
shortLabel: '外',
|
||||
subText: '外卖平台展示此分类',
|
||||
filterClass: 'wm',
|
||||
cardClass: 'wm',
|
||||
},
|
||||
pickup: {
|
||||
label: '自提',
|
||||
shortLabel: '自',
|
||||
subText: '到店自提展示此分类',
|
||||
filterClass: 'zt',
|
||||
cardClass: 'zt',
|
||||
},
|
||||
dine_in: {
|
||||
label: '堂食',
|
||||
shortLabel: '堂',
|
||||
subText: '堂食扫码点餐展示此分类',
|
||||
filterClass: 'ts',
|
||||
cardClass: 'ts',
|
||||
},
|
||||
};
|
||||
|
||||
/** 获取商品状态文案。 */
|
||||
export function getProductStatusText(status: ProductStatus) {
|
||||
if (status === 'on_sale') return '在售';
|
||||
if (status === 'sold_out') return '售罄';
|
||||
return '下架';
|
||||
}
|
||||
|
||||
/** 获取商品状态样式。 */
|
||||
export function getProductStatusClass(status: ProductStatus) {
|
||||
if (status === 'on_sale') return 'on-sale';
|
||||
if (status === 'sold_out') return 'sold-out';
|
||||
return 'off-shelf';
|
||||
}
|
||||
|
||||
/** 金额格式化。 */
|
||||
export function formatPrice(price: number) {
|
||||
return `¥${price.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/** 稳定生成月销预览值。 */
|
||||
export function deriveMonthlySales(seed: string) {
|
||||
let hash = 0;
|
||||
for (const char of seed) {
|
||||
hash = (hash * 31 + (char.codePointAt(0) ?? 0)) % 10_007;
|
||||
}
|
||||
return (hash % 160) + 20;
|
||||
}
|
||||
@@ -1,60 +1,171 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:商品详情骨架页。
|
||||
* 1. 根据路由参数加载商品详情数据。
|
||||
* 2. 展示基础信息并提供返回入口。
|
||||
* 文件职责:商品详情编辑页。
|
||||
* 1. 根据路由参数加载商品详情与分类。
|
||||
* 2. 提供商品基础信息编辑与保存能力。
|
||||
*/
|
||||
import type { ProductDetailDto } from '#/api/product';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Button, Card, Descriptions, Empty, Spin, Tag } from 'ant-design-vue';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { getProductDetailApi } from '#/api/product';
|
||||
import {
|
||||
getProductCategoryListApi,
|
||||
getProductDetailApi,
|
||||
saveProductApi,
|
||||
} from '#/api/product';
|
||||
|
||||
import {
|
||||
formatCurrency,
|
||||
resolveStatusMeta,
|
||||
tagsToText,
|
||||
textToTags,
|
||||
} from '../list/composables/product-list-page/helpers';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const isLoading = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
const detail = ref<null | ProductDetailDto>(null);
|
||||
const categoryOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||
|
||||
const form = reactive({
|
||||
id: '',
|
||||
name: '',
|
||||
subtitle: '',
|
||||
categoryId: '',
|
||||
kind: 'single' as 'combo' | 'single',
|
||||
description: '',
|
||||
price: 0,
|
||||
originalPrice: null as null | number,
|
||||
stock: 0,
|
||||
status: 'off_shelf' as 'off_shelf' | 'on_sale',
|
||||
tagsText: '',
|
||||
});
|
||||
|
||||
const storeId = computed(() => String(route.query.storeId || ''));
|
||||
const productId = computed(() => String(route.query.productId || ''));
|
||||
|
||||
const statusMeta = computed(() => {
|
||||
if (!detail.value) return resolveStatusMeta('off_shelf');
|
||||
return resolveStatusMeta(detail.value.status);
|
||||
if (form.status === 'on_sale') {
|
||||
return { color: 'green', label: '在售' };
|
||||
}
|
||||
return { color: 'default', label: '下架' };
|
||||
});
|
||||
|
||||
/** 加载商品详情。 */
|
||||
const originalPriceInput = computed<number | undefined>({
|
||||
get: () => form.originalPrice ?? undefined,
|
||||
set: (value) => {
|
||||
form.originalPrice = value ?? null;
|
||||
},
|
||||
});
|
||||
|
||||
/** 将详情映射到编辑表单。 */
|
||||
function patchForm(data: ProductDetailDto) {
|
||||
form.id = data.id;
|
||||
form.name = data.name;
|
||||
form.subtitle = data.subtitle;
|
||||
form.categoryId = data.categoryId;
|
||||
form.kind = data.kind;
|
||||
form.description = data.description;
|
||||
form.price = data.price;
|
||||
form.originalPrice = data.originalPrice;
|
||||
form.stock = data.stock;
|
||||
form.status = data.status === 'on_sale' ? 'on_sale' : 'off_shelf';
|
||||
form.tagsText = tagsToText(data.tags);
|
||||
}
|
||||
|
||||
/** 加载商品详情与分类。 */
|
||||
async function loadDetail() {
|
||||
if (!storeId.value || !productId.value) {
|
||||
detail.value = null;
|
||||
categoryOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
detail.value = await getProductDetailApi({
|
||||
storeId: storeId.value,
|
||||
productId: productId.value,
|
||||
});
|
||||
const [detailData, categories] = await Promise.all([
|
||||
getProductDetailApi({
|
||||
storeId: storeId.value,
|
||||
productId: productId.value,
|
||||
}),
|
||||
getProductCategoryListApi(storeId.value),
|
||||
]);
|
||||
|
||||
detail.value = detailData;
|
||||
categoryOptions.value = categories.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
}));
|
||||
patchForm(detailData);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
detail.value = null;
|
||||
categoryOptions.value = [];
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存商品详情。 */
|
||||
async function saveDetail() {
|
||||
if (!storeId.value || !form.id) return;
|
||||
if (!form.name.trim()) {
|
||||
message.warning('请输入商品名称');
|
||||
return;
|
||||
}
|
||||
if (!form.categoryId) {
|
||||
message.warning('请选择商品分类');
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
const saved = await saveProductApi({
|
||||
storeId: storeId.value,
|
||||
id: form.id,
|
||||
name: form.name.trim(),
|
||||
subtitle: form.subtitle.trim(),
|
||||
categoryId: form.categoryId,
|
||||
kind: form.kind,
|
||||
description: form.description.trim(),
|
||||
price: Number(form.price || 0),
|
||||
originalPrice:
|
||||
form.originalPrice && Number(form.originalPrice) > 0
|
||||
? Number(form.originalPrice)
|
||||
: null,
|
||||
stock: Math.max(0, Math.floor(Number(form.stock || 0))),
|
||||
status: form.status,
|
||||
shelfMode: form.status === 'on_sale' ? 'now' : 'draft',
|
||||
tags: textToTags(form.tagsText),
|
||||
});
|
||||
detail.value = saved;
|
||||
patchForm(saved);
|
||||
message.success('商品详情已保存');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 返回商品列表页。 */
|
||||
function goBack() {
|
||||
router.push('/product/list');
|
||||
@@ -66,7 +177,12 @@ watch([storeId, productId], loadDetail, { immediate: true });
|
||||
<template>
|
||||
<Page title="商品详情" content-class="space-y-4 page-product-detail">
|
||||
<Card :bordered="false">
|
||||
<Button @click="goBack">返回商品列表</Button>
|
||||
<Space>
|
||||
<Button @click="goBack">返回商品列表</Button>
|
||||
<Button type="primary" :loading="isSubmitting" @click="saveDetail">
|
||||
保存商品
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Spin :spinning="isLoading">
|
||||
@@ -85,51 +201,94 @@ watch([storeId, productId], loadDetail, { immediate: true });
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<Descriptions :column="2" bordered class="product-detail-descriptions">
|
||||
<Descriptions.Item label="商品ID">{{ detail.id }}</Descriptions.Item>
|
||||
<Descriptions.Item label="分类">
|
||||
{{ detail.categoryName || '--' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="商品类型">
|
||||
{{ detail.kind === 'combo' ? '套餐' : '单品' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="售价">
|
||||
{{ formatCurrency(detail.price) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="原价">
|
||||
{{
|
||||
detail.originalPrice ? formatCurrency(detail.originalPrice) : '--'
|
||||
}}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="库存">
|
||||
{{ detail.stock }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="月销">
|
||||
{{ detail.salesMonthly }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="沽清模式">
|
||||
{{
|
||||
detail.soldoutMode === 'today'
|
||||
? '今日沽清'
|
||||
: detail.soldoutMode === 'timed'
|
||||
? '定时沽清'
|
||||
: detail.soldoutMode === 'permanent'
|
||||
? '永久沽清'
|
||||
: '--'
|
||||
}}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="标签" :span="2">
|
||||
<div class="product-detail-tags">
|
||||
<Tag v-for="tag in detail.tags" :key="`${detail.id}-${tag}`">
|
||||
{{ tag }}
|
||||
</Tag>
|
||||
<span v-if="detail.tags.length === 0">--</span>
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="描述" :span="2">
|
||||
{{ detail.description || '--' }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Form layout="vertical">
|
||||
<Space style="display: flex; width: 100%">
|
||||
<Form.Item label="商品名称" required style="flex: 1">
|
||||
<Input v-model:value="form.name" :maxlength="30" show-count />
|
||||
</Form.Item>
|
||||
<Form.Item label="副标题" style="flex: 1">
|
||||
<Input v-model:value="form.subtitle" :maxlength="60" show-count />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Space style="display: flex; width: 100%">
|
||||
<Form.Item label="分类" style="flex: 1">
|
||||
<Select
|
||||
v-model:value="form.categoryId"
|
||||
:options="categoryOptions"
|
||||
placeholder="请选择分类"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="商品类型" style="flex: 1">
|
||||
<Select
|
||||
v-model:value="form.kind"
|
||||
:options="[
|
||||
{ label: '单品', value: 'single' },
|
||||
{ label: '套餐', value: 'combo' },
|
||||
]"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" style="flex: 1">
|
||||
<Select
|
||||
v-model:value="form.status"
|
||||
:options="[
|
||||
{ label: '在售', value: 'on_sale' },
|
||||
{ label: '下架', value: 'off_shelf' },
|
||||
]"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Space style="display: flex; width: 100%">
|
||||
<Form.Item label="售价" style="flex: 1">
|
||||
<InputNumber
|
||||
v-model:value="form.price"
|
||||
:min="0"
|
||||
:step="0.5"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="原价" style="flex: 1">
|
||||
<InputNumber
|
||||
v-model:value="originalPriceInput"
|
||||
:min="0"
|
||||
:step="0.5"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="库存" style="flex: 1">
|
||||
<InputNumber
|
||||
v-model:value="form.stock"
|
||||
:min="0"
|
||||
:step="1"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<Form.Item label="标签(逗号分隔)">
|
||||
<Input
|
||||
v-model:value="form.tagsText"
|
||||
placeholder="例如:招牌,新品,辣"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="商品描述">
|
||||
<Input.TextArea
|
||||
v-model:value="form.description"
|
||||
:rows="4"
|
||||
:maxlength="500"
|
||||
show-count
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div class="detail-extra">
|
||||
<span>商品ID:{{ detail.id }}</span>
|
||||
<span>SPU:{{ detail.spuCode }}</span>
|
||||
<span>月销:{{ detail.salesMonthly }}</span>
|
||||
<span>当前售价:{{ formatCurrency(detail.price) }}</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card v-else :bordered="false">
|
||||
|
||||
@@ -59,16 +59,15 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.product-detail-descriptions .ant-descriptions-item-label {
|
||||
width: 120px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.product-detail-tags {
|
||||
.detail-extra {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding-top: 12px;
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
border-top: 1px dashed #e5e7eb;
|
||||
}
|
||||
}
|
||||
|
||||
424
apps/web-antd/src/views/product/labels/index.vue
Normal file
424
apps/web-antd/src/views/product/labels/index.vue
Normal file
@@ -0,0 +1,424 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:商品标签管理页面。
|
||||
* 1. 管理标签基础信息与启停状态。
|
||||
* 2. 管理标签关联商品。
|
||||
*/
|
||||
import type { ProductSwitchStatus } from '#/api/product';
|
||||
import type { StoreListItemDto } from '#/api/store';
|
||||
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Drawer,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
changeProductLabelStatusApi,
|
||||
deleteProductLabelApi,
|
||||
getProductLabelListApi,
|
||||
saveProductLabelApi,
|
||||
searchProductPickerApi,
|
||||
} from '#/api/product';
|
||||
import { getStoreListApi } from '#/api/store';
|
||||
|
||||
type StatusFilter = '' | ProductSwitchStatus;
|
||||
|
||||
interface LabelRow {
|
||||
color: string;
|
||||
description: string;
|
||||
id: string;
|
||||
name: string;
|
||||
productCount: number;
|
||||
productIds: string[];
|
||||
sort: number;
|
||||
status: ProductSwitchStatus;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const stores = ref<StoreListItemDto[]>([]);
|
||||
const selectedStoreId = ref('');
|
||||
const isStoreLoading = ref(false);
|
||||
|
||||
const rows = ref<LabelRow[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const keyword = ref('');
|
||||
const statusFilter = ref<StatusFilter>('');
|
||||
|
||||
const pickerOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||
const isDrawerOpen = ref(false);
|
||||
const isDrawerSubmitting = ref(false);
|
||||
const editingLabelId = ref('');
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
color: '#1677ff',
|
||||
status: 'enabled' as ProductSwitchStatus,
|
||||
sort: 1,
|
||||
productIds: [] as string[],
|
||||
});
|
||||
|
||||
const storeOptions = computed(() =>
|
||||
stores.value.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})),
|
||||
);
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部状态', value: '' },
|
||||
{ label: '启用', value: 'enabled' },
|
||||
{ label: '停用', value: 'disabled' },
|
||||
];
|
||||
|
||||
const drawerTitle = computed(() =>
|
||||
editingLabelId.value ? '编辑标签' : '新增标签',
|
||||
);
|
||||
|
||||
/** 控制抽屉开关。 */
|
||||
function setDrawerOpen(value: boolean) {
|
||||
isDrawerOpen.value = value;
|
||||
}
|
||||
|
||||
/** 加载门店列表。 */
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载标签列表。 */
|
||||
async function loadLabels() {
|
||||
if (!selectedStoreId.value) {
|
||||
rows.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const result = await getProductLabelListApi({
|
||||
storeId: selectedStoreId.value,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
status: (statusFilter.value || undefined) as
|
||||
| ProductSwitchStatus
|
||||
| undefined,
|
||||
});
|
||||
rows.value = result as LabelRow[];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
rows.value = [];
|
||||
message.error('加载标签失败');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载商品选择器。 */
|
||||
async function loadPickerOptions() {
|
||||
if (!selectedStoreId.value) {
|
||||
pickerOptions.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const list = await searchProductPickerApi({
|
||||
storeId: selectedStoreId.value,
|
||||
limit: 500,
|
||||
});
|
||||
pickerOptions.value = list.map((item) => ({
|
||||
label: `${item.name}(${item.spuCode})`,
|
||||
value: item.id,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
pickerOptions.value = [];
|
||||
message.error('加载商品失败');
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置表单。 */
|
||||
function resetForm() {
|
||||
editingLabelId.value = '';
|
||||
form.name = '';
|
||||
form.description = '';
|
||||
form.color = '#1677ff';
|
||||
form.status = 'enabled';
|
||||
form.sort = rows.value.length + 1;
|
||||
form.productIds = [];
|
||||
}
|
||||
|
||||
/** 打开新增抽屉。 */
|
||||
async function openCreateDrawer() {
|
||||
resetForm();
|
||||
await loadPickerOptions();
|
||||
isDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
/** 打开编辑抽屉。 */
|
||||
async function openEditDrawer(row: LabelRow) {
|
||||
editingLabelId.value = row.id;
|
||||
form.name = row.name;
|
||||
form.description = row.description;
|
||||
form.color = row.color;
|
||||
form.status = row.status;
|
||||
form.sort = row.sort;
|
||||
form.productIds = [...row.productIds];
|
||||
await loadPickerOptions();
|
||||
isDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
/** 保存标签。 */
|
||||
async function submitDrawer() {
|
||||
if (!selectedStoreId.value) return;
|
||||
if (!form.name.trim()) {
|
||||
message.warning('请输入标签名称');
|
||||
return;
|
||||
}
|
||||
|
||||
isDrawerSubmitting.value = true;
|
||||
try {
|
||||
await saveProductLabelApi({
|
||||
storeId: selectedStoreId.value,
|
||||
id: editingLabelId.value || undefined,
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
color: form.color.trim() || '#1677ff',
|
||||
status: form.status,
|
||||
sort: form.sort,
|
||||
productIds: [...form.productIds],
|
||||
});
|
||||
message.success(editingLabelId.value ? '标签已更新' : '标签已创建');
|
||||
isDrawerOpen.value = false;
|
||||
await loadLabels();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
isDrawerSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除标签。 */
|
||||
function removeLabel(row: LabelRow) {
|
||||
if (!selectedStoreId.value) return;
|
||||
Modal.confirm({
|
||||
title: `确认删除标签「${row.name}」吗?`,
|
||||
async onOk() {
|
||||
await deleteProductLabelApi({
|
||||
storeId: selectedStoreId.value,
|
||||
labelId: row.id,
|
||||
});
|
||||
message.success('标签已删除');
|
||||
await loadLabels();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 切换标签状态。 */
|
||||
async function toggleLabelStatus(row: LabelRow, checked: boolean) {
|
||||
if (!selectedStoreId.value) return;
|
||||
await changeProductLabelStatusApi({
|
||||
storeId: selectedStoreId.value,
|
||||
labelId: row.id,
|
||||
status: checked ? 'enabled' : 'disabled',
|
||||
});
|
||||
row.status = checked ? 'enabled' : 'disabled';
|
||||
message.success('状态已更新');
|
||||
}
|
||||
|
||||
/** 重置筛选条件。 */
|
||||
function resetFilters() {
|
||||
keyword.value = '';
|
||||
statusFilter.value = '';
|
||||
loadLabels();
|
||||
}
|
||||
|
||||
watch(selectedStoreId, loadLabels);
|
||||
|
||||
onMounted(loadStores);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page title="商品标签" content-class="space-y-4 page-product-labels">
|
||||
<Card :bordered="false">
|
||||
<Space wrap>
|
||||
<Select
|
||||
v-model:value="selectedStoreId"
|
||||
:options="storeOptions"
|
||||
:loading="isStoreLoading"
|
||||
style="width: 240px"
|
||||
placeholder="请选择门店"
|
||||
/>
|
||||
<Input
|
||||
v-model:value="keyword"
|
||||
style="width: 220px"
|
||||
placeholder="搜索标签名称"
|
||||
/>
|
||||
<Select
|
||||
v-model:value="statusFilter"
|
||||
:options="statusOptions"
|
||||
style="width: 140px"
|
||||
/>
|
||||
<Button type="primary" @click="loadLabels">查询</Button>
|
||||
<Button @click="resetFilters">重置</Button>
|
||||
<Button type="primary" @click="openCreateDrawer">新增标签</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card v-if="!selectedStoreId" :bordered="false">
|
||||
<Empty description="暂无门店,请先创建门店" />
|
||||
</Card>
|
||||
|
||||
<Card v-else :bordered="false">
|
||||
<Table
|
||||
row-key="id"
|
||||
:data-source="rows"
|
||||
:loading="isLoading"
|
||||
:pagination="false"
|
||||
size="middle"
|
||||
>
|
||||
<Table.Column title="标签名称" key="name" :width="160">
|
||||
<template #default="{ record }">
|
||||
<Tag :color="record.color">{{ record.name }}</Tag>
|
||||
</template>
|
||||
</Table.Column>
|
||||
<Table.Column title="描述" data-index="description" key="description" />
|
||||
<Table.Column
|
||||
title="关联商品数"
|
||||
data-index="productCount"
|
||||
key="productCount"
|
||||
:width="100"
|
||||
/>
|
||||
<Table.Column title="排序" data-index="sort" key="sort" :width="80" />
|
||||
<Table.Column title="状态" key="status" :width="90">
|
||||
<template #default="{ record }">
|
||||
<Switch
|
||||
:checked="record.status === 'enabled'"
|
||||
size="small"
|
||||
@change="(checked) => toggleLabelStatus(record, checked === true)"
|
||||
/>
|
||||
</template>
|
||||
</Table.Column>
|
||||
<Table.Column
|
||||
title="更新时间"
|
||||
data-index="updatedAt"
|
||||
key="updatedAt"
|
||||
:width="170"
|
||||
/>
|
||||
<Table.Column title="操作" key="action" :width="170">
|
||||
<template #default="{ record }">
|
||||
<Space size="small">
|
||||
<Button size="small" @click="openEditDrawer(record)">编辑</Button>
|
||||
<Button danger size="small" @click="removeLabel(record)">
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</Table.Column>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
:open="isDrawerOpen"
|
||||
:title="drawerTitle"
|
||||
width="520"
|
||||
:destroy-on-close="true"
|
||||
@update:open="setDrawerOpen"
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="标签名称" required>
|
||||
<Input v-model:value="form.name" :maxlength="20" show-count />
|
||||
</Form.Item>
|
||||
<Form.Item label="标签颜色">
|
||||
<Input v-model:value="form.color" placeholder="#1677ff" />
|
||||
</Form.Item>
|
||||
<Form.Item label="标签描述">
|
||||
<Input v-model:value="form.description" :maxlength="100" show-count />
|
||||
</Form.Item>
|
||||
<Form.Item label="关联商品">
|
||||
<Select
|
||||
v-model:value="form.productIds"
|
||||
mode="multiple"
|
||||
:options="pickerOptions"
|
||||
placeholder="请选择商品"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Space style="display: flex; width: 100%">
|
||||
<Form.Item label="排序" style="flex: 1">
|
||||
<InputNumber
|
||||
v-model:value="form.sort"
|
||||
:min="1"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" style="flex: 1">
|
||||
<Select
|
||||
v-model:value="form.status"
|
||||
:options="[
|
||||
{ label: '启用', value: 'enabled' },
|
||||
{ label: '停用', value: 'disabled' },
|
||||
]"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form>
|
||||
|
||||
<template #footer>
|
||||
<Space>
|
||||
<Button @click="setDrawerOpen(false)">取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="isDrawerSubmitting"
|
||||
@click="submitDrawer"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</Drawer>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
/* 文件职责:商品标签页面样式。 */
|
||||
.page-product-labels {
|
||||
:deep(.ant-table-cell) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
559
apps/web-antd/src/views/product/schedule/index.vue
Normal file
559
apps/web-antd/src/views/product/schedule/index.vue
Normal file
@@ -0,0 +1,559 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:时段供应页面。
|
||||
* 1. 管理供应时段模板。
|
||||
* 2. 管理时段模板与商品关联关系。
|
||||
*/
|
||||
import type { ProductSwitchStatus } from '#/api/product';
|
||||
import type { StoreListItemDto } from '#/api/store';
|
||||
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Drawer,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
changeProductScheduleStatusApi,
|
||||
deleteProductScheduleApi,
|
||||
getProductScheduleListApi,
|
||||
saveProductScheduleApi,
|
||||
searchProductPickerApi,
|
||||
} from '#/api/product';
|
||||
import { getStoreListApi } from '#/api/store';
|
||||
|
||||
type StatusFilter = '' | ProductSwitchStatus;
|
||||
|
||||
interface SlotForm {
|
||||
id: string;
|
||||
weekDays: number[];
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
|
||||
interface ScheduleRow {
|
||||
description: string;
|
||||
id: string;
|
||||
name: string;
|
||||
productCount: number;
|
||||
productIds: string[];
|
||||
slots: SlotForm[];
|
||||
sort: number;
|
||||
status: ProductSwitchStatus;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const stores = ref<StoreListItemDto[]>([]);
|
||||
const selectedStoreId = ref('');
|
||||
const isStoreLoading = ref(false);
|
||||
|
||||
const rows = ref<ScheduleRow[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const keyword = ref('');
|
||||
const statusFilter = ref<StatusFilter>('');
|
||||
|
||||
const pickerOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||
const isDrawerOpen = ref(false);
|
||||
const isDrawerSubmitting = ref(false);
|
||||
const editingScheduleId = ref('');
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
status: 'enabled' as ProductSwitchStatus,
|
||||
sort: 1,
|
||||
productIds: [] as string[],
|
||||
slots: [] as SlotForm[],
|
||||
});
|
||||
|
||||
const weekDayOptions = [
|
||||
{ label: '周一', value: 1 },
|
||||
{ label: '周二', value: 2 },
|
||||
{ label: '周三', value: 3 },
|
||||
{ label: '周四', value: 4 },
|
||||
{ label: '周五', value: 5 },
|
||||
{ label: '周六', value: 6 },
|
||||
{ label: '周日', value: 7 },
|
||||
];
|
||||
|
||||
const storeOptions = computed(() =>
|
||||
stores.value.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})),
|
||||
);
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部状态', value: '' },
|
||||
{ label: '启用', value: 'enabled' },
|
||||
{ label: '停用', value: 'disabled' },
|
||||
];
|
||||
|
||||
const drawerTitle = computed(() =>
|
||||
editingScheduleId.value ? '编辑时段' : '新增时段',
|
||||
);
|
||||
|
||||
/** 控制抽屉开关。 */
|
||||
function setDrawerOpen(value: boolean) {
|
||||
isDrawerOpen.value = value;
|
||||
}
|
||||
|
||||
/** 加载门店列表。 */
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载时段列表。 */
|
||||
async function loadSchedules() {
|
||||
if (!selectedStoreId.value) {
|
||||
rows.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const result = await getProductScheduleListApi({
|
||||
storeId: selectedStoreId.value,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
status: (statusFilter.value || undefined) as
|
||||
| ProductSwitchStatus
|
||||
| undefined,
|
||||
});
|
||||
rows.value = result as ScheduleRow[];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
rows.value = [];
|
||||
message.error('加载时段失败');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载商品选择器。 */
|
||||
async function loadPickerOptions() {
|
||||
if (!selectedStoreId.value) {
|
||||
pickerOptions.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const list = await searchProductPickerApi({
|
||||
storeId: selectedStoreId.value,
|
||||
limit: 500,
|
||||
});
|
||||
pickerOptions.value = list.map((item) => ({
|
||||
label: `${item.name}(${item.spuCode})`,
|
||||
value: item.id,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
pickerOptions.value = [];
|
||||
message.error('加载商品失败');
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置表单。 */
|
||||
function resetForm() {
|
||||
editingScheduleId.value = '';
|
||||
form.name = '';
|
||||
form.description = '';
|
||||
form.status = 'enabled';
|
||||
form.sort = rows.value.length + 1;
|
||||
form.productIds = [];
|
||||
form.slots = [
|
||||
{
|
||||
id: '',
|
||||
weekDays: [1, 2, 3, 4, 5, 6, 7],
|
||||
startTime: '09:00',
|
||||
endTime: '21:00',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 打开新增抽屉。 */
|
||||
async function openCreateDrawer() {
|
||||
resetForm();
|
||||
await loadPickerOptions();
|
||||
isDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
/** 打开编辑抽屉。 */
|
||||
async function openEditDrawer(row: ScheduleRow) {
|
||||
editingScheduleId.value = row.id;
|
||||
form.name = row.name;
|
||||
form.description = row.description;
|
||||
form.status = row.status;
|
||||
form.sort = row.sort;
|
||||
form.productIds = [...row.productIds];
|
||||
form.slots = row.slots.map((slot) => ({
|
||||
...slot,
|
||||
weekDays: [...slot.weekDays],
|
||||
}));
|
||||
if (form.slots.length === 0) {
|
||||
form.slots = [
|
||||
{
|
||||
id: '',
|
||||
weekDays: [1, 2, 3, 4, 5, 6, 7],
|
||||
startTime: '09:00',
|
||||
endTime: '21:00',
|
||||
},
|
||||
];
|
||||
}
|
||||
await loadPickerOptions();
|
||||
isDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
/** 新增时段行。 */
|
||||
function addSlot() {
|
||||
form.slots.push({
|
||||
id: '',
|
||||
weekDays: [1, 2, 3, 4, 5, 6, 7],
|
||||
startTime: '09:00',
|
||||
endTime: '21:00',
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除时段行。 */
|
||||
function removeSlot(index: number) {
|
||||
if (form.slots.length <= 1) {
|
||||
message.warning('至少保留一个时段');
|
||||
return;
|
||||
}
|
||||
form.slots.splice(index, 1);
|
||||
}
|
||||
|
||||
/** 保存时段。 */
|
||||
async function submitDrawer() {
|
||||
if (!selectedStoreId.value) return;
|
||||
if (!form.name.trim()) {
|
||||
message.warning('请输入时段名称');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
form.slots.some(
|
||||
(slot) =>
|
||||
slot.weekDays.length === 0 ||
|
||||
!slot.startTime.trim() ||
|
||||
!slot.endTime.trim(),
|
||||
)
|
||||
) {
|
||||
message.warning('请完整填写时段信息');
|
||||
return;
|
||||
}
|
||||
|
||||
isDrawerSubmitting.value = true;
|
||||
try {
|
||||
await saveProductScheduleApi({
|
||||
storeId: selectedStoreId.value,
|
||||
id: editingScheduleId.value || undefined,
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
status: form.status,
|
||||
sort: form.sort,
|
||||
productIds: [...form.productIds],
|
||||
slots: form.slots.map((slot) => ({
|
||||
id: slot.id || undefined,
|
||||
weekDays: [...slot.weekDays],
|
||||
startTime: slot.startTime.trim(),
|
||||
endTime: slot.endTime.trim(),
|
||||
})),
|
||||
});
|
||||
message.success(editingScheduleId.value ? '时段已更新' : '时段已创建');
|
||||
isDrawerOpen.value = false;
|
||||
await loadSchedules();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
isDrawerSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除时段模板。 */
|
||||
function removeSchedule(row: ScheduleRow) {
|
||||
if (!selectedStoreId.value) return;
|
||||
Modal.confirm({
|
||||
title: `确认删除时段「${row.name}」吗?`,
|
||||
async onOk() {
|
||||
await deleteProductScheduleApi({
|
||||
storeId: selectedStoreId.value,
|
||||
scheduleId: row.id,
|
||||
});
|
||||
message.success('时段已删除');
|
||||
await loadSchedules();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 切换时段状态。 */
|
||||
async function toggleScheduleStatus(row: ScheduleRow, checked: boolean) {
|
||||
if (!selectedStoreId.value) return;
|
||||
await changeProductScheduleStatusApi({
|
||||
storeId: selectedStoreId.value,
|
||||
scheduleId: row.id,
|
||||
status: checked ? 'enabled' : 'disabled',
|
||||
});
|
||||
row.status = checked ? 'enabled' : 'disabled';
|
||||
message.success('状态已更新');
|
||||
}
|
||||
|
||||
/** 格式化星期展示文案。 */
|
||||
function formatWeekDays(weekDays: number[]) {
|
||||
const map: Record<number, string> = {
|
||||
1: '一',
|
||||
2: '二',
|
||||
3: '三',
|
||||
4: '四',
|
||||
5: '五',
|
||||
6: '六',
|
||||
7: '日',
|
||||
};
|
||||
return [...weekDays]
|
||||
.toSorted((a, b) => a - b)
|
||||
.map((day) => map[day] || day)
|
||||
.join('/');
|
||||
}
|
||||
|
||||
/** 重置筛选。 */
|
||||
function resetFilters() {
|
||||
keyword.value = '';
|
||||
statusFilter.value = '';
|
||||
loadSchedules();
|
||||
}
|
||||
|
||||
watch(selectedStoreId, loadSchedules);
|
||||
|
||||
onMounted(loadStores);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page title="时段供应" content-class="space-y-4 page-product-schedule">
|
||||
<Card :bordered="false">
|
||||
<Space wrap>
|
||||
<Select
|
||||
v-model:value="selectedStoreId"
|
||||
:options="storeOptions"
|
||||
:loading="isStoreLoading"
|
||||
style="width: 240px"
|
||||
placeholder="请选择门店"
|
||||
/>
|
||||
<Input
|
||||
v-model:value="keyword"
|
||||
style="width: 220px"
|
||||
placeholder="搜索时段名称"
|
||||
/>
|
||||
<Select
|
||||
v-model:value="statusFilter"
|
||||
:options="statusOptions"
|
||||
style="width: 140px"
|
||||
/>
|
||||
<Button type="primary" @click="loadSchedules">查询</Button>
|
||||
<Button @click="resetFilters">重置</Button>
|
||||
<Button type="primary" @click="openCreateDrawer">新增时段</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card v-if="!selectedStoreId" :bordered="false">
|
||||
<Empty description="暂无门店,请先创建门店" />
|
||||
</Card>
|
||||
|
||||
<Card v-else :bordered="false">
|
||||
<Table
|
||||
row-key="id"
|
||||
:data-source="rows"
|
||||
:loading="isLoading"
|
||||
:pagination="false"
|
||||
size="middle"
|
||||
>
|
||||
<Table.Column
|
||||
title="时段名称"
|
||||
data-index="name"
|
||||
key="name"
|
||||
:width="180"
|
||||
/>
|
||||
<Table.Column title="供应时段" key="slots">
|
||||
<template #default="{ record }">
|
||||
<Space direction="vertical" size="small">
|
||||
<Tag
|
||||
v-for="slot in record.slots"
|
||||
:key="slot.id || `${slot.startTime}-${slot.endTime}`"
|
||||
>
|
||||
周{{ formatWeekDays(slot.weekDays) }} {{ slot.startTime }}-{{
|
||||
slot.endTime
|
||||
}}
|
||||
</Tag>
|
||||
</Space>
|
||||
</template>
|
||||
</Table.Column>
|
||||
<Table.Column
|
||||
title="关联商品数"
|
||||
data-index="productCount"
|
||||
key="productCount"
|
||||
:width="100"
|
||||
/>
|
||||
<Table.Column title="状态" key="status" :width="90">
|
||||
<template #default="{ record }">
|
||||
<Switch
|
||||
:checked="record.status === 'enabled'"
|
||||
size="small"
|
||||
@change="
|
||||
(checked) => toggleScheduleStatus(record, checked === true)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</Table.Column>
|
||||
<Table.Column
|
||||
title="更新时间"
|
||||
data-index="updatedAt"
|
||||
key="updatedAt"
|
||||
:width="170"
|
||||
/>
|
||||
<Table.Column title="操作" key="action" :width="170">
|
||||
<template #default="{ record }">
|
||||
<Space size="small">
|
||||
<Button size="small" @click="openEditDrawer(record)">编辑</Button>
|
||||
<Button danger size="small" @click="removeSchedule(record)">
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</Table.Column>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
:open="isDrawerOpen"
|
||||
:title="drawerTitle"
|
||||
width="660"
|
||||
:destroy-on-close="true"
|
||||
@update:open="setDrawerOpen"
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="时段名称" required>
|
||||
<Input v-model:value="form.name" :maxlength="30" show-count />
|
||||
</Form.Item>
|
||||
<Form.Item label="时段描述">
|
||||
<Input v-model:value="form.description" :maxlength="100" show-count />
|
||||
</Form.Item>
|
||||
<Form.Item label="关联商品">
|
||||
<Select
|
||||
v-model:value="form.productIds"
|
||||
mode="multiple"
|
||||
:options="pickerOptions"
|
||||
placeholder="请选择商品"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Divider orientation="left">时段配置</Divider>
|
||||
<div v-for="(slot, index) in form.slots" :key="index" class="slot-row">
|
||||
<Select
|
||||
v-model:value="slot.weekDays"
|
||||
mode="multiple"
|
||||
:options="weekDayOptions"
|
||||
style="width: 220px"
|
||||
placeholder="选择星期"
|
||||
/>
|
||||
<Input
|
||||
v-model:value="slot.startTime"
|
||||
style="width: 110px"
|
||||
placeholder="开始 HH:mm"
|
||||
/>
|
||||
<Input
|
||||
v-model:value="slot.endTime"
|
||||
style="width: 110px"
|
||||
placeholder="结束 HH:mm"
|
||||
/>
|
||||
<Button danger @click="removeSlot(index)">删除</Button>
|
||||
</div>
|
||||
<Button @click="addSlot">新增时段</Button>
|
||||
|
||||
<Divider />
|
||||
<Space style="display: flex; width: 100%">
|
||||
<Form.Item label="排序" style="flex: 1">
|
||||
<InputNumber
|
||||
v-model:value="form.sort"
|
||||
:min="1"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" style="flex: 1">
|
||||
<Select
|
||||
v-model:value="form.status"
|
||||
:options="[
|
||||
{ label: '启用', value: 'enabled' },
|
||||
{ label: '停用', value: 'disabled' },
|
||||
]"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form>
|
||||
|
||||
<template #footer>
|
||||
<Space>
|
||||
<Button @click="setDrawerOpen(false)">取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="isDrawerSubmitting"
|
||||
@click="submitDrawer"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</Drawer>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
/* 文件职责:时段供应页面样式。 */
|
||||
.page-product-schedule {
|
||||
.slot-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
:deep(.ant-table-cell) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
502
apps/web-antd/src/views/product/specs/index.vue
Normal file
502
apps/web-antd/src/views/product/specs/index.vue
Normal file
@@ -0,0 +1,502 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:规格做法管理页面。
|
||||
* 1. 管理规格模板与规格值。
|
||||
* 2. 管理规格与商品关联关系。
|
||||
*/
|
||||
import type { ProductSwitchStatus } from '#/api/product';
|
||||
import type { StoreListItemDto } from '#/api/store';
|
||||
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Drawer,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
changeProductSpecStatusApi,
|
||||
deleteProductSpecApi,
|
||||
getProductSpecListApi,
|
||||
saveProductSpecApi,
|
||||
searchProductPickerApi,
|
||||
} from '#/api/product';
|
||||
import { getStoreListApi } from '#/api/store';
|
||||
|
||||
type StatusFilter = '' | ProductSwitchStatus;
|
||||
|
||||
interface SpecValueForm {
|
||||
id: string;
|
||||
name: string;
|
||||
extraPrice: number;
|
||||
sort: number;
|
||||
}
|
||||
|
||||
interface SpecRow {
|
||||
description: string;
|
||||
id: string;
|
||||
name: string;
|
||||
productCount: number;
|
||||
productIds: string[];
|
||||
sort: number;
|
||||
status: ProductSwitchStatus;
|
||||
updatedAt: string;
|
||||
values: SpecValueForm[];
|
||||
}
|
||||
|
||||
const stores = ref<StoreListItemDto[]>([]);
|
||||
const selectedStoreId = ref('');
|
||||
const isStoreLoading = ref(false);
|
||||
|
||||
const rows = ref<SpecRow[]>([]);
|
||||
const isLoading = ref(false);
|
||||
const keyword = ref('');
|
||||
const statusFilter = ref<StatusFilter>('');
|
||||
|
||||
const pickerOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||
const isDrawerOpen = ref(false);
|
||||
const isDrawerSubmitting = ref(false);
|
||||
const editingSpecId = ref('');
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
status: 'enabled' as ProductSwitchStatus,
|
||||
sort: 1,
|
||||
productIds: [] as string[],
|
||||
values: [] as SpecValueForm[],
|
||||
});
|
||||
|
||||
const storeOptions = computed(() =>
|
||||
stores.value.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
})),
|
||||
);
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部状态', value: '' },
|
||||
{ label: '启用', value: 'enabled' },
|
||||
{ label: '停用', value: 'disabled' },
|
||||
];
|
||||
|
||||
const drawerTitle = computed(() =>
|
||||
editingSpecId.value ? '编辑规格' : '新增规格',
|
||||
);
|
||||
|
||||
/** 控制抽屉开关。 */
|
||||
function setDrawerOpen(value: boolean) {
|
||||
isDrawerOpen.value = value;
|
||||
}
|
||||
|
||||
/** 加载门店列表。 */
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载规格列表。 */
|
||||
async function loadSpecs() {
|
||||
if (!selectedStoreId.value) {
|
||||
rows.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const result = await getProductSpecListApi({
|
||||
storeId: selectedStoreId.value,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
status: (statusFilter.value || undefined) as
|
||||
| ProductSwitchStatus
|
||||
| undefined,
|
||||
});
|
||||
rows.value = result as SpecRow[];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
rows.value = [];
|
||||
message.error('加载规格失败');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载商品选择器。 */
|
||||
async function loadPickerOptions() {
|
||||
if (!selectedStoreId.value) {
|
||||
pickerOptions.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const list = await searchProductPickerApi({
|
||||
storeId: selectedStoreId.value,
|
||||
limit: 500,
|
||||
});
|
||||
pickerOptions.value = list.map((item) => ({
|
||||
label: `${item.name}(${item.spuCode})`,
|
||||
value: item.id,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
pickerOptions.value = [];
|
||||
message.error('加载商品失败');
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化默认表单。 */
|
||||
function resetForm() {
|
||||
editingSpecId.value = '';
|
||||
form.name = '';
|
||||
form.description = '';
|
||||
form.status = 'enabled';
|
||||
form.sort = rows.value.length + 1;
|
||||
form.productIds = [];
|
||||
form.values = [
|
||||
{
|
||||
id: '',
|
||||
name: '',
|
||||
extraPrice: 0,
|
||||
sort: 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 打开新增抽屉。 */
|
||||
async function openCreateDrawer() {
|
||||
resetForm();
|
||||
await loadPickerOptions();
|
||||
isDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
/** 打开编辑抽屉。 */
|
||||
async function openEditDrawer(row: SpecRow) {
|
||||
editingSpecId.value = row.id;
|
||||
form.name = row.name;
|
||||
form.description = row.description;
|
||||
form.status = row.status;
|
||||
form.sort = row.sort;
|
||||
form.productIds = [...row.productIds];
|
||||
form.values = row.values.map((item) => ({ ...item }));
|
||||
if (form.values.length === 0) {
|
||||
form.values = [{ id: '', name: '', extraPrice: 0, sort: 1 }];
|
||||
}
|
||||
await loadPickerOptions();
|
||||
isDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
/** 增加规格值行。 */
|
||||
function addSpecValue() {
|
||||
form.values.push({
|
||||
id: '',
|
||||
name: '',
|
||||
extraPrice: 0,
|
||||
sort: form.values.length + 1,
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除规格值行。 */
|
||||
function removeSpecValue(index: number) {
|
||||
if (form.values.length <= 1) {
|
||||
message.warning('至少保留一个规格值');
|
||||
return;
|
||||
}
|
||||
form.values.splice(index, 1);
|
||||
form.values.forEach((item, idx) => {
|
||||
item.sort = idx + 1;
|
||||
});
|
||||
}
|
||||
|
||||
/** 保存规格。 */
|
||||
async function submitDrawer() {
|
||||
if (!selectedStoreId.value) return;
|
||||
if (!form.name.trim()) {
|
||||
message.warning('请输入规格名称');
|
||||
return;
|
||||
}
|
||||
if (form.values.some((item) => !item.name.trim())) {
|
||||
message.warning('规格值名称不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
isDrawerSubmitting.value = true;
|
||||
try {
|
||||
await saveProductSpecApi({
|
||||
storeId: selectedStoreId.value,
|
||||
id: editingSpecId.value || undefined,
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
status: form.status,
|
||||
sort: form.sort,
|
||||
productIds: [...form.productIds],
|
||||
values: form.values.map((item) => ({
|
||||
id: item.id || undefined,
|
||||
name: item.name.trim(),
|
||||
extraPrice: item.extraPrice,
|
||||
sort: item.sort,
|
||||
})),
|
||||
});
|
||||
message.success(editingSpecId.value ? '规格已更新' : '规格已创建');
|
||||
isDrawerOpen.value = false;
|
||||
await loadSpecs();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
isDrawerSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除规格。 */
|
||||
function removeSpec(row: SpecRow) {
|
||||
if (!selectedStoreId.value) return;
|
||||
Modal.confirm({
|
||||
title: `确认删除规格「${row.name}」吗?`,
|
||||
async onOk() {
|
||||
await deleteProductSpecApi({
|
||||
storeId: selectedStoreId.value,
|
||||
specId: row.id,
|
||||
});
|
||||
message.success('规格已删除');
|
||||
await loadSpecs();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 切换规格启停状态。 */
|
||||
async function toggleSpecStatus(row: SpecRow, checked: boolean) {
|
||||
if (!selectedStoreId.value) return;
|
||||
await changeProductSpecStatusApi({
|
||||
storeId: selectedStoreId.value,
|
||||
specId: row.id,
|
||||
status: checked ? 'enabled' : 'disabled',
|
||||
});
|
||||
row.status = checked ? 'enabled' : 'disabled';
|
||||
message.success('状态已更新');
|
||||
}
|
||||
|
||||
/** 重置筛选。 */
|
||||
function resetFilters() {
|
||||
keyword.value = '';
|
||||
statusFilter.value = '';
|
||||
loadSpecs();
|
||||
}
|
||||
|
||||
watch(selectedStoreId, loadSpecs);
|
||||
|
||||
onMounted(loadStores);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page title="规格做法" content-class="space-y-4 page-product-specs">
|
||||
<Card :bordered="false">
|
||||
<Space wrap>
|
||||
<Select
|
||||
v-model:value="selectedStoreId"
|
||||
:options="storeOptions"
|
||||
:loading="isStoreLoading"
|
||||
style="width: 240px"
|
||||
placeholder="请选择门店"
|
||||
/>
|
||||
<Input
|
||||
v-model:value="keyword"
|
||||
style="width: 220px"
|
||||
placeholder="搜索规格名称"
|
||||
/>
|
||||
<Select
|
||||
v-model:value="statusFilter"
|
||||
:options="statusOptions"
|
||||
style="width: 140px"
|
||||
/>
|
||||
<Button type="primary" @click="loadSpecs">查询</Button>
|
||||
<Button @click="resetFilters">重置</Button>
|
||||
<Button type="primary" @click="openCreateDrawer">新增规格</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card v-if="!selectedStoreId" :bordered="false">
|
||||
<Empty description="暂无门店,请先创建门店" />
|
||||
</Card>
|
||||
|
||||
<Card v-else :bordered="false">
|
||||
<Table
|
||||
row-key="id"
|
||||
:data-source="rows"
|
||||
:loading="isLoading"
|
||||
:pagination="false"
|
||||
size="middle"
|
||||
>
|
||||
<Table.Column
|
||||
title="规格名称"
|
||||
data-index="name"
|
||||
key="name"
|
||||
:width="160"
|
||||
/>
|
||||
<Table.Column title="规格值" key="values">
|
||||
<template #default="{ record }">
|
||||
<Space wrap size="small">
|
||||
<Tag v-for="value in record.values" :key="value.id || value.name">
|
||||
{{ value.name }}
|
||||
<template v-if="value.extraPrice">
|
||||
(+{{ value.extraPrice }})
|
||||
</template>
|
||||
</Tag>
|
||||
</Space>
|
||||
</template>
|
||||
</Table.Column>
|
||||
<Table.Column
|
||||
title="关联商品数"
|
||||
data-index="productCount"
|
||||
key="productCount"
|
||||
:width="100"
|
||||
/>
|
||||
<Table.Column title="排序" data-index="sort" key="sort" :width="80" />
|
||||
<Table.Column title="状态" key="status" :width="90">
|
||||
<template #default="{ record }">
|
||||
<Switch
|
||||
:checked="record.status === 'enabled'"
|
||||
size="small"
|
||||
@change="(checked) => toggleSpecStatus(record, checked === true)"
|
||||
/>
|
||||
</template>
|
||||
</Table.Column>
|
||||
<Table.Column
|
||||
title="更新时间"
|
||||
data-index="updatedAt"
|
||||
key="updatedAt"
|
||||
:width="170"
|
||||
/>
|
||||
<Table.Column title="操作" key="action" :width="170">
|
||||
<template #default="{ record }">
|
||||
<Space size="small">
|
||||
<Button size="small" @click="openEditDrawer(record)">编辑</Button>
|
||||
<Button danger size="small" @click="removeSpec(record)">
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</Table.Column>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
:open="isDrawerOpen"
|
||||
:title="drawerTitle"
|
||||
width="560"
|
||||
:destroy-on-close="true"
|
||||
@update:open="setDrawerOpen"
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="规格名称" required>
|
||||
<Input v-model:value="form.name" :maxlength="20" show-count />
|
||||
</Form.Item>
|
||||
<Form.Item label="规格描述">
|
||||
<Input v-model:value="form.description" :maxlength="100" show-count />
|
||||
</Form.Item>
|
||||
<Form.Item label="关联商品">
|
||||
<Select
|
||||
v-model:value="form.productIds"
|
||||
mode="multiple"
|
||||
:options="pickerOptions"
|
||||
placeholder="请选择商品"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Divider orientation="left">规格值</Divider>
|
||||
<div
|
||||
v-for="(item, index) in form.values"
|
||||
:key="index"
|
||||
class="spec-value-row"
|
||||
>
|
||||
<Input v-model:value="item.name" placeholder="规格值名称,如:微辣" />
|
||||
<InputNumber
|
||||
v-model:value="item.extraPrice"
|
||||
:step="0.5"
|
||||
style="width: 120px"
|
||||
placeholder="加价"
|
||||
/>
|
||||
<Button danger @click="removeSpecValue(index)">删除</Button>
|
||||
</div>
|
||||
<Button @click="addSpecValue">新增规格值</Button>
|
||||
|
||||
<Divider />
|
||||
<Form.Item label="排序">
|
||||
<InputNumber v-model:value="form.sort" :min="1" style="width: 100%" />
|
||||
</Form.Item>
|
||||
<Form.Item label="状态">
|
||||
<Select
|
||||
v-model:value="form.status"
|
||||
:options="[
|
||||
{ label: '启用', value: 'enabled' },
|
||||
{ label: '停用', value: 'disabled' },
|
||||
]"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<template #footer>
|
||||
<Space>
|
||||
<Button @click="setDrawerOpen(false)">取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="isDrawerSubmitting"
|
||||
@click="submitDrawer"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</Drawer>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
/* 文件职责:规格做法页面样式。 */
|
||||
.page-product-specs {
|
||||
.spec-value-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-table-cell) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -14,8 +14,10 @@ interface StoreOption {
|
||||
|
||||
interface Props {
|
||||
copyButtonText?: string;
|
||||
copyPosition?: 'left' | 'right';
|
||||
copyDisabled?: boolean;
|
||||
isStoreLoading: boolean;
|
||||
showCopyButton?: boolean;
|
||||
selectedStoreId: string;
|
||||
storeOptions: StoreOption[];
|
||||
storePlaceholder?: string;
|
||||
@@ -23,7 +25,9 @@ interface Props {
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
copyButtonText: '复制到其他门店',
|
||||
copyPosition: 'right',
|
||||
copyDisabled: false,
|
||||
showCopyButton: true,
|
||||
storePlaceholder: '请选择门店',
|
||||
});
|
||||
|
||||
@@ -54,10 +58,22 @@ function handleStoreChange(value: unknown) {
|
||||
:disabled="props.isStoreLoading || props.storeOptions.length === 0"
|
||||
@update:value="(value) => handleStoreChange(value)"
|
||||
/>
|
||||
<div class="store-scope-spacer"></div>
|
||||
<Button :disabled="props.copyDisabled" @click="emit('copy')">
|
||||
<Button
|
||||
v-if="props.showCopyButton && props.copyPosition === 'left'"
|
||||
:disabled="props.copyDisabled"
|
||||
@click="emit('copy')"
|
||||
>
|
||||
{{ props.copyButtonText }}
|
||||
</Button>
|
||||
<div class="store-scope-spacer"></div>
|
||||
<Button
|
||||
v-if="props.showCopyButton && props.copyPosition === 'right'"
|
||||
:disabled="props.copyDisabled"
|
||||
@click="emit('copy')"
|
||||
>
|
||||
{{ props.copyButtonText }}
|
||||
</Button>
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -8,8 +8,8 @@ import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Card, Empty, Spin } from 'ant-design-vue';
|
||||
|
||||
import CopyToStoresModal from '../components/CopyToStoresModal.vue';
|
||||
import StoreScopeToolbar from '../components/StoreScopeToolbar.vue';
|
||||
import CopyToStoresModal from '../../shared/components/CopyToStoresModal.vue';
|
||||
import StoreScopeToolbar from '../../shared/components/StoreScopeToolbar.vue';
|
||||
import DeliveryCommonSettingsCard from './components/DeliveryCommonSettingsCard.vue';
|
||||
import DeliveryModeCard from './components/DeliveryModeCard.vue';
|
||||
import DeliveryTierDrawer from './components/DeliveryTierDrawer.vue';
|
||||
|
||||
@@ -10,8 +10,8 @@ import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Card, Empty, message, Spin } from 'ant-design-vue';
|
||||
|
||||
import CopyToStoresModal from '../components/CopyToStoresModal.vue';
|
||||
import StoreScopeToolbar from '../components/StoreScopeToolbar.vue';
|
||||
import CopyToStoresModal from '../../shared/components/CopyToStoresModal.vue';
|
||||
import StoreScopeToolbar from '../../shared/components/StoreScopeToolbar.vue';
|
||||
import DineInAreaDrawer from './components/DineInAreaDrawer.vue';
|
||||
import DineInAreaSection from './components/DineInAreaSection.vue';
|
||||
import DineInBasicSettingsCard from './components/DineInBasicSettingsCard.vue';
|
||||
|
||||
@@ -10,8 +10,8 @@ import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Card, Empty, Spin } from 'ant-design-vue';
|
||||
|
||||
import CopyToStoresModal from '../components/CopyToStoresModal.vue';
|
||||
import StoreScopeToolbar from '../components/StoreScopeToolbar.vue';
|
||||
import CopyToStoresModal from '../../shared/components/CopyToStoresModal.vue';
|
||||
import StoreScopeToolbar from '../../shared/components/StoreScopeToolbar.vue';
|
||||
import FeesDeliveryCard from './components/FeesDeliveryCard.vue';
|
||||
import FeesOtherCard from './components/FeesOtherCard.vue';
|
||||
import FeesPackagingCard from './components/FeesPackagingCard.vue';
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Button, Card, Empty, Popconfirm, Spin } from 'ant-design-vue';
|
||||
|
||||
import CopyToStoresModal from '../components/CopyToStoresModal.vue';
|
||||
import StoreScopeToolbar from '../components/StoreScopeToolbar.vue';
|
||||
import CopyToStoresModal from '../../shared/components/CopyToStoresModal.vue';
|
||||
import StoreScopeToolbar from '../../shared/components/StoreScopeToolbar.vue';
|
||||
import AddSlotDrawer from './components/AddSlotDrawer.vue';
|
||||
import DayEditDrawer from './components/DayEditDrawer.vue';
|
||||
import HolidayDrawer from './components/HolidayDrawer.vue';
|
||||
|
||||
@@ -8,8 +8,8 @@ import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Card, Empty, Spin } from 'ant-design-vue';
|
||||
|
||||
import CopyToStoresModal from '../components/CopyToStoresModal.vue';
|
||||
import StoreScopeToolbar from '../components/StoreScopeToolbar.vue';
|
||||
import CopyToStoresModal from '../../shared/components/CopyToStoresModal.vue';
|
||||
import StoreScopeToolbar from '../../shared/components/StoreScopeToolbar.vue';
|
||||
import PickupBasicSettingsCard from './components/PickupBasicSettingsCard.vue';
|
||||
import PickupBigSlotSection from './components/PickupBigSlotSection.vue';
|
||||
import PickupFineRuleSection from './components/PickupFineRuleSection.vue';
|
||||
|
||||
@@ -8,8 +8,8 @@ import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Card, Empty, Spin } from 'ant-design-vue';
|
||||
|
||||
import CopyToStoresModal from '../components/CopyToStoresModal.vue';
|
||||
import StoreScopeToolbar from '../components/StoreScopeToolbar.vue';
|
||||
import CopyToStoresModal from '../../shared/components/CopyToStoresModal.vue';
|
||||
import StoreScopeToolbar from '../../shared/components/StoreScopeToolbar.vue';
|
||||
import ShiftTemplateCard from './components/ShiftTemplateCard.vue';
|
||||
import StaffEditorDrawer from './components/StaffEditorDrawer.vue';
|
||||
import StaffFilterBar from './components/StaffFilterBar.vue';
|
||||
|
||||
@@ -163,16 +163,26 @@ const adjustCropperToAspectRatio = () => {
|
||||
const containerWidthVal = containerWidth.value;
|
||||
const containerHeightVal = containerHeight.value;
|
||||
|
||||
// 根据比例计算裁剪框尺寸
|
||||
let newHeight: number, newWidth: number;
|
||||
// 有固定比例时保留默认留白,避免初始裁剪框占满导致拖拽不便
|
||||
const padding = Math.min(
|
||||
CROPPER_CONSTANTS.MAX_PADDING,
|
||||
Math.floor(containerWidthVal * CROPPER_CONSTANTS.PADDING_RATIO),
|
||||
Math.floor(containerHeightVal * CROPPER_CONSTANTS.PADDING_RATIO),
|
||||
);
|
||||
const maxCropWidth = Math.max(
|
||||
CROPPER_CONSTANTS.MIN_WIDTH,
|
||||
containerWidthVal - padding * 2,
|
||||
);
|
||||
const maxCropHeight = Math.max(
|
||||
CROPPER_CONSTANTS.MIN_HEIGHT,
|
||||
containerHeightVal - padding * 2,
|
||||
);
|
||||
|
||||
// 先按宽度优先计算
|
||||
newWidth = containerWidthVal;
|
||||
newHeight = newWidth / ratio;
|
||||
|
||||
// 如果高度超出容器,按高度优先计算
|
||||
if (newHeight > containerHeightVal) {
|
||||
newHeight = containerHeightVal;
|
||||
// 根据比例在可用区域内计算裁剪框尺寸
|
||||
let newWidth = maxCropWidth;
|
||||
let newHeight = newWidth / ratio;
|
||||
if (newHeight > maxCropHeight) {
|
||||
newHeight = maxCropHeight;
|
||||
newWidth = newHeight * ratio;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user