diff --git a/apps/web-antd/src/api/product/index.ts b/apps/web-antd/src/api/product/index.ts index 7c67423..54d3866 100644 --- a/apps/web-antd/src/api/product/index.ts +++ b/apps/web-antd/src/api/product/index.ts @@ -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('/product/category/list', { params: { storeId }, }); } +/** 获取分类管理列表。 */ +export async function getProductCategoryManageListApi( + params: ProductCategoryManageQuery, +) { + return requestClient.get( + '/product/category/manage/list', + { + params, + }, + ); +} + +/** 保存分类。 */ +export async function saveProductCategoryApi(data: SaveProductCategoryDto) { + return requestClient.post( + '/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>( @@ -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( '/product/batch', data, ); } + +/** 获取规格列表。 */ +export async function getProductSpecListApi(params: ProductSpecQuery) { + return requestClient.get('/product/spec/list', { + params, + }); +} + +/** 保存规格。 */ +export async function saveProductSpecApi(data: SaveProductSpecDto) { + return requestClient.post('/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( + '/product/addon/group/list', + { + params, + }, + ); +} + +/** 保存加料组。 */ +export async function saveProductAddonGroupApi(data: SaveProductAddonGroupDto) { + return requestClient.post( + '/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('/product/label/list', { + params, + }); +} + +/** 保存标签。 */ +export async function saveProductLabelApi(data: SaveProductLabelDto) { + return requestClient.post('/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('/product/schedule/list', { + params, + }); +} + +/** 保存时段。 */ +export async function saveProductScheduleApi(data: SaveProductScheduleDto) { + return requestClient.post('/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( + '/product/batch/price-adjust', + data, + ); +} + +/** 批量移动分类。 */ +export async function batchMoveProductCategoryApi( + data: ProductBatchMoveCategoryDto, +) { + return requestClient.post( + '/product/batch/move-category', + data, + ); +} + +/** 批量上下架。 */ +export async function batchSwitchProductSaleApi( + data: ProductBatchSaleSwitchDto, +) { + return requestClient.post( + '/product/batch/sale-switch', + data, + ); +} + +/** 批量同步门店。 */ +export async function batchSyncProductStoreApi(data: ProductBatchSyncStoreDto) { + return requestClient.post( + '/product/batch/store-sync', + data, + ); +} + +/** 批量导入。 */ +export async function batchImportProductApi(data: ProductBatchImportExportDto) { + return requestClient.post( + '/product/batch/import', + data, + ); +} + +/** 批量导出。 */ +export async function batchExportProductApi(data: ProductBatchImportExportDto) { + return requestClient.post( + '/product/batch/export', + data, + ); +} + +/** 商品选择器列表。 */ +export async function searchProductPickerApi(params: ProductPickerQuery) { + return requestClient.get('/product/picker/list', { + params, + }); +} diff --git a/apps/web-antd/src/mock/index.ts b/apps/web-antd/src/mock/index.ts index 0353ebe..33e93c0 100644 --- a/apps/web-antd/src/mock/index.ts +++ b/apps/web-antd/src/mock/index.ts @@ -1,5 +1,6 @@ // Mock 数据入口,仅在开发环境下使用 // 门店模块已切换真实 TenantApi,此处仅保留其他业务的 mock。 import './product'; +import './product-extensions'; console.warn('[Mock] 非门店模块 Mock 数据已启用'); diff --git a/apps/web-antd/src/mock/product-extensions.ts b/apps/web-antd/src/mock/product-extensions.ts new file mode 100644 index 0000000..d9e1b44 --- /dev/null +++ b/apps/web-antd/src/mock/product-extensions.ts @@ -0,0 +1,1625 @@ +/* eslint-disable unicorn/prefer-set-has, unicorn/no-array-sort, regexp/no-unused-capturing-group, unicorn/no-nested-ternary, unicorn/no-array-reduce */ +// @ts-nocheck Mock 扩展接口数据结构较大,关闭严格类型检查。 +import Mock from 'mockjs'; + +/** + * 文件职责:商品管理扩展模块 Mock 接口。 + * 1. 提供分类管理、规格做法、加料管理、商品标签、时段供应接口。 + * 2. 提供商品选择器与批量工具扩展接口。 + */ +interface MockRequestOptions { + body: null | string; + type: string; + url: string; +} + +type ProductCategoryChannel = 'dine_in' | 'pickup' | 'wm'; +type ProductStatus = 'off_shelf' | 'on_sale' | 'sold_out'; +type ProductSwitchStatus = 'disabled' | 'enabled'; + +interface ProductPickerRecord { + categoryId: string; + id: string; + name: string; + price: number; + spuCode: string; + status: ProductStatus; +} + +interface CategoryRecord { + channels: ProductCategoryChannel[]; + description: string; + icon: string; + id: string; + name: string; + sort: number; + status: ProductSwitchStatus; +} + +interface SpecValueRecord { + extraPrice: number; + id: string; + name: string; + sort: number; +} + +interface SpecRecord { + description: string; + id: string; + name: string; + productIds: string[]; + sort: number; + status: ProductSwitchStatus; + updatedAt: string; + values: SpecValueRecord[]; +} + +interface AddonItemRecord { + id: string; + name: string; + price: number; + sort: number; + status: ProductSwitchStatus; +} + +interface AddonGroupRecord { + description: string; + id: string; + items: AddonItemRecord[]; + maxSelect: number; + minSelect: number; + name: string; + productIds: string[]; + required: boolean; + sort: number; + status: ProductSwitchStatus; + updatedAt: string; +} + +interface LabelRecord { + color: string; + description: string; + id: string; + name: string; + productIds: string[]; + sort: number; + status: ProductSwitchStatus; + updatedAt: string; +} + +interface ScheduleSlotRecord { + endTime: string; + id: string; + startTime: string; + weekDays: number[]; +} + +interface ScheduleRecord { + description: string; + id: string; + name: string; + productIds: string[]; + slots: ScheduleSlotRecord[]; + sort: number; + status: ProductSwitchStatus; + updatedAt: string; +} + +interface ProductExtensionStoreState { + addonGroups: AddonGroupRecord[]; + categories: CategoryRecord[]; + labels: LabelRecord[]; + products: ProductPickerRecord[]; + schedules: ScheduleRecord[]; + specs: SpecRecord[]; +} + +interface ProductBatchScope { + categoryId?: string; + productIds?: string[]; + type: 'all' | 'category' | 'selected'; +} + +const CATEGORY_SEEDS = [ + { id: 'cat-hot', name: '热销推荐', sort: 1, description: '高销量推荐菜品' }, + { id: 'cat-main', name: '主食', sort: 2, description: '主食与招牌正餐' }, + { id: 'cat-snack', name: '小吃凉菜', sort: 3, description: '小食与凉拌菜' }, + { id: 'cat-soup', name: '汤羹粥品', sort: 4, description: '汤羹与粥品' }, + { id: 'cat-drink', name: '饮品', sort: 5, description: '冷热饮品与甜品' }, + { id: 'cat-combo', name: '套餐', sort: 6, description: '多人份组合套餐' }, +]; + +const PRODUCT_SEEDS = [ + { categoryId: 'cat-main', name: '宫保鸡丁', price: 32 }, + { categoryId: 'cat-main', name: '鱼香肉丝', price: 28 }, + { categoryId: 'cat-main', name: '红烧排骨', price: 42 }, + { categoryId: 'cat-main', name: '照烧鸡腿饭', price: 27 }, + { categoryId: 'cat-snack', name: '蒜香鸡翅', price: 24 }, + { categoryId: 'cat-snack', name: '凉拌黄瓜', price: 10 }, + { categoryId: 'cat-snack', name: '椒盐玉米', price: 14 }, + { categoryId: 'cat-soup', name: '酸辣汤', price: 18 }, + { categoryId: 'cat-soup', name: '番茄蛋花汤', price: 16 }, + { categoryId: 'cat-drink', name: '杨枝甘露', price: 16 }, + { categoryId: 'cat-drink', name: '冰粉', price: 8 }, + { categoryId: 'cat-drink', name: '柠檬气泡水', price: 9 }, + { categoryId: 'cat-combo', name: '双人午市套餐', price: 69 }, + { categoryId: 'cat-combo', name: '单人畅享套餐', price: 39 }, + { categoryId: 'cat-hot', name: '招牌牛肉饭', price: 34 }, + { categoryId: 'cat-hot', name: '经典鸡排饭', price: 26 }, +]; + +const storeMap = new Map(); +let idSeed = 10_000; + +function parseUrlParams(url: string) { + const parsed = new URL(url, 'http://localhost'); + const params: Record = {}; + parsed.searchParams.forEach((value, key) => { + params[key] = value; + }); + return params; +} + +function parseBody(options: MockRequestOptions) { + if (!options.body) return {}; + try { + return JSON.parse(options.body) as Record; + } catch (error) { + console.error('[mock-product-extensions] parseBody error:', error); + return {}; + } +} + +function toDateTimeText(date: Date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hour = String(date.getHours()).padStart(2, '0'); + const minute = String(date.getMinutes()).padStart(2, '0'); + const second = String(date.getSeconds()).padStart(2, '0'); + return `${year}-${month}-${day} ${hour}:${minute}:${second}`; +} + +function createId(prefix: string, storeId: string) { + idSeed += 1; + return `${prefix}-${storeId}-${idSeed}`; +} + +function normalizeText(value: unknown, fallback = '') { + const text = String(value ?? '').trim(); + return text || fallback; +} + +function normalizeNumber(value: unknown, fallback = 0, min = 0) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.max(min, parsed); +} + +function normalizeInt(value: unknown, fallback = 0, min = 0) { + return Math.floor(normalizeNumber(value, fallback, min)); +} + +function normalizeBool(value: unknown, fallback = false) { + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value === 1; + if (typeof value === 'string') { + const next = value.trim().toLowerCase(); + if (next === 'true' || next === '1') return true; + if (next === 'false' || next === '0') return false; + } + return fallback; +} + +function normalizeSwitchStatus( + value: unknown, + fallback: ProductSwitchStatus, +): ProductSwitchStatus { + const next = String(value || '').trim(); + return next === 'enabled' || next === 'disabled' ? next : fallback; +} + +function normalizeProductStatus( + value: unknown, + fallback: ProductStatus, +): ProductStatus { + const next = String(value || '').trim(); + return next === 'off_shelf' || next === 'on_sale' || next === 'sold_out' + ? next + : fallback; +} + +function normalizeIdList(value: unknown) { + if (!Array.isArray(value)) return []; + return [ + ...new Set(value.map((item) => String(item || '').trim()).filter(Boolean)), + ]; +} + +function normalizeChannels(value: unknown, fallback: ProductCategoryChannel[]) { + if (!Array.isArray(value)) return [...fallback]; + const allowed: ProductCategoryChannel[] = ['wm', 'pickup', 'dine_in']; + const list = value + .map((item) => String(item || '').trim()) + .filter((item): item is ProductCategoryChannel => + allowed.includes(item as ProductCategoryChannel), + ); + const unique = [...new Set(list)]; + return unique.length > 0 ? unique : [...fallback]; +} + +function normalizeWeekDays(value: unknown) { + if (!Array.isArray(value)) return [1, 2, 3, 4, 5, 6, 7]; + const list = value + .map((item) => normalizeInt(item, 0, 0)) + .filter((item) => item >= 1 && item <= 7); + const unique = [...new Set(list)].sort((a, b) => a - b); + return unique.length > 0 ? unique : [1, 2, 3, 4, 5, 6, 7]; +} + +function normalizeTimeText(value: unknown, fallback: string) { + const text = normalizeText(value, fallback); + if (/^([01]\d|2[0-3]):[0-5]\d$/.test(text)) return text; + return fallback; +} + +function getCategoryName( + state: ProductExtensionStoreState, + categoryId: string, +) { + return ( + state.categories.find((item) => item.id === categoryId)?.name ?? + state.categories[0]?.name ?? + '未分类' + ); +} + +function hasCategory(state: ProductExtensionStoreState, categoryId: string) { + return state.categories.some((item) => item.id === categoryId); +} + +function cleanupRelationIds(state: ProductExtensionStoreState) { + const idSet = new Set(state.products.map((item) => item.id)); + for (const spec of state.specs) { + spec.productIds = spec.productIds.filter((item) => idSet.has(item)); + } + for (const group of state.addonGroups) { + group.productIds = group.productIds.filter((item) => idSet.has(item)); + } + for (const label of state.labels) { + label.productIds = label.productIds.filter((item) => idSet.has(item)); + } + for (const schedule of state.schedules) { + schedule.productIds = schedule.productIds.filter((item) => idSet.has(item)); + } +} + +function resolveScopedProducts( + state: ProductExtensionStoreState, + scope: ProductBatchScope, +) { + const type = String(scope?.type || 'all'); + if (type === 'selected') { + const idSet = new Set(normalizeIdList(scope?.productIds)); + return state.products.filter((item) => idSet.has(item.id)); + } + if (type === 'category') { + const categoryId = normalizeText(scope?.categoryId); + if (!categoryId) return []; + return state.products.filter((item) => item.categoryId === categoryId); + } + return [...state.products]; +} + +function toCategoryManageItem( + state: ProductExtensionStoreState, + item: CategoryRecord, +) { + return { + id: item.id, + name: item.name, + sort: item.sort, + description: item.description, + icon: item.icon, + channels: [...item.channels], + status: item.status, + productCount: state.products.filter( + (product) => product.categoryId === item.id, + ).length, + }; +} + +function toSpecItem(state: ProductExtensionStoreState, item: SpecRecord) { + const idSet = new Set(state.products.map((product) => product.id)); + const productIds = item.productIds.filter((id) => idSet.has(id)); + return { + id: item.id, + name: item.name, + description: item.description, + sort: item.sort, + status: item.status, + productIds, + productCount: productIds.length, + updatedAt: item.updatedAt, + values: item.values + .toSorted((a, b) => a.sort - b.sort) + .map((value) => ({ + id: value.id, + name: value.name, + sort: value.sort, + extraPrice: value.extraPrice, + })), + }; +} + +function toAddonGroupItem( + state: ProductExtensionStoreState, + item: AddonGroupRecord, +) { + const idSet = new Set(state.products.map((product) => product.id)); + const productIds = item.productIds.filter((id) => idSet.has(id)); + return { + id: item.id, + name: item.name, + description: item.description, + required: item.required, + minSelect: item.minSelect, + maxSelect: item.maxSelect, + sort: item.sort, + status: item.status, + productIds, + productCount: productIds.length, + updatedAt: item.updatedAt, + items: item.items + .toSorted((a, b) => a.sort - b.sort) + .map((addon) => ({ + id: addon.id, + name: addon.name, + price: addon.price, + sort: addon.sort, + status: addon.status, + })), + }; +} + +function toLabelItem(state: ProductExtensionStoreState, item: LabelRecord) { + const idSet = new Set(state.products.map((product) => product.id)); + const productIds = item.productIds.filter((id) => idSet.has(id)); + return { + id: item.id, + name: item.name, + color: item.color, + description: item.description, + sort: item.sort, + status: item.status, + productIds, + productCount: productIds.length, + updatedAt: item.updatedAt, + }; +} + +function toScheduleItem( + state: ProductExtensionStoreState, + item: ScheduleRecord, +) { + const idSet = new Set(state.products.map((product) => product.id)); + const productIds = item.productIds.filter((id) => idSet.has(id)); + return { + id: item.id, + name: item.name, + description: item.description, + sort: item.sort, + status: item.status, + productIds, + productCount: productIds.length, + updatedAt: item.updatedAt, + slots: item.slots.map((slot) => ({ + id: slot.id, + weekDays: [...slot.weekDays], + startTime: slot.startTime, + endTime: slot.endTime, + })), + }; +} + +function createDefaultState(storeId: string): ProductExtensionStoreState { + const categories: CategoryRecord[] = CATEGORY_SEEDS.map((item) => ({ + id: item.id, + name: item.name, + sort: item.sort, + description: item.description, + icon: 'lucide:folder', + channels: ['wm', 'pickup', 'dine_in'], + status: 'enabled', + })); + + const products: ProductPickerRecord[] = PRODUCT_SEEDS.map((item, index) => ({ + id: `ext-prd-${storeId}-${String(index + 1).padStart(4, '0')}`, + categoryId: item.categoryId, + name: item.name, + price: item.price, + spuCode: `SPU2026${String(10_000 + index).slice(-5)}`, + status: + index % 7 === 0 ? 'sold_out' : index % 5 === 0 ? 'off_shelf' : 'on_sale', + })); + + const specs: SpecRecord[] = [ + { + id: createId('spec', storeId), + name: '份量', + description: '控制半份/整份售卖', + sort: 1, + status: 'enabled', + productIds: products.slice(0, 8).map((item) => item.id), + updatedAt: toDateTimeText(new Date()), + values: [ + { + id: createId('specv', storeId), + name: '半份', + sort: 1, + extraPrice: -3, + }, + { + id: createId('specv', storeId), + name: '整份', + sort: 2, + extraPrice: 0, + }, + ], + }, + { + id: createId('spec', storeId), + name: '辣度', + description: '顾客按口味选择辣度', + sort: 2, + status: 'enabled', + productIds: products.slice(0, 10).map((item) => item.id), + updatedAt: toDateTimeText(new Date()), + values: [ + { + id: createId('specv', storeId), + name: '不辣', + sort: 1, + extraPrice: 0, + }, + { + id: createId('specv', storeId), + name: '微辣', + sort: 2, + extraPrice: 0, + }, + { + id: createId('specv', storeId), + name: '中辣', + sort: 3, + extraPrice: 0, + }, + ], + }, + ]; + + const addonGroups: AddonGroupRecord[] = [ + { + id: createId('addon', storeId), + name: '主食加量', + description: '适用于主食类商品', + required: false, + minSelect: 0, + maxSelect: 2, + sort: 1, + status: 'enabled', + productIds: products.slice(0, 10).map((item) => item.id), + updatedAt: toDateTimeText(new Date()), + items: [ + { + id: createId('addon-item', storeId), + name: '加鸡蛋', + price: 2, + sort: 1, + status: 'enabled', + }, + { + id: createId('addon-item', storeId), + name: '加饭', + price: 3, + sort: 2, + status: 'enabled', + }, + ], + }, + ]; + + const labels: LabelRecord[] = [ + { + id: createId('label', storeId), + name: '招牌', + color: '#cf1322', + description: '店铺主推商品', + sort: 1, + status: 'enabled', + productIds: products.slice(0, 6).map((item) => item.id), + updatedAt: toDateTimeText(new Date()), + }, + { + id: createId('label', storeId), + name: '新品', + color: '#2f54eb', + description: '近 30 天上新', + sort: 2, + status: 'enabled', + productIds: products.slice(6, 12).map((item) => item.id), + updatedAt: toDateTimeText(new Date()), + }, + ]; + + const schedules: ScheduleRecord[] = [ + { + id: createId('schedule', storeId), + name: '午市供应', + description: '适用于午餐时段', + sort: 1, + status: 'enabled', + productIds: products.slice(0, 8).map((item) => item.id), + updatedAt: toDateTimeText(new Date()), + slots: [ + { + id: createId('slot', storeId), + weekDays: [1, 2, 3, 4, 5, 6, 7], + startTime: '10:30', + endTime: '14:00', + }, + ], + }, + ]; + + return { + categories, + products, + specs, + addonGroups, + labels, + schedules, + }; +} + +function ensureStoreState(storeId = '') { + const key = storeId || 'default'; + let state = storeMap.get(key); + if (!state) { + state = createDefaultState(key); + storeMap.set(key, state); + } + return state; +} + +Mock.mock( + /\/product\/category\/manage\/list(?:\?|$)/, + 'get', + (options: MockRequestOptions) => { + const params = parseUrlParams(options.url); + const storeId = normalizeText(params.storeId); + const keyword = normalizeText(params.keyword).toLowerCase(); + const status = normalizeText(params.status); + const state = ensureStoreState(storeId); + + const list = state.categories + .toSorted((a, b) => a.sort - b.sort) + .filter((item) => { + if (status && item.status !== status) return false; + if (!keyword) return true; + return ( + item.name.toLowerCase().includes(keyword) || + item.description.toLowerCase().includes(keyword) + ); + }) + .map((item) => toCategoryManageItem(state, item)); + + return { code: 200, data: list }; + }, +); + +Mock.mock( + /\/product\/category\/manage\/save/, + 'post', + (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + const id = normalizeText(body.id); + const name = normalizeText(body.name); + if (!storeId || !name) { + return { code: 400, data: null, message: '参数不完整' }; + } + + const state = ensureStoreState(storeId); + const duplicate = state.categories.find( + (item) => item.name === name && item.id !== id, + ); + if (duplicate) { + return { code: 400, data: null, message: '分类名称已存在' }; + } + + const currentSortMax = + state.categories.reduce((max, item) => Math.max(max, item.sort), 0) + 1; + const existingIndex = state.categories.findIndex((item) => item.id === id); + + const next = + existingIndex === -1 + ? { + id: createId('cat', storeId), + name, + description: normalizeText(body.description), + icon: normalizeText(body.icon, 'lucide:folder'), + sort: normalizeInt(body.sort, currentSortMax, 1), + status: normalizeSwitchStatus(body.status, 'enabled'), + channels: normalizeChannels(body.channels, ['wm']), + } + : { + ...state.categories[existingIndex], + name, + description: normalizeText( + body.description, + state.categories[existingIndex].description, + ), + icon: normalizeText( + body.icon, + state.categories[existingIndex].icon, + ), + sort: normalizeInt( + body.sort, + state.categories[existingIndex].sort, + 1, + ), + status: normalizeSwitchStatus( + body.status, + state.categories[existingIndex].status, + ), + channels: normalizeChannels( + body.channels, + state.categories[existingIndex].channels, + ), + }; + + if (existingIndex === -1) { + state.categories.push(next); + } else { + state.categories.splice(existingIndex, 1, next); + } + + return { code: 200, data: toCategoryManageItem(state, next) }; + }, +); + +Mock.mock( + /\/product\/category\/manage\/delete/, + 'post', + (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + const categoryId = normalizeText(body.categoryId); + if (!storeId || !categoryId) { + return { code: 400, data: null, message: '参数不完整' }; + } + + const state = ensureStoreState(storeId); + const hasProducts = state.products.some( + (item) => item.categoryId === categoryId, + ); + if (hasProducts) { + return { code: 400, data: null, message: '分类下仍有商品,不能删除' }; + } + + if (state.categories.length <= 1) { + return { code: 400, data: null, message: '至少保留一个分类' }; + } + + state.categories = state.categories.filter( + (item) => item.id !== categoryId, + ); + return { code: 200, data: null }; + }, +); + +Mock.mock( + /\/product\/category\/manage\/status/, + 'post', + (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + const categoryId = normalizeText(body.categoryId); + if (!storeId || !categoryId) { + return { code: 400, data: null, message: '参数不完整' }; + } + const state = ensureStoreState(storeId); + const target = state.categories.find((item) => item.id === categoryId); + if (!target) return { code: 404, data: null, message: '分类不存在' }; + target.status = normalizeSwitchStatus(body.status, target.status); + return { code: 200, data: toCategoryManageItem(state, target) }; + }, +); + +Mock.mock( + /\/product\/category\/manage\/sort/, + 'post', + (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + const items = Array.isArray(body.items) ? body.items : []; + if (!storeId) return { code: 400, data: null, message: '参数不完整' }; + + const sortMap = new Map(); + for (const item of items) { + if (!item || typeof item !== 'object') continue; + const current = item as Record; + const id = normalizeText(current.categoryId); + const sort = normalizeInt(current.sort, 0, 1); + if (!id || sort <= 0) continue; + sortMap.set(id, sort); + } + + const state = ensureStoreState(storeId); + state.categories = state.categories + .map((item) => ({ + ...item, + sort: sortMap.get(item.id) ?? item.sort, + })) + .toSorted((a, b) => a.sort - b.sort) + .map((item, index) => ({ ...item, sort: index + 1 })); + + return { + code: 200, + data: state.categories.map((item) => toCategoryManageItem(state, item)), + }; + }, +); + +Mock.mock( + /\/product\/category\/manage\/products\/bind/, + 'post', + (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + const categoryId = normalizeText(body.categoryId); + const productIds = normalizeIdList(body.productIds); + if (!storeId || !categoryId || productIds.length === 0) { + return { code: 400, data: null, message: '参数不完整' }; + } + + const state = ensureStoreState(storeId); + if (!hasCategory(state, categoryId)) { + return { code: 404, data: null, message: '分类不存在' }; + } + + const idSet = new Set(productIds); + let successCount = 0; + for (const item of state.products) { + if (!idSet.has(item.id)) continue; + item.categoryId = categoryId; + successCount += 1; + } + + return { + code: 200, + data: { + totalCount: productIds.length, + successCount, + failedCount: Math.max(productIds.length - successCount, 0), + }, + }; + }, +); + +Mock.mock( + /\/product\/category\/manage\/products\/unbind/, + 'post', + (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + const categoryId = normalizeText(body.categoryId); + const productId = normalizeText(body.productId); + if (!storeId || !categoryId || !productId) { + return { code: 400, data: null, message: '参数不完整' }; + } + + const state = ensureStoreState(storeId); + const fallbackCategory = state.categories.find( + (item) => item.id !== categoryId, + ); + if (!fallbackCategory) { + return { code: 400, data: null, message: '无可用目标分类' }; + } + + const target = state.products.find((item) => item.id === productId); + if (!target) return { code: 404, data: null, message: '商品不存在' }; + target.categoryId = fallbackCategory.id; + + return { code: 200, data: target }; + }, +); + +Mock.mock( + /\/product\/picker\/list(?:\?|$)/, + 'get', + (options: MockRequestOptions) => { + const params = parseUrlParams(options.url); + const storeId = normalizeText(params.storeId); + const keyword = normalizeText(params.keyword).toLowerCase(); + const categoryId = normalizeText(params.categoryId); + const limit = Math.max( + 1, + Math.min(500, normalizeInt(params.limit, 200, 1)), + ); + const state = ensureStoreState(storeId); + + const list = state.products + .filter((item) => { + if (categoryId && item.categoryId !== categoryId) return false; + if (!keyword) return true; + return ( + item.name.toLowerCase().includes(keyword) || + item.spuCode.toLowerCase().includes(keyword) + ); + }) + .slice(0, limit) + .map((item) => ({ + id: item.id, + name: item.name, + spuCode: item.spuCode, + categoryId: item.categoryId, + categoryName: getCategoryName(state, item.categoryId), + price: item.price, + status: item.status, + })); + + return { code: 200, data: list }; + }, +); + +Mock.mock( + /\/product\/spec\/list(?:\?|$)/, + 'get', + (options: MockRequestOptions) => { + const params = parseUrlParams(options.url); + const storeId = normalizeText(params.storeId); + const keyword = normalizeText(params.keyword).toLowerCase(); + const status = normalizeText(params.status); + const state = ensureStoreState(storeId); + + const list = state.specs + .toSorted((a, b) => a.sort - b.sort) + .filter((item) => { + if (status && item.status !== status) return false; + if (!keyword) return true; + return ( + item.name.toLowerCase().includes(keyword) || + item.description.toLowerCase().includes(keyword) + ); + }) + .map((item) => toSpecItem(state, item)); + + return { code: 200, data: list }; + }, +); + +Mock.mock(/\/product\/spec\/save/, 'post', (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + const id = normalizeText(body.id); + const name = normalizeText(body.name); + if (!storeId || !name) { + return { code: 400, data: null, message: '参数不完整' }; + } + + const state = ensureStoreState(storeId); + const existingIndex = state.specs.findIndex((item) => item.id === id); + const fallbackSort = + state.specs.reduce((max, item) => Math.max(max, item.sort), 0) + 1; + const productIds = normalizeIdList(body.productIds).filter((productId) => + state.products.some((item) => item.id === productId), + ); + + const valueListRaw = Array.isArray(body.values) ? body.values : []; + const values: SpecValueRecord[] = valueListRaw + .map((value, index) => { + if (!value || typeof value !== 'object') return null; + const current = value as Record; + const valueName = normalizeText(current.name); + if (!valueName) return null; + return { + id: normalizeText(current.id, createId('specv', storeId)), + name: valueName, + sort: normalizeInt(current.sort, index + 1, 1), + extraPrice: Number( + normalizeNumber(current.extraPrice, 0, -999).toFixed(2), + ), + }; + }) + .filter((item): item is SpecValueRecord => item !== null) + .toSorted((a, b) => a.sort - b.sort); + + if (values.length === 0) { + return { code: 400, data: null, message: '至少保留一个规格值' }; + } + + const next: SpecRecord = + existingIndex === -1 + ? { + id: createId('spec', storeId), + name, + description: normalizeText(body.description), + sort: normalizeInt(body.sort, fallbackSort, 1), + status: normalizeSwitchStatus(body.status, 'enabled'), + productIds, + values, + updatedAt: toDateTimeText(new Date()), + } + : { + ...state.specs[existingIndex], + name, + description: normalizeText( + body.description, + state.specs[existingIndex].description, + ), + sort: normalizeInt(body.sort, state.specs[existingIndex].sort, 1), + status: normalizeSwitchStatus( + body.status, + state.specs[existingIndex].status, + ), + productIds, + values, + updatedAt: toDateTimeText(new Date()), + }; + + if (existingIndex === -1) { + state.specs.push(next); + } else { + state.specs.splice(existingIndex, 1, next); + } + + return { code: 200, data: toSpecItem(state, next) }; +}); + +Mock.mock(/\/product\/spec\/delete/, 'post', (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + const specId = normalizeText(body.specId); + if (!storeId || !specId) + return { code: 400, data: null, message: '参数不完整' }; + const state = ensureStoreState(storeId); + state.specs = state.specs.filter((item) => item.id !== specId); + return { code: 200, data: null }; +}); + +Mock.mock(/\/product\/spec\/status/, 'post', (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + const specId = normalizeText(body.specId); + if (!storeId || !specId) + return { code: 400, data: null, message: '参数不完整' }; + const state = ensureStoreState(storeId); + const target = state.specs.find((item) => item.id === specId); + if (!target) return { code: 404, data: null, message: '规格不存在' }; + target.status = normalizeSwitchStatus(body.status, target.status); + target.updatedAt = toDateTimeText(new Date()); + return { code: 200, data: toSpecItem(state, target) }; +}); + +Mock.mock( + /\/product\/addon\/group\/list(?:\?|$)/, + 'get', + (options: MockRequestOptions) => { + const params = parseUrlParams(options.url); + const storeId = normalizeText(params.storeId); + const keyword = normalizeText(params.keyword).toLowerCase(); + const status = normalizeText(params.status); + const state = ensureStoreState(storeId); + + const list = state.addonGroups + .toSorted((a, b) => a.sort - b.sort) + .filter((item) => { + if (status && item.status !== status) return false; + if (!keyword) return true; + return ( + item.name.toLowerCase().includes(keyword) || + item.description.toLowerCase().includes(keyword) + ); + }) + .map((item) => toAddonGroupItem(state, item)); + + return { code: 200, data: list }; + }, +); + +Mock.mock( + /\/product\/addon\/group\/save/, + 'post', + (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + const id = normalizeText(body.id); + const name = normalizeText(body.name); + if (!storeId || !name) { + return { code: 400, data: null, message: '参数不完整' }; + } + + const state = ensureStoreState(storeId); + const existingIndex = state.addonGroups.findIndex((item) => item.id === id); + const fallbackSort = + state.addonGroups.reduce((max, item) => Math.max(max, item.sort), 0) + 1; + const productIds = normalizeIdList(body.productIds).filter((productId) => + state.products.some((item) => item.id === productId), + ); + + const itemListRaw = Array.isArray(body.items) ? body.items : []; + const items: AddonItemRecord[] = itemListRaw + .map((item, index) => { + if (!item || typeof item !== 'object') return null; + const current = item as Record; + const itemName = normalizeText(current.name); + if (!itemName) return null; + return { + id: normalizeText(current.id, createId('addon-item', storeId)), + name: itemName, + price: Number(normalizeNumber(current.price, 0, 0).toFixed(2)), + sort: normalizeInt(current.sort, index + 1, 1), + status: normalizeSwitchStatus(current.status, 'enabled'), + }; + }) + .filter((item): item is AddonItemRecord => item !== null) + .toSorted((a, b) => a.sort - b.sort); + + if (items.length === 0) { + return { code: 400, data: null, message: '至少保留一个加料项' }; + } + + const next: AddonGroupRecord = + existingIndex === -1 + ? { + id: createId('addon', storeId), + name, + description: normalizeText(body.description), + required: normalizeBool(body.required, false), + minSelect: normalizeInt(body.minSelect, 0, 0), + maxSelect: normalizeInt(body.maxSelect, 1, 1), + sort: normalizeInt(body.sort, fallbackSort, 1), + status: normalizeSwitchStatus(body.status, 'enabled'), + productIds, + items, + updatedAt: toDateTimeText(new Date()), + } + : { + ...state.addonGroups[existingIndex], + name, + description: normalizeText( + body.description, + state.addonGroups[existingIndex].description, + ), + required: normalizeBool( + body.required, + state.addonGroups[existingIndex].required, + ), + minSelect: normalizeInt( + body.minSelect, + state.addonGroups[existingIndex].minSelect, + 0, + ), + maxSelect: normalizeInt( + body.maxSelect, + state.addonGroups[existingIndex].maxSelect, + 1, + ), + sort: normalizeInt( + body.sort, + state.addonGroups[existingIndex].sort, + 1, + ), + status: normalizeSwitchStatus( + body.status, + state.addonGroups[existingIndex].status, + ), + productIds, + items, + updatedAt: toDateTimeText(new Date()), + }; + + if (next.maxSelect < next.minSelect) { + next.maxSelect = next.minSelect; + } + + if (existingIndex === -1) { + state.addonGroups.push(next); + } else { + state.addonGroups.splice(existingIndex, 1, next); + } + + return { code: 200, data: toAddonGroupItem(state, next) }; + }, +); + +Mock.mock( + /\/product\/addon\/group\/delete/, + 'post', + (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + const groupId = normalizeText(body.groupId); + if (!storeId || !groupId) { + return { code: 400, data: null, message: '参数不完整' }; + } + const state = ensureStoreState(storeId); + state.addonGroups = state.addonGroups.filter((item) => item.id !== groupId); + return { code: 200, data: null }; + }, +); + +Mock.mock( + /\/product\/addon\/group\/status/, + 'post', + (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + const groupId = normalizeText(body.groupId); + if (!storeId || !groupId) { + return { code: 400, data: null, message: '参数不完整' }; + } + const state = ensureStoreState(storeId); + const target = state.addonGroups.find((item) => item.id === groupId); + if (!target) return { code: 404, data: null, message: '加料组不存在' }; + target.status = normalizeSwitchStatus(body.status, target.status); + target.updatedAt = toDateTimeText(new Date()); + return { code: 200, data: toAddonGroupItem(state, target) }; + }, +); + +Mock.mock( + /\/product\/label\/list(?:\?|$)/, + 'get', + (options: MockRequestOptions) => { + const params = parseUrlParams(options.url); + const storeId = normalizeText(params.storeId); + const keyword = normalizeText(params.keyword).toLowerCase(); + const status = normalizeText(params.status); + const state = ensureStoreState(storeId); + + const list = state.labels + .toSorted((a, b) => a.sort - b.sort) + .filter((item) => { + if (status && item.status !== status) return false; + if (!keyword) return true; + return ( + item.name.toLowerCase().includes(keyword) || + item.description.toLowerCase().includes(keyword) + ); + }) + .map((item) => toLabelItem(state, item)); + + return { code: 200, data: list }; + }, +); + +Mock.mock(/\/product\/label\/save/, 'post', (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + const id = normalizeText(body.id); + const name = normalizeText(body.name); + if (!storeId || !name) { + return { code: 400, data: null, message: '参数不完整' }; + } + + const state = ensureStoreState(storeId); + const existingIndex = state.labels.findIndex((item) => item.id === id); + const fallbackSort = + state.labels.reduce((max, item) => Math.max(max, item.sort), 0) + 1; + const productIds = normalizeIdList(body.productIds).filter((productId) => + state.products.some((item) => item.id === productId), + ); + + const next: LabelRecord = + existingIndex === -1 + ? { + id: createId('label', storeId), + name, + color: normalizeText(body.color, '#1677ff'), + description: normalizeText(body.description), + sort: normalizeInt(body.sort, fallbackSort, 1), + status: normalizeSwitchStatus(body.status, 'enabled'), + productIds, + updatedAt: toDateTimeText(new Date()), + } + : { + ...state.labels[existingIndex], + name, + color: normalizeText(body.color, state.labels[existingIndex].color), + description: normalizeText( + body.description, + state.labels[existingIndex].description, + ), + sort: normalizeInt(body.sort, state.labels[existingIndex].sort, 1), + status: normalizeSwitchStatus( + body.status, + state.labels[existingIndex].status, + ), + productIds, + updatedAt: toDateTimeText(new Date()), + }; + + if (existingIndex === -1) { + state.labels.push(next); + } else { + state.labels.splice(existingIndex, 1, next); + } + + return { code: 200, data: toLabelItem(state, next) }; +}); + +Mock.mock(/\/product\/label\/delete/, 'post', (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + const labelId = normalizeText(body.labelId); + if (!storeId || !labelId) + return { code: 400, data: null, message: '参数不完整' }; + const state = ensureStoreState(storeId); + state.labels = state.labels.filter((item) => item.id !== labelId); + return { code: 200, data: null }; +}); + +Mock.mock(/\/product\/label\/status/, 'post', (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + const labelId = normalizeText(body.labelId); + if (!storeId || !labelId) + return { code: 400, data: null, message: '参数不完整' }; + const state = ensureStoreState(storeId); + const target = state.labels.find((item) => item.id === labelId); + if (!target) return { code: 404, data: null, message: '标签不存在' }; + target.status = normalizeSwitchStatus(body.status, target.status); + target.updatedAt = toDateTimeText(new Date()); + return { code: 200, data: toLabelItem(state, target) }; +}); + +Mock.mock( + /\/product\/schedule\/list(?:\?|$)/, + 'get', + (options: MockRequestOptions) => { + const params = parseUrlParams(options.url); + const storeId = normalizeText(params.storeId); + const keyword = normalizeText(params.keyword).toLowerCase(); + const status = normalizeText(params.status); + const state = ensureStoreState(storeId); + + const list = state.schedules + .toSorted((a, b) => a.sort - b.sort) + .filter((item) => { + if (status && item.status !== status) return false; + if (!keyword) return true; + return ( + item.name.toLowerCase().includes(keyword) || + item.description.toLowerCase().includes(keyword) + ); + }) + .map((item) => toScheduleItem(state, item)); + + return { code: 200, data: list }; + }, +); + +Mock.mock( + /\/product\/schedule\/save/, + 'post', + (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + const id = normalizeText(body.id); + const name = normalizeText(body.name); + if (!storeId || !name) { + return { code: 400, data: null, message: '参数不完整' }; + } + + const state = ensureStoreState(storeId); + const existingIndex = state.schedules.findIndex((item) => item.id === id); + const fallbackSort = + state.schedules.reduce((max, item) => Math.max(max, item.sort), 0) + 1; + const productIds = normalizeIdList(body.productIds).filter((productId) => + state.products.some((item) => item.id === productId), + ); + + const slotListRaw = Array.isArray(body.slots) ? body.slots : []; + const slots: ScheduleSlotRecord[] = slotListRaw + .map((slot) => { + if (!slot || typeof slot !== 'object') return null; + const current = slot as Record; + return { + id: normalizeText(current.id, createId('slot', storeId)), + weekDays: normalizeWeekDays(current.weekDays), + startTime: normalizeTimeText(current.startTime, '09:00'), + endTime: normalizeTimeText(current.endTime, '21:00'), + }; + }) + .filter((item): item is ScheduleSlotRecord => item !== null) + .toSorted((a, b) => a.startTime.localeCompare(b.startTime)); + + if (slots.length === 0) { + return { code: 400, data: null, message: '至少保留一个时段' }; + } + + const next: ScheduleRecord = + existingIndex === -1 + ? { + id: createId('schedule', storeId), + name, + description: normalizeText(body.description), + sort: normalizeInt(body.sort, fallbackSort, 1), + status: normalizeSwitchStatus(body.status, 'enabled'), + productIds, + slots, + updatedAt: toDateTimeText(new Date()), + } + : { + ...state.schedules[existingIndex], + name, + description: normalizeText( + body.description, + state.schedules[existingIndex].description, + ), + sort: normalizeInt( + body.sort, + state.schedules[existingIndex].sort, + 1, + ), + status: normalizeSwitchStatus( + body.status, + state.schedules[existingIndex].status, + ), + productIds, + slots, + updatedAt: toDateTimeText(new Date()), + }; + + if (existingIndex === -1) { + state.schedules.push(next); + } else { + state.schedules.splice(existingIndex, 1, next); + } + + return { code: 200, data: toScheduleItem(state, next) }; + }, +); + +Mock.mock( + /\/product\/schedule\/delete/, + 'post', + (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + const scheduleId = normalizeText(body.scheduleId); + if (!storeId || !scheduleId) { + return { code: 400, data: null, message: '参数不完整' }; + } + const state = ensureStoreState(storeId); + state.schedules = state.schedules.filter((item) => item.id !== scheduleId); + return { code: 200, data: null }; + }, +); + +Mock.mock( + /\/product\/schedule\/status/, + 'post', + (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + const scheduleId = normalizeText(body.scheduleId); + if (!storeId || !scheduleId) { + return { code: 400, data: null, message: '参数不完整' }; + } + const state = ensureStoreState(storeId); + const target = state.schedules.find((item) => item.id === scheduleId); + if (!target) return { code: 404, data: null, message: '时段不存在' }; + target.status = normalizeSwitchStatus(body.status, target.status); + target.updatedAt = toDateTimeText(new Date()); + return { code: 200, data: toScheduleItem(state, target) }; + }, +); + +Mock.mock( + /\/product\/batch\/price-adjust/, + 'post', + (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + const mode = normalizeText(body.mode, 'increase'); + const amount = normalizeNumber(body.amount, 0, 0); + if (!storeId || amount < 0) { + return { code: 400, data: null, message: '参数不完整' }; + } + + const scope = (body.scope ?? { type: 'all' }) as ProductBatchScope; + const state = ensureStoreState(storeId); + const targets = resolveScopedProducts(state, scope); + + for (const item of targets) { + if (mode === 'set') { + item.price = Number(amount.toFixed(2)); + } else if (mode === 'decrease') { + item.price = Number(Math.max(0, item.price - amount).toFixed(2)); + } else { + item.price = Number((item.price + amount).toFixed(2)); + } + } + + return { + code: 200, + data: { + totalCount: targets.length, + successCount: targets.length, + failedCount: 0, + }, + }; + }, +); + +Mock.mock( + /\/product\/batch\/move-category/, + 'post', + (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + const targetCategoryId = normalizeText(body.targetCategoryId); + if (!storeId || !targetCategoryId) { + return { code: 400, data: null, message: '参数不完整' }; + } + const state = ensureStoreState(storeId); + if (!hasCategory(state, targetCategoryId)) { + return { code: 404, data: null, message: '目标分类不存在' }; + } + + const scope = (body.scope ?? { type: 'all' }) as ProductBatchScope; + const targets = resolveScopedProducts(state, scope); + for (const item of targets) { + item.categoryId = targetCategoryId; + } + + return { + code: 200, + data: { + totalCount: targets.length, + successCount: targets.length, + failedCount: 0, + }, + }; + }, +); + +Mock.mock( + /\/product\/batch\/sale-switch/, + 'post', + (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + const action = normalizeText(body.action, 'off'); + if (!storeId) return { code: 400, data: null, message: '参数不完整' }; + + const scope = (body.scope ?? { type: 'all' }) as ProductBatchScope; + const state = ensureStoreState(storeId); + const targets = resolveScopedProducts(state, scope); + for (const item of targets) { + item.status = normalizeProductStatus( + action === 'on' ? 'on_sale' : 'off_shelf', + item.status, + ); + } + + return { + code: 200, + data: { + totalCount: targets.length, + successCount: targets.length, + failedCount: 0, + }, + }; + }, +); + +Mock.mock( + /\/product\/batch\/store-sync/, + 'post', + (options: MockRequestOptions) => { + const body = parseBody(options); + const sourceStoreId = normalizeText(body.sourceStoreId || body.storeId); + const targetStoreIds = normalizeIdList(body.targetStoreIds); + const productIds = normalizeIdList(body.productIds); + if ( + !sourceStoreId || + targetStoreIds.length === 0 || + productIds.length === 0 + ) { + return { code: 400, data: null, message: '参数不完整' }; + } + + const source = ensureStoreState(sourceStoreId); + const sourceProducts = source.products.filter((item) => + productIds.includes(item.id), + ); + if (sourceProducts.length === 0) { + return { code: 400, data: null, message: '未找到待同步商品' }; + } + + let successCount = 0; + + for (const targetStoreId of targetStoreIds) { + const target = ensureStoreState(targetStoreId); + for (const sourceProduct of sourceProducts) { + const targetCategoryId = hasCategory(target, sourceProduct.categoryId) + ? sourceProduct.categoryId + : target.categories[0]?.id; + if (!targetCategoryId) continue; + + const sameSpu = target.products.find( + (item) => item.spuCode === sourceProduct.spuCode, + ); + if (sameSpu) { + sameSpu.name = sourceProduct.name; + sameSpu.price = sourceProduct.price; + sameSpu.categoryId = targetCategoryId; + sameSpu.status = sourceProduct.status; + } else { + target.products.push({ + ...sourceProduct, + id: createId('ext-prd', targetStoreId), + categoryId: targetCategoryId, + }); + } + successCount += 1; + } + cleanupRelationIds(target); + } + + return { + code: 200, + data: { + totalCount: sourceProducts.length * targetStoreIds.length, + successCount, + failedCount: Math.max( + sourceProducts.length * targetStoreIds.length - successCount, + 0, + ), + }, + }; + }, +); + +Mock.mock(/\/product\/batch\/import/, 'post', (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + if (!storeId) return { code: 400, data: null, message: '参数不完整' }; + const state = ensureStoreState(storeId); + const scope = (body.scope ?? { type: 'all' }) as ProductBatchScope; + const targets = resolveScopedProducts(state, scope); + return { + code: 200, + data: { + fileName: `product-import-${storeId}.xlsx`, + totalCount: targets.length, + successCount: targets.length, + failedCount: 0, + skippedCount: 0, + }, + }; +}); + +Mock.mock(/\/product\/batch\/export/, 'post', (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + if (!storeId) return { code: 400, data: null, message: '参数不完整' }; + const state = ensureStoreState(storeId); + const scope = (body.scope ?? { type: 'all' }) as ProductBatchScope; + const targets = resolveScopedProducts(state, scope); + return { + code: 200, + data: { + fileName: `product-export-${storeId}.xlsx`, + totalCount: targets.length, + successCount: targets.length, + failedCount: 0, + exportedCount: targets.length, + }, + }; +}); diff --git a/apps/web-antd/src/router/routes/modules/product.ts b/apps/web-antd/src/router/routes/modules/product.ts index 1a5deb2..577ddf9 100644 --- a/apps/web-antd/src/router/routes/modules/product.ts +++ b/apps/web-antd/src/router/routes/modules/product.ts @@ -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', diff --git a/apps/web-antd/src/views/product/addons/index.vue b/apps/web-antd/src/views/product/addons/index.vue new file mode 100644 index 0000000..b3a5db1 --- /dev/null +++ b/apps/web-antd/src/views/product/addons/index.vue @@ -0,0 +1,564 @@ + + + + + diff --git a/apps/web-antd/src/views/product/batch/index.vue b/apps/web-antd/src/views/product/batch/index.vue new file mode 100644 index 0000000..db8bc8d --- /dev/null +++ b/apps/web-antd/src/views/product/batch/index.vue @@ -0,0 +1,522 @@ + + + + + diff --git a/apps/web-antd/src/views/product/category/components/CategoryDetailPanel.vue b/apps/web-antd/src/views/product/category/components/CategoryDetailPanel.vue new file mode 100644 index 0000000..7d90861 --- /dev/null +++ b/apps/web-antd/src/views/product/category/components/CategoryDetailPanel.vue @@ -0,0 +1,207 @@ + + + diff --git a/apps/web-antd/src/views/product/category/components/CategoryProductPickerModal.vue b/apps/web-antd/src/views/product/category/components/CategoryProductPickerModal.vue new file mode 100644 index 0000000..4f1f99c --- /dev/null +++ b/apps/web-antd/src/views/product/category/components/CategoryProductPickerModal.vue @@ -0,0 +1,87 @@ + + + diff --git a/apps/web-antd/src/views/product/category/components/CategorySidebar.vue b/apps/web-antd/src/views/product/category/components/CategorySidebar.vue new file mode 100644 index 0000000..0724f9d --- /dev/null +++ b/apps/web-antd/src/views/product/category/components/CategorySidebar.vue @@ -0,0 +1,117 @@ + + + diff --git a/apps/web-antd/src/views/product/category/components/CategoryStatsBar.vue b/apps/web-antd/src/views/product/category/components/CategoryStatsBar.vue new file mode 100644 index 0000000..f6c689d --- /dev/null +++ b/apps/web-antd/src/views/product/category/components/CategoryStatsBar.vue @@ -0,0 +1,44 @@ + + + diff --git a/apps/web-antd/src/views/product/category/composables/useProductCategoryPage.ts b/apps/web-antd/src/views/product/category/composables/useProductCategoryPage.ts new file mode 100644 index 0000000..90721ef --- /dev/null +++ b/apps/web-antd/src/views/product/category/composables/useProductCategoryPage.ts @@ -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([]); + const selectedStoreId = ref(''); + const isStoreLoading = ref(false); + + const categories = ref([]); + const isCategoryLoading = ref(false); + const selectedCategoryId = ref(''); + const categoryKeyword = ref(''); + const channelFilter = ref('all'); + + const categoryProducts = ref([]); + const isCategoryProductsLoading = ref(false); + + const isDrawerOpen = ref(false); + const drawerMode = ref('create'); + const isDrawerSubmitting = ref(false); + const form = reactive({ + id: '', + name: '', + description: '', + icon: '', + channels: [...CATEGORY_CHANNEL_ORDER], + sort: 1, + status: 'enabled', + }); + + const isPickerOpen = ref(false); + const pickerKeyword = ref(''); + const pickerProducts = ref([]); + const pickerSelectedIds = ref([]); + const isPickerLoading = ref(false); + const isPickerSubmitting = ref(false); + + const isCopyModalOpen = ref(false); + const isCopySubmitting = ref(false); + const copyTargetStoreIds = ref([]); + + const storeOptions = computed(() => + stores.value.map((item) => ({ + label: item.name, + value: item.id, + })), + ); + + const selectedCategory = computed(() => + categories.value.find((item) => item.id === selectedCategoryId.value), + ); + + const stats = computed(() => { + 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, + }; +} diff --git a/apps/web-antd/src/views/product/category/index.vue b/apps/web-antd/src/views/product/category/index.vue new file mode 100644 index 0000000..426c4a9 --- /dev/null +++ b/apps/web-antd/src/views/product/category/index.vue @@ -0,0 +1,189 @@ + + + + + diff --git a/apps/web-antd/src/views/product/category/styles/base.less b/apps/web-antd/src/views/product/category/styles/base.less new file mode 100644 index 0000000..f4cc181 --- /dev/null +++ b/apps/web-antd/src/views/product/category/styles/base.less @@ -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; + } +} diff --git a/apps/web-antd/src/views/product/category/styles/detail.less b/apps/web-antd/src/views/product/category/styles/detail.less new file mode 100644 index 0000000..0de22ec --- /dev/null +++ b/apps/web-antd/src/views/product/category/styles/detail.less @@ -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; + } +} diff --git a/apps/web-antd/src/views/product/category/styles/drawer.less b/apps/web-antd/src/views/product/category/styles/drawer.less new file mode 100644 index 0000000..766cba4 --- /dev/null +++ b/apps/web-antd/src/views/product/category/styles/drawer.less @@ -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; + } +} diff --git a/apps/web-antd/src/views/product/category/styles/index.less b/apps/web-antd/src/views/product/category/styles/index.less new file mode 100644 index 0000000..03c62e9 --- /dev/null +++ b/apps/web-antd/src/views/product/category/styles/index.less @@ -0,0 +1,6 @@ +@import './base.less'; +@import './sidebar.less'; +@import './detail.less'; +@import './drawer.less'; +@import './modal.less'; +@import './responsive.less'; diff --git a/apps/web-antd/src/views/product/category/styles/modal.less b/apps/web-antd/src/views/product/category/styles/modal.less new file mode 100644 index 0000000..96d2328 --- /dev/null +++ b/apps/web-antd/src/views/product/category/styles/modal.less @@ -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; + } +} diff --git a/apps/web-antd/src/views/product/category/styles/responsive.less b/apps/web-antd/src/views/product/category/styles/responsive.less new file mode 100644 index 0000000..5846204 --- /dev/null +++ b/apps/web-antd/src/views/product/category/styles/responsive.less @@ -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; + } + } +} diff --git a/apps/web-antd/src/views/product/category/styles/sidebar.less b/apps/web-antd/src/views/product/category/styles/sidebar.less new file mode 100644 index 0000000..f69d004 --- /dev/null +++ b/apps/web-antd/src/views/product/category/styles/sidebar.less @@ -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%); + } +} diff --git a/apps/web-antd/src/views/product/category/types.ts b/apps/web-antd/src/views/product/category/types.ts new file mode 100644 index 0000000..75bc0da --- /dev/null +++ b/apps/web-antd/src/views/product/category/types.ts @@ -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; +} diff --git a/apps/web-antd/src/views/product/detail/index.vue b/apps/web-antd/src/views/product/detail/index.vue index 2e96db5..1e2237e 100644 --- a/apps/web-antd/src/views/product/detail/index.vue +++ b/apps/web-antd/src/views/product/detail/index.vue @@ -1,60 +1,171 @@ + + + + diff --git a/apps/web-antd/src/views/product/schedule/index.vue b/apps/web-antd/src/views/product/schedule/index.vue new file mode 100644 index 0000000..7a77be2 --- /dev/null +++ b/apps/web-antd/src/views/product/schedule/index.vue @@ -0,0 +1,559 @@ + + + + + diff --git a/apps/web-antd/src/views/product/specs/index.vue b/apps/web-antd/src/views/product/specs/index.vue new file mode 100644 index 0000000..cdfcfba --- /dev/null +++ b/apps/web-antd/src/views/product/specs/index.vue @@ -0,0 +1,502 @@ + + + + + diff --git a/apps/web-antd/src/views/store/components/CopyToStoresModal.vue b/apps/web-antd/src/views/shared/components/CopyToStoresModal.vue similarity index 100% rename from apps/web-antd/src/views/store/components/CopyToStoresModal.vue rename to apps/web-antd/src/views/shared/components/CopyToStoresModal.vue diff --git a/apps/web-antd/src/views/store/components/StoreScopeToolbar.vue b/apps/web-antd/src/views/shared/components/StoreScopeToolbar.vue similarity index 81% rename from apps/web-antd/src/views/store/components/StoreScopeToolbar.vue rename to apps/web-antd/src/views/shared/components/StoreScopeToolbar.vue index 3b3c00b..3230ee9 100644 --- a/apps/web-antd/src/views/store/components/StoreScopeToolbar.vue +++ b/apps/web-antd/src/views/shared/components/StoreScopeToolbar.vue @@ -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(), { 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)" /> -
- +
+ + diff --git a/apps/web-antd/src/views/store/delivery/index.vue b/apps/web-antd/src/views/store/delivery/index.vue index 1f40512..ec53ed0 100644 --- a/apps/web-antd/src/views/store/delivery/index.vue +++ b/apps/web-antd/src/views/store/delivery/index.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 DeliveryCommonSettingsCard from './components/DeliveryCommonSettingsCard.vue'; import DeliveryModeCard from './components/DeliveryModeCard.vue'; import DeliveryTierDrawer from './components/DeliveryTierDrawer.vue'; diff --git a/apps/web-antd/src/views/store/dine-in/index.vue b/apps/web-antd/src/views/store/dine-in/index.vue index 444266e..4fc0565 100644 --- a/apps/web-antd/src/views/store/dine-in/index.vue +++ b/apps/web-antd/src/views/store/dine-in/index.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'; diff --git a/apps/web-antd/src/views/store/fees/index.vue b/apps/web-antd/src/views/store/fees/index.vue index 6560ef9..32c4640 100644 --- a/apps/web-antd/src/views/store/fees/index.vue +++ b/apps/web-antd/src/views/store/fees/index.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'; diff --git a/apps/web-antd/src/views/store/hours/index.vue b/apps/web-antd/src/views/store/hours/index.vue index ef174d4..0b558fb 100644 --- a/apps/web-antd/src/views/store/hours/index.vue +++ b/apps/web-antd/src/views/store/hours/index.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'; diff --git a/apps/web-antd/src/views/store/pickup/index.vue b/apps/web-antd/src/views/store/pickup/index.vue index 8531fd5..2c07443 100644 --- a/apps/web-antd/src/views/store/pickup/index.vue +++ b/apps/web-antd/src/views/store/pickup/index.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'; diff --git a/apps/web-antd/src/views/store/staff/index.vue b/apps/web-antd/src/views/store/staff/index.vue index c484bcf..016e119 100644 --- a/apps/web-antd/src/views/store/staff/index.vue +++ b/apps/web-antd/src/views/store/staff/index.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'; diff --git a/packages/effects/common-ui/src/components/cropper/cropper.vue b/packages/effects/common-ui/src/components/cropper/cropper.vue index 1b46705..28a1cb4 100644 --- a/packages/effects/common-ui/src/components/cropper/cropper.vue +++ b/packages/effects/common-ui/src/components/cropper/cropper.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; }