From 3b96b3f92d10d144200075b0b56672b231ad60d7 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Thu, 19 Feb 2026 17:15:19 +0800 Subject: [PATCH] feat(project): align store delivery pages with live APIs and geocode status --- apps/web-antd/.env | 3 + apps/web-antd/src/api/merchant/index.ts | 22 + apps/web-antd/src/api/product/index.ts | 194 ++++ apps/web-antd/src/api/store/index.ts | 22 + apps/web-antd/src/enums/merchantEnum.ts | 6 + apps/web-antd/src/enums/storeEnum.ts | 14 +- apps/web-antd/src/mock/product.ts | 879 ++++++++++++++++++ .../src/router/routes/modules/product.ts | 36 + .../src/views/product/detail/index.vue | 144 +++ .../views/product/detail/styles/index.less | 74 ++ .../list/components/ProductActionBar.vue | 61 ++ .../components/ProductCategorySidebar.vue | 48 + .../list/components/ProductEditorDrawer.vue | 274 ++++++ .../list/components/ProductFilterToolbar.vue | 148 +++ .../list/components/ProductListSection.vue | 380 ++++++++ .../components/ProductQuickEditDrawer.vue | 157 ++++ .../list/components/ProductSoldoutDrawer.vue | 184 ++++ .../product-list-page/batch-actions.ts | 73 ++ .../product-list-page/constants.ts | 125 +++ .../product-list-page/data-actions.ts | 125 +++ .../product-list-page/drawer-actions.ts | 288 ++++++ .../composables/product-list-page/helpers.ts | 247 +++++ .../product-list-page/list-actions.ts | 86 ++ .../list/composables/useProductListPage.ts | 489 ++++++++++ .../web-antd/src/views/product/list/index.vue | 253 +++++ .../views/product/list/styles/actionbar.less | 24 + .../src/views/product/list/styles/base.less | 28 + .../src/views/product/list/styles/drawer.less | 207 +++++ .../src/views/product/list/styles/index.less | 8 + .../src/views/product/list/styles/list.less | 347 +++++++ .../views/product/list/styles/responsive.less | 77 ++ .../views/product/list/styles/sidebar.less | 68 ++ .../views/product/list/styles/toolbar.less | 54 ++ apps/web-antd/src/views/product/list/types.ts | 86 ++ .../delivery/components/DeliveryModeCard.vue | 96 +- .../components/DeliveryPolygonMapModal.vue | 731 +++++++++++++++ .../components/DeliveryZoneDrawer.vue | 69 ++ .../components/PolygonZoneSection.vue | 9 + .../composables/delivery-page/constants.ts | 37 + .../composables/delivery-page/data-actions.ts | 13 + .../composables/delivery-page/geojson.ts | 102 ++ .../composables/delivery-page/helpers.ts | 4 + .../composables/delivery-page/zone-actions.ts | 16 + .../composables/useStoreDeliveryPage.ts | 109 ++- .../composables/useTencentMapLoader.ts | 129 +++ .../src/views/store/delivery/index.vue | 93 +- .../src/views/store/delivery/styles/base.less | 6 + .../views/store/delivery/styles/drawer.less | 94 ++ .../src/views/store/delivery/styles/mode.less | 185 ++-- .../store/delivery/styles/responsive.less | 19 - .../src/views/store/delivery/styles/zone.less | 6 + .../src/views/store/delivery/types.ts | 3 + .../dine-in/composables/useStoreDineInPage.ts | 4 +- .../fees/composables/useStoreFeesPage.ts | 4 +- .../hours/composables/useStoreHoursPage.ts | 4 +- .../store/list/components/StoreListTable.vue | 38 + .../composables/store-list-page/constants.ts | 10 +- .../store-list-page/drawer-actions.ts | 13 + .../list/composables/useStoreListPage.ts | 26 +- apps/web-antd/src/views/store/list/index.vue | 4 + .../pickup/composables/useStorePickupPage.ts | 4 +- .../staff/composables/useStoreStaffPage.ts | 4 +- 62 files changed, 6813 insertions(+), 250 deletions(-) create mode 100644 apps/web-antd/src/api/product/index.ts create mode 100644 apps/web-antd/src/mock/product.ts create mode 100644 apps/web-antd/src/router/routes/modules/product.ts create mode 100644 apps/web-antd/src/views/product/detail/index.vue create mode 100644 apps/web-antd/src/views/product/detail/styles/index.less create mode 100644 apps/web-antd/src/views/product/list/components/ProductActionBar.vue create mode 100644 apps/web-antd/src/views/product/list/components/ProductCategorySidebar.vue create mode 100644 apps/web-antd/src/views/product/list/components/ProductEditorDrawer.vue create mode 100644 apps/web-antd/src/views/product/list/components/ProductFilterToolbar.vue create mode 100644 apps/web-antd/src/views/product/list/components/ProductListSection.vue create mode 100644 apps/web-antd/src/views/product/list/components/ProductQuickEditDrawer.vue create mode 100644 apps/web-antd/src/views/product/list/components/ProductSoldoutDrawer.vue create mode 100644 apps/web-antd/src/views/product/list/composables/product-list-page/batch-actions.ts create mode 100644 apps/web-antd/src/views/product/list/composables/product-list-page/constants.ts create mode 100644 apps/web-antd/src/views/product/list/composables/product-list-page/data-actions.ts create mode 100644 apps/web-antd/src/views/product/list/composables/product-list-page/drawer-actions.ts create mode 100644 apps/web-antd/src/views/product/list/composables/product-list-page/helpers.ts create mode 100644 apps/web-antd/src/views/product/list/composables/product-list-page/list-actions.ts create mode 100644 apps/web-antd/src/views/product/list/composables/useProductListPage.ts create mode 100644 apps/web-antd/src/views/product/list/index.vue create mode 100644 apps/web-antd/src/views/product/list/styles/actionbar.less create mode 100644 apps/web-antd/src/views/product/list/styles/base.less create mode 100644 apps/web-antd/src/views/product/list/styles/drawer.less create mode 100644 apps/web-antd/src/views/product/list/styles/index.less create mode 100644 apps/web-antd/src/views/product/list/styles/list.less create mode 100644 apps/web-antd/src/views/product/list/styles/responsive.less create mode 100644 apps/web-antd/src/views/product/list/styles/sidebar.less create mode 100644 apps/web-antd/src/views/product/list/styles/toolbar.less create mode 100644 apps/web-antd/src/views/product/list/types.ts create mode 100644 apps/web-antd/src/views/store/delivery/components/DeliveryPolygonMapModal.vue create mode 100644 apps/web-antd/src/views/store/delivery/composables/delivery-page/geojson.ts create mode 100644 apps/web-antd/src/views/store/delivery/composables/useTencentMapLoader.ts diff --git a/apps/web-antd/.env b/apps/web-antd/.env index 19735f3..63e885d 100644 --- a/apps/web-antd/.env +++ b/apps/web-antd/.env @@ -6,3 +6,6 @@ VITE_APP_NAMESPACE=vben-web-antd # 对store进行加密的密钥,在将store持久化到localStorage时会使用该密钥进行加密 VITE_APP_STORE_SECURE_KEY=please-replace-me-with-your-own-key + +# 腾讯地图 WebGL JS SDK Key +VITE_TENCENT_MAP_KEY=DGIBZ-YUM64-5ONUT-F4RIA-MPUP2-XFFXZ diff --git a/apps/web-antd/src/api/merchant/index.ts b/apps/web-antd/src/api/merchant/index.ts index c479a5a..6c8ddea 100644 --- a/apps/web-antd/src/api/merchant/index.ts +++ b/apps/web-antd/src/api/merchant/index.ts @@ -1,5 +1,6 @@ import type { ContractStatus, + GeoLocationStatus, MerchantDocumentStatus, MerchantDocumentType, MerchantStatus, @@ -51,6 +52,22 @@ export interface MerchantDetailDto { legalRepresentative?: string; /** 注册地址 */ registeredAddress?: string; + /** 所在省份 */ + province?: string; + /** 所在城市 */ + city?: string; + /** 所在区县 */ + district?: string; + /** 商户经度 */ + longitude?: null | number; + /** 商户纬度 */ + latitude?: null | number; + /** 地理定位状态 */ + geoStatus?: GeoLocationStatus; + /** 地理定位失败原因 */ + geoFailReason?: string; + /** 地理定位成功时间 */ + geoUpdatedAt?: null | string; /** 联系电话 */ contactPhone?: string; /** 联系邮箱 */ @@ -196,3 +213,8 @@ export interface UpdateMerchantDto { export async function updateMerchantInfoApi(data: UpdateMerchantDto) { return requestClient.post('/merchant/update', data); } + +/** 手动重试当前商户地理定位 */ +export async function retryMerchantGeocodeApi() { + return requestClient.post('/merchant/geocode/retry'); +} diff --git a/apps/web-antd/src/api/product/index.ts b/apps/web-antd/src/api/product/index.ts new file mode 100644 index 0000000..7c67423 --- /dev/null +++ b/apps/web-antd/src/api/product/index.ts @@ -0,0 +1,194 @@ +/** + * 文件职责:商品管理模块 API 与 DTO 定义。 + * 1. 维护商品列表、分类、详情、批量操作契约。 + * 2. 提供商品查询、保存、状态变更、沽清与批量动作接口。 + */ +import type { PaginatedResult } from '#/api/store'; + +import { requestClient } from '#/api/request'; + +/** 商品状态。 */ +export type ProductStatus = 'off_shelf' | 'on_sale' | 'sold_out'; + +/** 商品类型。 */ +export type ProductKind = 'combo' | 'single'; + +/** 沽清模式。 */ +export type ProductSoldoutMode = 'permanent' | 'timed' | 'today'; + +/** 分类信息。 */ +export interface ProductCategoryDto { + id: string; + name: string; + productCount: number; + sort: number; +} + +/** 商品列表项。 */ +export interface ProductListItemDto { + categoryId: string; + categoryName: string; + id: string; + imageUrl: string; + kind: ProductKind; + name: string; + originalPrice: null | number; + price: number; + salesMonthly: number; + soldoutMode: null | ProductSoldoutMode; + spuCode: string; + status: ProductStatus; + stock: number; + subtitle: string; + tags: string[]; +} + +/** 商品详情。 */ +export interface ProductDetailDto extends ProductListItemDto { + description: string; + notifyManager: boolean; + recoverAt: null | string; + remainStock: number; + soldoutReason: string; + syncToPlatform: boolean; +} + +/** 商品列表查询参数。 */ +export interface ProductListQuery { + categoryId?: string; + kind?: ProductKind; + keyword?: string; + page: number; + pageSize: number; + status?: ProductStatus; + storeId: string; +} + +/** 查询详情参数。 */ +export interface ProductDetailQuery { + productId: string; + storeId: string; +} + +/** 保存商品参数。 */ +export interface SaveProductDto { + categoryId: string; + description: string; + id?: string; + kind: ProductKind; + name: string; + originalPrice: null | number; + price: number; + shelfMode: 'draft' | 'now' | 'scheduled'; + spuCode?: string; + status: ProductStatus; + stock: number; + storeId: string; + subtitle: string; + tags: string[]; + timedOnShelfAt?: string; +} + +/** 删除商品参数。 */ +export interface DeleteProductDto { + productId: string; + storeId: string; +} + +/** 修改商品状态参数。 */ +export interface ChangeProductStatusDto { + productId: string; + status: ProductStatus; + storeId: string; +} + +/** 商品沽清参数。 */ +export interface SoldoutProductDto { + mode: ProductSoldoutMode; + notifyManager: boolean; + productId: string; + reason: string; + recoverAt?: string; + remainStock: number; + storeId: string; + syncToPlatform: boolean; +} + +/** 批量动作类型。 */ +export type BatchProductActionType = + | 'batch_delete' + | 'batch_off' + | 'batch_on' + | 'batch_soldout'; + +/** 批量商品操作参数。 */ +export interface BatchProductActionDto { + action: BatchProductActionType; + notifyManager?: boolean; + productIds: string[]; + reason?: string; + recoverAt?: string; + remainStock?: number; + storeId: string; + syncToPlatform?: boolean; +} + +/** 批量操作返回。 */ +export interface BatchProductActionResultDto { + action: BatchProductActionType; + failedCount: number; + successCount: number; + totalCount: number; +} + +/** 获取商品分类。 */ +export async function getProductCategoryListApi(storeId: string) { + return requestClient.get('/product/category/list', { + params: { storeId }, + }); +} + +/** 获取商品列表。 */ +export async function getProductListApi(params: ProductListQuery) { + return requestClient.get>( + '/product/list', + { + params, + }, + ); +} + +/** 获取商品详情。 */ +export async function getProductDetailApi(params: ProductDetailQuery) { + return requestClient.get('/product/detail', { + params, + }); +} + +/** 保存商品(新增/编辑)。 */ +export async function saveProductApi(data: SaveProductDto) { + return requestClient.post('/product/save', data); +} + +/** 删除商品。 */ +export async function deleteProductApi(data: DeleteProductDto) { + return requestClient.post('/product/delete', data); +} + +/** 修改商品状态。 */ +export async function changeProductStatusApi(data: ChangeProductStatusDto) { + return requestClient.post('/product/status/change', data); +} + +/** 提交沽清。 */ +export async function soldoutProductApi(data: SoldoutProductDto) { + return requestClient.post('/product/soldout', data); +} + +/** 批量商品操作。 */ +export async function batchProductActionApi(data: BatchProductActionDto) { + return requestClient.post( + '/product/batch', + data, + ); +} diff --git a/apps/web-antd/src/api/store/index.ts b/apps/web-antd/src/api/store/index.ts index f938733..a9bf850 100644 --- a/apps/web-antd/src/api/store/index.ts +++ b/apps/web-antd/src/api/store/index.ts @@ -1,4 +1,5 @@ import type { + GeoLocationStatus, ServiceType, StoreAuditStatus, StoreBusinessStatus, @@ -22,6 +23,22 @@ export interface StoreListItemDto { managerName: string; /** 门店地址 */ address: string; + /** 所在省份 */ + province?: string; + /** 所在城市 */ + city?: string; + /** 所在区县 */ + district?: string; + /** 门店经度 */ + longitude?: null | number; + /** 门店纬度 */ + latitude?: null | number; + /** 地理定位状态 */ + geoStatus?: GeoLocationStatus; + /** 地理定位失败原因 */ + geoFailReason?: string; + /** 地理定位成功时间 */ + geoUpdatedAt?: null | string; /** 门店封面图 */ coverImage?: string; /** 营业状态 */ @@ -117,3 +134,8 @@ export async function toggleStoreBusinessStatusApi( ) { return requestClient.post('/store/toggle-business-status', data); } + +/** 手动重试门店地理定位 */ +export async function retryStoreGeocodeApi(storeId: string) { + return requestClient.post(`/store/${storeId}/geocode/retry`); +} diff --git a/apps/web-antd/src/enums/merchantEnum.ts b/apps/web-antd/src/enums/merchantEnum.ts index 2bcba9d..9ecac13 100644 --- a/apps/web-antd/src/enums/merchantEnum.ts +++ b/apps/web-antd/src/enums/merchantEnum.ts @@ -60,3 +60,9 @@ export enum MerchantAuditAction { Unknown = 0, Update = 2, } + +export enum GeoLocationStatus { + Failed = 2, + Pending = 0, + Success = 1, +} diff --git a/apps/web-antd/src/enums/storeEnum.ts b/apps/web-antd/src/enums/storeEnum.ts index 0d572c6..63faf9b 100644 --- a/apps/web-antd/src/enums/storeEnum.ts +++ b/apps/web-antd/src/enums/storeEnum.ts @@ -10,12 +10,12 @@ export enum StoreBusinessStatus { /** 门店审核状态 */ export enum StoreAuditStatus { + /** 已通过 */ + Approved = 2, /** 草稿 */ Draft = 0, /** 待审核 */ Pending = 1, - /** 已通过 */ - Approved = 2, /** 已拒绝 */ Rejected = 3, } @@ -29,3 +29,13 @@ export enum ServiceType { /** 到店自提 */ Pickup = 2, } + +/** 地理定位状态 */ +export enum GeoLocationStatus { + /** 定位失败 */ + Failed = 2, + /** 待定位 */ + Pending = 0, + /** 定位成功 */ + Success = 1, +} diff --git a/apps/web-antd/src/mock/product.ts b/apps/web-antd/src/mock/product.ts new file mode 100644 index 0000000..5b1c9d0 --- /dev/null +++ b/apps/web-antd/src/mock/product.ts @@ -0,0 +1,879 @@ +import Mock from 'mockjs'; + +/** 文件职责:商品管理页面 Mock 接口。 */ +interface MockRequestOptions { + body: null | string; + type: string; + url: string; +} + +type ProductStatus = 'off_shelf' | 'on_sale' | 'sold_out'; +type ProductKind = 'combo' | 'single'; +type ProductSoldoutMode = 'permanent' | 'timed' | 'today'; +type ShelfMode = 'draft' | 'now' | 'scheduled'; +type BatchActionType = + | 'batch_delete' + | 'batch_off' + | 'batch_on' + | 'batch_soldout'; + +interface CategorySeed { + id: string; + name: string; + sort: number; +} + +interface ProductSeed { + categoryId: string; + defaultKind?: ProductKind; + name: string; + price: number; + subtitle: string; + tags?: string[]; +} + +interface ProductRecord { + categoryId: string; + description: string; + id: string; + imageUrl: string; + kind: ProductKind; + name: string; + notifyManager: boolean; + originalPrice: null | number; + price: number; + recoverAt: null | string; + remainStock: number; + salesMonthly: number; + soldoutMode: null | ProductSoldoutMode; + soldoutReason: string; + spuCode: string; + status: ProductStatus; + stock: number; + storeId: string; + subtitle: string; + syncToPlatform: boolean; + tags: string[]; + timedOnShelfAt: null | string; + updatedAt: string; +} + +interface ProductStoreState { + nextSeq: number; + products: ProductRecord[]; +} + +const CATEGORY_SEEDS: CategorySeed[] = [ + { id: 'cat-hot', name: '热销推荐', sort: 1 }, + { id: 'cat-main', name: '主食', sort: 2 }, + { id: 'cat-snack', name: '小吃凉菜', sort: 3 }, + { id: 'cat-soup', name: '汤羹粥品', sort: 4 }, + { id: 'cat-drink', name: '饮品', sort: 5 }, + { id: 'cat-alcohol', name: '酒水', sort: 6 }, + { id: 'cat-combo', name: '套餐', sort: 7 }, +]; + +const PRODUCT_SEEDS: ProductSeed[] = [ + { + categoryId: 'cat-main', + name: '宫保鸡丁', + price: 32, + subtitle: '经典川菜,香辣可口', + tags: ['招牌'], + }, + { + categoryId: 'cat-main', + name: '鱼香肉丝', + price: 28, + subtitle: '酸甜开胃,佐饭佳选', + tags: ['必点'], + }, + { + categoryId: 'cat-main', + name: '麻婆豆腐', + price: 22, + subtitle: '麻辣鲜香,口感细腻', + tags: ['辣'], + }, + { + categoryId: 'cat-main', + name: '蛋炒饭', + price: 15, + subtitle: '粒粒分明,锅气十足', + }, + { + categoryId: 'cat-main', + name: '红烧排骨', + price: 42, + subtitle: '酱香浓郁,肉质软烂', + tags: ['招牌'], + }, + { + categoryId: 'cat-main', + name: '土豆烧牛腩', + price: 38, + subtitle: '软糯入味,牛腩足量', + }, + { + categoryId: 'cat-main', + name: '黑椒牛柳意面', + price: 36, + subtitle: '西式风味,黑椒浓香', + tags: ['新品'], + }, + { + categoryId: 'cat-main', + name: '照烧鸡腿饭', + price: 27, + subtitle: '日式酱香,鸡腿多汁', + }, + { + categoryId: 'cat-snack', + name: '蒜香鸡翅', + price: 24, + subtitle: '外酥里嫩,蒜香扑鼻', + tags: ['热卖'], + }, + { + categoryId: 'cat-snack', + name: '香辣薯条', + price: 12, + subtitle: '酥脆爽口,轻微辣感', + }, + { + categoryId: 'cat-snack', + name: '凉拌黄瓜', + price: 10, + subtitle: '爽脆开胃,解腻优选', + }, + { + categoryId: 'cat-snack', + name: '口水鸡', + price: 26, + subtitle: '麻香鲜辣,肉质嫩滑', + tags: ['辣'], + }, + { + categoryId: 'cat-snack', + name: '椒盐玉米', + price: 14, + subtitle: '外脆内甜,老少皆宜', + }, + { + categoryId: 'cat-soup', + name: '酸辣汤', + price: 18, + subtitle: '开胃暖身,酸辣平衡', + }, + { + categoryId: 'cat-soup', + name: '番茄蛋花汤', + price: 16, + subtitle: '清爽酸甜,营养丰富', + }, + { + categoryId: 'cat-soup', + name: '皮蛋瘦肉粥', + price: 19, + subtitle: '米香浓郁,口感顺滑', + }, + { + categoryId: 'cat-soup', + name: '海带排骨汤', + price: 21, + subtitle: '汤鲜味美,口感醇厚', + }, + { + categoryId: 'cat-drink', + name: '柠檬气泡水', + price: 9, + subtitle: '清爽解腻,微甜口感', + tags: ['新品'], + }, + { + categoryId: 'cat-drink', + name: '冰粉', + price: 8, + subtitle: '夏日必备,冰凉清甜', + tags: ['限时'], + }, + { + categoryId: 'cat-drink', + name: '酸梅汤', + price: 11, + subtitle: '古法熬制,酸甜适中', + }, + { + categoryId: 'cat-drink', + name: '杨枝甘露', + price: 16, + subtitle: '果香浓郁,奶香顺滑', + tags: ['推荐'], + }, + { + categoryId: 'cat-drink', + name: '鲜榨橙汁', + price: 13, + subtitle: '现榨现卖,清新自然', + }, + { + categoryId: 'cat-alcohol', + name: '青岛啤酒 500ml', + price: 12, + subtitle: '经典口感,冰镇更佳', + }, + { + categoryId: 'cat-alcohol', + name: '哈尔滨啤酒 500ml', + price: 11, + subtitle: '麦香清爽,口感顺滑', + }, + { + categoryId: 'cat-alcohol', + name: '乌苏啤酒 620ml', + price: 15, + subtitle: '劲爽畅饮,聚餐优选', + tags: ['热卖'], + }, + { + categoryId: 'cat-combo', + defaultKind: 'combo', + name: '双人午市套餐', + price: 69, + subtitle: '两荤一素+汤+饮料', + tags: ['套餐'], + }, + { + categoryId: 'cat-combo', + defaultKind: 'combo', + name: '单人畅享套餐', + price: 39, + subtitle: '主食+小吃+饮品', + tags: ['套餐'], + }, + { + categoryId: 'cat-combo', + defaultKind: 'combo', + name: '家庭四人餐', + price: 168, + subtitle: '经典大份组合,聚会优选', + tags: ['套餐', '招牌'], + }, + { + categoryId: 'cat-hot', + name: '招牌牛肉饭', + price: 34, + subtitle: '高人气单品,复购率高', + tags: ['热销'], + }, + { + categoryId: 'cat-hot', + name: '经典鸡排饭', + price: 26, + subtitle: '酥脆鸡排,份量十足', + tags: ['热销'], + }, + { + categoryId: 'cat-hot', + name: '藤椒鸡腿饭', + price: 29, + subtitle: '麻香清爽,口味独特', + tags: ['推荐'], + }, + { + categoryId: 'cat-hot', + name: '番茄牛腩饭', + price: 33, + subtitle: '酸甜开胃,牛腩软烂', + }, + { + categoryId: 'cat-hot', + name: '椒麻鸡拌面', + price: 24, + subtitle: '清爽拌面,椒麻上头', + tags: ['新品'], + }, +]; + +const productStoreMap = new Map(); + +/** 解析 URL 查询参数。 */ +function parseUrlParams(url: string) { + const parsed = new URL(url, 'http://localhost'); + const params: Record = {}; + parsed.searchParams.forEach((value, key) => { + params[key] = value; + }); + return params; +} + +/** 解析请求体 JSON。 */ +function parseBody(options: MockRequestOptions) { + if (!options.body) return {}; + try { + return JSON.parse(options.body) as Record; + } catch (error) { + console.error('[mock-product] parseBody error:', error); + return {}; + } +} + +/** 统一 yyyy-MM-dd HH:mm:ss。 */ +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 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 normalizeText(value: unknown, fallback = '') { + const text = String(value ?? '').trim(); + return text || fallback; +} + +/** 归一化状态。 */ +function normalizeStatus( + value: unknown, + fallback: ProductStatus, +): ProductStatus { + const next = String(value || ''); + if (next === 'on_sale' || next === 'off_shelf' || next === 'sold_out') { + return next; + } + return fallback; +} + +/** 归一化商品类型。 */ +function normalizeKind(value: unknown, fallback: ProductKind): ProductKind { + const next = String(value || ''); + return next === 'combo' || next === 'single' ? next : fallback; +} + +/** 归一化沽清模式。 */ +function normalizeSoldoutMode( + value: unknown, + fallback: ProductSoldoutMode, +): ProductSoldoutMode { + const next = String(value || ''); + if (next === 'today' || next === 'timed' || next === 'permanent') return next; + return fallback; +} + +/** 标准化标签列表。 */ +function normalizeTags(value: unknown) { + if (!Array.isArray(value)) return []; + return [ + ...new Set( + value + .map((item) => String(item || '').trim()) + .filter(Boolean) + .slice(0, 8), + ), + ]; +} + +/** 查找分类名。 */ +function getCategoryName(categoryId: string) { + return ( + CATEGORY_SEEDS.find((item) => item.id === categoryId)?.name ?? + CATEGORY_SEEDS[0]?.name ?? + '未分类' + ); +} + +/** 生成默认商品池。 */ +function createDefaultProducts(storeId: string): ProductRecord[] { + const now = new Date(); + const list: ProductRecord[] = []; + let seq = 1; + for (const seed of PRODUCT_SEEDS) { + const statusSeed = seq % 9; + let status: ProductStatus = 'on_sale'; + if (statusSeed === 0) { + status = 'sold_out'; + } else if (statusSeed === 4 || statusSeed === 7) { + status = 'off_shelf'; + } + + let stock = 20 + (seq % 120); + if (status === 'sold_out') { + stock = 0; + } else if (status === 'off_shelf') { + stock = 12 + (seq % 20); + } + const price = Number(seed.price.toFixed(2)); + const originalPrice = + seq % 3 === 0 ? Number((price + (3 + (seq % 8))).toFixed(2)) : null; + const createdAt = new Date(now); + createdAt.setDate(now.getDate() - (seq % 30)); + list.push({ + id: `prd-${storeId}-${String(seq).padStart(4, '0')}`, + storeId, + spuCode: `SPU2024${String(1000 + seq).padStart(5, '0')}`, + name: seed.name, + subtitle: seed.subtitle, + description: `${seed.name},${seed.subtitle}。`, + categoryId: seed.categoryId, + kind: seed.defaultKind ?? 'single', + price, + originalPrice, + stock, + salesMonthly: 15 + ((seq * 17) % 260), + tags: seed.tags ? [...seed.tags] : [], + status, + soldoutMode: status === 'sold_out' ? 'today' : null, + remainStock: stock, + recoverAt: null, + soldoutReason: status === 'sold_out' ? '食材用完' : '', + syncToPlatform: true, + notifyManager: false, + timedOnShelfAt: null, + imageUrl: '', + updatedAt: toDateTimeText(createdAt), + }); + seq += 1; + } + return list; +} + +/** 创建默认门店状态。 */ +function createDefaultState(storeId: string): ProductStoreState { + const products = createDefaultProducts(storeId); + return { + products, + nextSeq: products.length + 1, + }; +} + +/** 确保门店状态存在。 */ +function ensureStoreState(storeId = '') { + const key = storeId || 'default'; + let state = productStoreMap.get(key); + if (!state) { + state = createDefaultState(key); + productStoreMap.set(key, state); + } + return state; +} + +/** 构建分类结果(全量口径,不受筛选影响)。 */ +function buildCategoryList(products: ProductRecord[]) { + const countMap = new Map(); + for (const item of products) { + countMap.set(item.categoryId, (countMap.get(item.categoryId) ?? 0) + 1); + } + return CATEGORY_SEEDS.toSorted((a, b) => a.sort - b.sort).map((category) => ({ + id: category.id, + name: category.name, + sort: category.sort, + productCount: countMap.get(category.id) ?? 0, + })); +} + +/** 列表项映射。 */ +function toListItem(product: ProductRecord) { + return { + id: product.id, + spuCode: product.spuCode, + name: product.name, + subtitle: product.subtitle, + categoryId: product.categoryId, + categoryName: getCategoryName(product.categoryId), + kind: product.kind, + price: product.price, + originalPrice: product.originalPrice, + stock: product.stock, + salesMonthly: product.salesMonthly, + tags: [...product.tags], + status: product.status, + soldoutMode: product.soldoutMode, + imageUrl: product.imageUrl, + }; +} + +/** 详情映射。 */ +function toDetailItem(product: ProductRecord) { + return { + ...toListItem(product), + description: product.description, + remainStock: product.remainStock, + soldoutReason: product.soldoutReason, + recoverAt: product.recoverAt, + syncToPlatform: product.syncToPlatform, + notifyManager: product.notifyManager, + }; +} + +/** 创建商品 ID。 */ +function createProductId(storeId: string, seq: number) { + return `prd-${storeId}-${String(seq).padStart(4, '0')}`; +} + +/** 创建 SPU 编码。 */ +function createSpuCode(seq: number) { + return `SPU2026${String(10_000 + seq).slice(-5)}`; +} + +/** 解析上架方式。 */ +function resolveStatusByShelfMode( + mode: ShelfMode, + fallback: ProductStatus, +): ProductStatus { + if (mode === 'now') return 'on_sale'; + if (mode === 'draft' || mode === 'scheduled') return 'off_shelf'; + return fallback; +} + +/** 获取商品分类。 */ +Mock.mock( + /\/product\/category\/list(?:\?|$)/, + 'get', + (options: MockRequestOptions) => { + const params = parseUrlParams(options.url); + const storeId = String(params.storeId || ''); + const state = ensureStoreState(storeId); + return { + code: 200, + data: buildCategoryList(state.products), + }; + }, +); + +/** 获取商品列表。 */ +Mock.mock(/\/product\/list(?:\?|$)/, 'get', (options: MockRequestOptions) => { + const params = parseUrlParams(options.url); + const storeId = String(params.storeId || ''); + const state = ensureStoreState(storeId); + + const categoryId = String(params.categoryId || '').trim(); + const keyword = String(params.keyword || '') + .trim() + .toLowerCase(); + const status = normalizeStatus(params.status, 'on_sale'); + const kind = normalizeKind(params.kind, 'single'); + const hasStatus = Boolean(params.status); + const hasKind = Boolean(params.kind); + + const page = Math.max(1, normalizeInt(params.page, 1, 1)); + const pageSize = Math.max( + 1, + Math.min(200, normalizeInt(params.pageSize, 12, 1)), + ); + + const filtered = state.products.filter((item) => { + if (categoryId && item.categoryId !== categoryId) return false; + if (keyword) { + const hit = + item.name.toLowerCase().includes(keyword) || + item.spuCode.toLowerCase().includes(keyword); + if (!hit) return false; + } + if (hasStatus && item.status !== status) return false; + if (hasKind && item.kind !== kind) return false; + return true; + }); + + const ordered = filtered.toSorted((a, b) => + b.updatedAt.localeCompare(a.updatedAt), + ); + const start = (page - 1) * pageSize; + const items = ordered.slice(start, start + pageSize); + return { + code: 200, + data: { + items: items.map((item) => toListItem(item)), + total: ordered.length, + page, + pageSize, + }, + }; +}); + +/** 获取商品详情。 */ +Mock.mock(/\/product\/detail(?:\?|$)/, 'get', (options: MockRequestOptions) => { + const params = parseUrlParams(options.url); + const storeId = String(params.storeId || ''); + const productId = String(params.productId || ''); + const state = ensureStoreState(storeId); + const product = state.products.find((item) => item.id === productId); + if (!product) { + return { + code: 404, + data: null, + message: '商品不存在', + }; + } + return { + code: 200, + data: toDetailItem(product), + }; +}); + +/** 保存商品(新增/编辑)。 */ +Mock.mock(/\/product\/save/, '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 id = normalizeText(body.id); + const shelfMode = normalizeText(body.shelfMode, 'now') as ShelfMode; + const existingIndex = state.products.findIndex((item) => item.id === id); + const fallbackStatus = + existingIndex === -1 ? 'on_sale' : state.products[existingIndex]?.status; + const statusFromBody = normalizeStatus( + body.status, + fallbackStatus || 'on_sale', + ); + const nextStatus = resolveStatusByShelfMode(shelfMode, statusFromBody); + const categoryId = normalizeText( + body.categoryId, + CATEGORY_SEEDS[0]?.id ?? 'cat-main', + ); + + const existingProduct = + existingIndex === -1 ? null : state.products[existingIndex]; + const baseProduct: ProductRecord = existingProduct + ? { ...existingProduct } + : { + id: createProductId(storeId, state.nextSeq), + storeId, + spuCode: createSpuCode(state.nextSeq), + name: '', + subtitle: '', + description: '', + categoryId, + kind: 'single', + price: 0, + originalPrice: null, + stock: 0, + salesMonthly: 0, + tags: [], + status: 'off_shelf', + soldoutMode: null, + remainStock: 0, + recoverAt: null, + soldoutReason: '', + syncToPlatform: true, + notifyManager: false, + timedOnShelfAt: null, + imageUrl: '', + updatedAt: toDateTimeText(new Date()), + }; + + baseProduct.name = normalizeText(body.name, baseProduct.name); + baseProduct.subtitle = normalizeText(body.subtitle, baseProduct.subtitle); + baseProduct.description = normalizeText( + body.description, + baseProduct.description || `${baseProduct.name},${baseProduct.subtitle}`, + ); + baseProduct.categoryId = CATEGORY_SEEDS.some((item) => item.id === categoryId) + ? categoryId + : baseProduct.categoryId; + baseProduct.kind = normalizeKind(body.kind, baseProduct.kind); + baseProduct.price = Number( + normalizeNumber(body.price, baseProduct.price, 0).toFixed(2), + ); + const originalPrice = normalizeNumber(body.originalPrice, -1, 0); + baseProduct.originalPrice = + originalPrice > 0 ? Number(originalPrice.toFixed(2)) : null; + baseProduct.stock = normalizeInt(body.stock, baseProduct.stock, 0); + baseProduct.tags = normalizeTags(body.tags); + baseProduct.status = nextStatus; + baseProduct.soldoutMode = nextStatus === 'sold_out' ? 'today' : null; + baseProduct.recoverAt = null; + baseProduct.soldoutReason = nextStatus === 'sold_out' ? '库存售罄' : ''; + baseProduct.remainStock = baseProduct.stock; + baseProduct.syncToPlatform = true; + baseProduct.notifyManager = false; + baseProduct.timedOnShelfAt = + shelfMode === 'scheduled' ? normalizeText(body.timedOnShelfAt) : null; + baseProduct.updatedAt = toDateTimeText(new Date()); + + if (!baseProduct.name) { + return { + code: 400, + data: null, + message: '商品名称不能为空', + }; + } + + if (existingIndex === -1) { + state.products.unshift(baseProduct); + state.nextSeq += 1; + } else { + state.products.splice(existingIndex, 1, baseProduct); + } + + return { + code: 200, + data: toDetailItem(baseProduct), + }; +}); + +/** 删除商品。 */ +Mock.mock(/\/product\/delete/, 'post', (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + const productId = normalizeText(body.productId); + if (!storeId || !productId) { + return { code: 400, data: null, message: '参数不完整' }; + } + const state = ensureStoreState(storeId); + state.products = state.products.filter((item) => item.id !== productId); + return { code: 200, data: null }; +}); + +/** 修改商品状态。 */ +Mock.mock( + /\/product\/status\/change/, + 'post', + (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + const productId = normalizeText(body.productId); + if (!storeId || !productId) { + return { code: 400, data: null, message: '参数不完整' }; + } + const state = ensureStoreState(storeId); + const product = state.products.find((item) => item.id === productId); + if (!product) return { code: 404, data: null, message: '商品不存在' }; + + const nextStatus = normalizeStatus(body.status, product.status); + product.status = nextStatus; + if (nextStatus === 'sold_out') { + product.soldoutMode = 'today'; + product.soldoutReason = product.soldoutReason || '库存售罄'; + product.remainStock = 0; + product.stock = 0; + } else { + product.soldoutMode = null; + product.recoverAt = null; + product.soldoutReason = ''; + } + product.updatedAt = toDateTimeText(new Date()); + return { code: 200, data: toListItem(product) }; + }, +); + +/** 商品沽清。 */ +Mock.mock(/\/product\/soldout/, 'post', (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + const productId = normalizeText(body.productId); + if (!storeId || !productId) { + return { code: 400, data: null, message: '参数不完整' }; + } + const state = ensureStoreState(storeId); + const product = state.products.find((item) => item.id === productId); + if (!product) return { code: 404, data: null, message: '商品不存在' }; + + const mode = normalizeSoldoutMode(body.mode, 'today'); + const remainStock = normalizeInt(body.remainStock, 0, 0); + product.status = 'sold_out'; + product.soldoutMode = mode; + product.remainStock = remainStock; + product.stock = remainStock; + product.soldoutReason = normalizeText(body.reason); + product.recoverAt = mode === 'timed' ? normalizeText(body.recoverAt) : null; + product.syncToPlatform = Boolean(body.syncToPlatform); + product.notifyManager = Boolean(body.notifyManager); + product.updatedAt = toDateTimeText(new Date()); + return { code: 200, data: toDetailItem(product) }; +}); + +/** 批量商品动作。 */ +Mock.mock(/\/product\/batch/, 'post', (options: MockRequestOptions) => { + const body = parseBody(options); + const storeId = normalizeText(body.storeId); + const action = normalizeText(body.action) as BatchActionType; + const productIds = Array.isArray(body.productIds) + ? body.productIds.map((item) => String(item || '').trim()).filter(Boolean) + : []; + + if (!storeId || productIds.length === 0) { + return { + code: 400, + data: null, + message: '批量操作参数不完整', + }; + } + + const state = ensureStoreState(storeId); + const idSet = new Set(productIds); + const targets = state.products.filter((item) => idSet.has(item.id)); + let successCount = 0; + + if (action === 'batch_delete') { + const before = state.products.length; + state.products = state.products.filter((item) => !idSet.has(item.id)); + successCount = before - state.products.length; + } else { + for (const item of targets) { + switch (action) { + case 'batch_off': { + item.status = 'off_shelf'; + item.soldoutMode = null; + item.recoverAt = null; + item.soldoutReason = ''; + + break; + } + case 'batch_on': { + item.status = 'on_sale'; + item.soldoutMode = null; + item.recoverAt = null; + item.soldoutReason = ''; + + break; + } + case 'batch_soldout': { + item.status = 'sold_out'; + item.soldoutMode = normalizeSoldoutMode(body.mode, 'today'); + item.remainStock = normalizeInt(body.remainStock, 0, 0); + item.stock = item.remainStock; + item.soldoutReason = normalizeText(body.reason); + item.recoverAt = + item.soldoutMode === 'timed' ? normalizeText(body.recoverAt) : null; + item.syncToPlatform = Boolean(body.syncToPlatform ?? true); + item.notifyManager = Boolean(body.notifyManager ?? false); + + break; + } + // No default + } + item.updatedAt = toDateTimeText(new Date()); + successCount += 1; + } + } + + const totalCount = productIds.length; + const failedCount = Math.max(totalCount - successCount, 0); + return { + code: 200, + data: { + action, + totalCount, + successCount, + failedCount, + }, + }; +}); diff --git a/apps/web-antd/src/router/routes/modules/product.ts b/apps/web-antd/src/router/routes/modules/product.ts new file mode 100644 index 0000000..1a5deb2 --- /dev/null +++ b/apps/web-antd/src/router/routes/modules/product.ts @@ -0,0 +1,36 @@ +import type { RouteRecordRaw } from 'vue-router'; + +/** 文件职责:商品管理模块静态路由。 */ +const routes: RouteRecordRaw[] = [ + { + meta: { + icon: 'lucide:package', + order: 20, + title: '商品管理', + }, + name: 'Product', + path: '/product', + children: [ + { + name: 'ProductList', + path: '/product/list', + component: () => import('#/views/product/list/index.vue'), + meta: { + icon: 'lucide:list', + title: '商品列表', + }, + }, + { + name: 'ProductDetail', + path: '/product/detail', + component: () => import('#/views/product/detail/index.vue'), + meta: { + hideInMenu: true, + title: '商品详情', + }, + }, + ], + }, +]; + +export default routes; diff --git a/apps/web-antd/src/views/product/detail/index.vue b/apps/web-antd/src/views/product/detail/index.vue new file mode 100644 index 0000000..2e96db5 --- /dev/null +++ b/apps/web-antd/src/views/product/detail/index.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/apps/web-antd/src/views/product/detail/styles/index.less b/apps/web-antd/src/views/product/detail/styles/index.less new file mode 100644 index 0000000..143d0d8 --- /dev/null +++ b/apps/web-antd/src/views/product/detail/styles/index.less @@ -0,0 +1,74 @@ +/* 文件职责:商品详情页面样式。 */ +.page-product-detail { + .product-detail-header { + display: flex; + gap: 14px; + align-items: center; + padding-bottom: 16px; + margin-bottom: 18px; + border-bottom: 1px solid #f0f0f0; + } + + .product-detail-cover { + display: inline-flex; + align-items: center; + justify-content: center; + width: 64px; + height: 64px; + font-size: 24px; + font-weight: 700; + color: #94a3b8; + background: linear-gradient(135deg, #f8fafc 0%, #eef2f7 100%); + border-radius: 12px; + } + + .product-detail-title-wrap { + flex: 1; + min-width: 0; + } + + .product-detail-title-wrap .title { + overflow: hidden; + text-overflow: ellipsis; + font-size: 20px; + font-weight: 700; + color: #111827; + white-space: nowrap; + } + + .product-detail-title-wrap .sub { + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + font-size: 13px; + color: #6b7280; + white-space: nowrap; + } + + .product-detail-title-wrap .spu { + margin-top: 6px; + font-family: + ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, + 'Liberation Mono', monospace; + font-size: 12px; + color: #9ca3af; + } + + .product-detail-status { + font-size: 13px; + font-weight: 600; + } + + .product-detail-descriptions .ant-descriptions-item-label { + width: 120px; + font-weight: 600; + color: #374151; + } + + .product-detail-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; + } +} diff --git a/apps/web-antd/src/views/product/list/components/ProductActionBar.vue b/apps/web-antd/src/views/product/list/components/ProductActionBar.vue new file mode 100644 index 0000000..3339f03 --- /dev/null +++ b/apps/web-antd/src/views/product/list/components/ProductActionBar.vue @@ -0,0 +1,61 @@ + + + diff --git a/apps/web-antd/src/views/product/list/components/ProductCategorySidebar.vue b/apps/web-antd/src/views/product/list/components/ProductCategorySidebar.vue new file mode 100644 index 0000000..964fa1a --- /dev/null +++ b/apps/web-antd/src/views/product/list/components/ProductCategorySidebar.vue @@ -0,0 +1,48 @@ + + + diff --git a/apps/web-antd/src/views/product/list/components/ProductEditorDrawer.vue b/apps/web-antd/src/views/product/list/components/ProductEditorDrawer.vue new file mode 100644 index 0000000..370fd08 --- /dev/null +++ b/apps/web-antd/src/views/product/list/components/ProductEditorDrawer.vue @@ -0,0 +1,274 @@ + + + diff --git a/apps/web-antd/src/views/product/list/components/ProductFilterToolbar.vue b/apps/web-antd/src/views/product/list/components/ProductFilterToolbar.vue new file mode 100644 index 0000000..b9353e2 --- /dev/null +++ b/apps/web-antd/src/views/product/list/components/ProductFilterToolbar.vue @@ -0,0 +1,148 @@ + + + diff --git a/apps/web-antd/src/views/product/list/components/ProductListSection.vue b/apps/web-antd/src/views/product/list/components/ProductListSection.vue new file mode 100644 index 0000000..82d1d61 --- /dev/null +++ b/apps/web-antd/src/views/product/list/components/ProductListSection.vue @@ -0,0 +1,380 @@ + + + diff --git a/apps/web-antd/src/views/product/list/components/ProductQuickEditDrawer.vue b/apps/web-antd/src/views/product/list/components/ProductQuickEditDrawer.vue new file mode 100644 index 0000000..c30133a --- /dev/null +++ b/apps/web-antd/src/views/product/list/components/ProductQuickEditDrawer.vue @@ -0,0 +1,157 @@ + + + diff --git a/apps/web-antd/src/views/product/list/components/ProductSoldoutDrawer.vue b/apps/web-antd/src/views/product/list/components/ProductSoldoutDrawer.vue new file mode 100644 index 0000000..bb782e9 --- /dev/null +++ b/apps/web-antd/src/views/product/list/components/ProductSoldoutDrawer.vue @@ -0,0 +1,184 @@ + + + diff --git a/apps/web-antd/src/views/product/list/composables/product-list-page/batch-actions.ts b/apps/web-antd/src/views/product/list/composables/product-list-page/batch-actions.ts new file mode 100644 index 0000000..551f228 --- /dev/null +++ b/apps/web-antd/src/views/product/list/composables/product-list-page/batch-actions.ts @@ -0,0 +1,73 @@ +/** + * 文件职责:商品批量动作。 + * 1. 处理批量上架、下架、沽清、删除。 + * 2. 对批量动作做参数校验、确认与结果提示。 + */ +import type { Ref } from 'vue'; + +import type { ProductBatchAction } from '#/views/product/list/types'; + +import { message, Modal } from 'ant-design-vue'; + +import { batchProductActionApi } from '#/api/product'; + +interface CreateBatchActionsOptions { + clearSelection: () => void; + reloadCurrentStoreData: () => Promise; + selectedProductIds: Ref; + selectedStoreId: Ref; +} + +export function createBatchActions(options: CreateBatchActionsOptions) { + /** 执行批量操作。 */ + async function handleBatchAction(action: ProductBatchAction) { + if (!options.selectedStoreId.value) return; + if (options.selectedProductIds.value.length === 0) { + message.warning('请先勾选商品'); + return; + } + + if (action === 'batch_delete') { + Modal.confirm({ + title: '确认批量删除吗?', + content: `将删除 ${options.selectedProductIds.value.length} 个商品`, + okText: '确认删除', + cancelText: '取消', + async onOk() { + await submitBatchAction('batch_delete'); + }, + }); + return; + } + + await submitBatchAction(action); + } + + /** 提交批量动作并刷新页面。 */ + async function submitBatchAction(action: ProductBatchAction) { + if (!options.selectedStoreId.value) return; + const payload = { + action, + storeId: options.selectedStoreId.value, + productIds: [...options.selectedProductIds.value], + remainStock: 0, + reason: '批量沽清', + recoverAt: '', + syncToPlatform: true, + notifyManager: false, + }; + + try { + const result = await batchProductActionApi(payload); + message.success(`已处理 ${result.successCount}/${result.totalCount} 项`); + options.clearSelection(); + await options.reloadCurrentStoreData(); + } catch (error) { + console.error(error); + } + } + + return { + handleBatchAction, + }; +} diff --git a/apps/web-antd/src/views/product/list/composables/product-list-page/constants.ts b/apps/web-antd/src/views/product/list/composables/product-list-page/constants.ts new file mode 100644 index 0000000..358c9a4 --- /dev/null +++ b/apps/web-antd/src/views/product/list/composables/product-list-page/constants.ts @@ -0,0 +1,125 @@ +import type { ProductKind, ProductStatus } from '#/api/product'; +/** + * 文件职责:商品列表页面常量配置。 + * 1. 提供筛选选项、默认状态、批量动作定义。 + * 2. 统一维护编辑/快速编辑/沽清表单默认值。 + */ +import type { + ProductBatchAction, + ProductEditorFormState, + ProductFilterState, + ProductQuickEditFormState, + ProductSoldoutFormState, + ProductStatusMeta, + ProductViewMode, +} from '#/views/product/list/types'; + +/** 分页尺寸选项。 */ +export const PAGE_SIZE_OPTIONS = ['12', '24', '48']; + +/** 视图模式切换选项。 */ +export const PRODUCT_VIEW_OPTIONS: Array<{ + label: string; + value: ProductViewMode; +}> = [ + { label: '卡片', value: 'card' }, + { label: '列表', value: 'list' }, +]; + +/** 商品状态筛选。 */ +export const PRODUCT_STATUS_OPTIONS: Array<{ + label: string; + value: '' | ProductStatus; +}> = [ + { label: '全部状态', value: '' }, + { label: '在售', value: 'on_sale' }, + { label: '下架', value: 'off_shelf' }, + { label: '售罄', value: 'sold_out' }, +]; + +/** 商品类型筛选。 */ +export const PRODUCT_KIND_OPTIONS: Array<{ + label: string; + value: '' | ProductKind; +}> = [ + { label: '全部类型', value: '' }, + { label: '单品', value: 'single' }, + { label: '套餐', value: 'combo' }, +]; + +/** 商品状态展示映射。 */ +export const PRODUCT_STATUS_META_MAP: Record = + { + on_sale: { + label: '在售', + color: '#52c41a', + badgeClass: 'status-on-sale', + }, + off_shelf: { + label: '下架', + color: '#9ca3af', + badgeClass: 'status-off-shelf', + }, + sold_out: { + label: '售罄', + color: '#ef4444', + badgeClass: 'status-sold-out', + }, + }; + +/** 默认筛选状态。 */ +export const DEFAULT_FILTER_STATE: ProductFilterState = { + categoryId: '', + keyword: '', + status: '', + kind: '', + page: 1, + pageSize: 12, +}; + +/** 默认编辑表单。 */ +export const DEFAULT_EDITOR_FORM: ProductEditorFormState = { + id: '', + name: '', + subtitle: '', + description: '', + categoryId: '', + kind: 'single', + price: 0, + originalPrice: null, + stock: 0, + tagsText: '', + status: 'off_shelf', + shelfMode: 'draft', + timedOnShelfAt: '', +}; + +/** 默认快速编辑表单。 */ +export const DEFAULT_QUICK_EDIT_FORM: ProductQuickEditFormState = { + id: '', + price: 0, + originalPrice: null, + stock: 0, + isOnSale: false, +}; + +/** 默认沽清表单。 */ +export const DEFAULT_SOLDOUT_FORM: ProductSoldoutFormState = { + mode: 'today', + remainStock: 0, + reason: '', + recoverAt: '', + syncToPlatform: true, + notifyManager: false, +}; + +/** 批量操作选项。 */ +export const PRODUCT_BATCH_ACTION_OPTIONS: Array<{ + label: string; + value: ProductBatchAction; +}> = [ + { label: '批量上架', value: 'batch_on' }, + { label: '批量下架', value: 'batch_off' }, + { label: '批量沽清', value: 'batch_soldout' }, + { label: '批量删除', value: 'batch_delete' }, +]; diff --git a/apps/web-antd/src/views/product/list/composables/product-list-page/data-actions.ts b/apps/web-antd/src/views/product/list/composables/product-list-page/data-actions.ts new file mode 100644 index 0000000..3744cae --- /dev/null +++ b/apps/web-antd/src/views/product/list/composables/product-list-page/data-actions.ts @@ -0,0 +1,125 @@ +/** + * 文件职责:商品列表数据加载动作。 + * 1. 加载门店、分类、列表数据。 + * 2. 管理列表与分类的 loading 状态。 + */ +import type { Ref } from 'vue'; + +import type { ProductCategoryDto, ProductListItemDto } from '#/api/product'; +import type { StoreListItemDto } from '#/api/store'; +import type { ProductFilterState } from '#/views/product/list/types'; + +import { getProductCategoryListApi, getProductListApi } from '#/api/product'; +import { getStoreListApi } from '#/api/store'; + +interface CreateDataActionsOptions { + categories: Ref; + filters: ProductFilterState; + isCategoryLoading: Ref; + isListLoading: Ref; + isStoreLoading: Ref; + products: Ref; + selectedStoreId: Ref; + stores: Ref; + total: Ref; +} + +export function createDataActions(options: CreateDataActionsOptions) { + /** 加载门店列表并设置默认门店。 */ + async function loadStores() { + options.isStoreLoading.value = true; + try { + const result = await getStoreListApi({ + keyword: undefined, + businessStatus: undefined, + auditStatus: undefined, + serviceType: undefined, + page: 1, + pageSize: 200, + }); + options.stores.value = result.items ?? []; + + if (options.stores.value.length === 0) { + options.selectedStoreId.value = ''; + options.categories.value = []; + options.products.value = []; + options.total.value = 0; + return; + } + + const hasSelected = options.stores.value.some( + (item) => item.id === options.selectedStoreId.value, + ); + if (!hasSelected) { + options.selectedStoreId.value = options.stores.value[0]?.id ?? ''; + } + } catch (error) { + console.error(error); + } finally { + options.isStoreLoading.value = false; + } + } + + /** 加载商品分类(全量口径,不受筛选影响)。 */ + async function loadCategories() { + if (!options.selectedStoreId.value) { + options.categories.value = []; + return; + } + + options.isCategoryLoading.value = true; + try { + options.categories.value = await getProductCategoryListApi( + options.selectedStoreId.value, + ); + } catch (error) { + console.error(error); + options.categories.value = []; + } finally { + options.isCategoryLoading.value = false; + } + } + + /** 加载商品列表。 */ + async function loadProducts() { + if (!options.selectedStoreId.value) { + options.products.value = []; + options.total.value = 0; + return; + } + + options.isListLoading.value = true; + try { + const result = await getProductListApi({ + storeId: options.selectedStoreId.value, + categoryId: options.filters.categoryId || undefined, + keyword: options.filters.keyword.trim() || undefined, + status: options.filters.status || undefined, + kind: options.filters.kind || undefined, + page: options.filters.page, + pageSize: options.filters.pageSize, + }); + + options.products.value = result.items ?? []; + options.total.value = result.total ?? 0; + } catch (error) { + console.error(error); + options.products.value = []; + options.total.value = 0; + } finally { + options.isListLoading.value = false; + } + } + + /** 同步刷新当前门店分类与列表。 */ + async function reloadCurrentStoreData() { + await Promise.all([loadCategories(), loadProducts()]); + } + + return { + loadCategories, + loadProducts, + loadStores, + reloadCurrentStoreData, + }; +} diff --git a/apps/web-antd/src/views/product/list/composables/product-list-page/drawer-actions.ts b/apps/web-antd/src/views/product/list/composables/product-list-page/drawer-actions.ts new file mode 100644 index 0000000..2dcb244 --- /dev/null +++ b/apps/web-antd/src/views/product/list/composables/product-list-page/drawer-actions.ts @@ -0,0 +1,288 @@ +/** + * 文件职责:商品抽屉与单项动作。 + * 1. 维护添加/编辑、快速编辑、沽清抽屉打开与提交流程。 + * 2. 处理单商品删除与状态切换动作。 + */ +import type { Ref } from 'vue'; + +import type { ProductListItemDto, ProductStatus } from '#/api/product'; +import type { + ProductEditorDrawerMode, + ProductEditorFormState, + ProductQuickEditFormState, + ProductSoldoutFormState, +} from '#/views/product/list/types'; + +import { message } from 'ant-design-vue'; + +import { + changeProductStatusApi, + deleteProductApi, + getProductDetailApi, + saveProductApi, + soldoutProductApi, +} from '#/api/product'; + +import { + formatDateTime, + mapDetailToEditorForm, + mapListItemToEditorForm, + mapListItemToQuickEditForm, + mapListItemToSoldoutForm, + toSavePayload, +} from './helpers'; + +interface CreateDrawerActionsOptions { + clearSelection: () => void; + currentQuickEditProduct: Ref; + currentSoldoutProduct: Ref; + editorDrawerMode: Ref; + editorForm: ProductEditorFormState; + isEditorDrawerOpen: Ref; + isEditorSubmitting: Ref; + isQuickEditDrawerOpen: Ref; + isQuickEditSubmitting: Ref; + isSoldoutDrawerOpen: Ref; + isSoldoutSubmitting: Ref; + quickEditForm: ProductQuickEditFormState; + reloadCurrentStoreData: () => Promise; + selectedStoreId: Ref; + soldoutForm: ProductSoldoutFormState; +} + +export function createDrawerActions(options: CreateDrawerActionsOptions) { + /** 打开添加商品抽屉。 */ + function openCreateDrawer(defaultCategoryId: string) { + options.editorDrawerMode.value = 'create'; + options.editorForm.id = ''; + options.editorForm.name = ''; + options.editorForm.subtitle = ''; + options.editorForm.description = ''; + options.editorForm.categoryId = defaultCategoryId; + options.editorForm.kind = 'single'; + options.editorForm.price = 0; + options.editorForm.originalPrice = null; + options.editorForm.stock = 0; + options.editorForm.tagsText = ''; + options.editorForm.status = 'off_shelf'; + options.editorForm.shelfMode = 'draft'; + options.editorForm.timedOnShelfAt = ''; + options.isEditorDrawerOpen.value = true; + } + + /** 打开编辑商品抽屉并尽量补全详情。 */ + async function openEditDrawer(item: ProductListItemDto) { + options.editorDrawerMode.value = 'edit'; + Object.assign(options.editorForm, mapListItemToEditorForm(item)); + options.isEditorDrawerOpen.value = true; + + if (!options.selectedStoreId.value) return; + try { + const detail = await getProductDetailApi({ + storeId: options.selectedStoreId.value, + productId: item.id, + }); + Object.assign(options.editorForm, mapDetailToEditorForm(detail)); + } catch (error) { + console.error(error); + } + } + + /** 提交添加/编辑商品。 */ + async function submitEditor() { + if (!options.selectedStoreId.value) return; + + if (!options.editorForm.name.trim()) { + message.warning('请输入商品名称'); + return; + } + if (!options.editorForm.categoryId) { + message.warning('请选择商品分类'); + return; + } + if ( + options.editorForm.shelfMode === 'scheduled' && + !options.editorForm.timedOnShelfAt + ) { + message.warning('请选择定时上架时间'); + return; + } + + options.isEditorSubmitting.value = true; + try { + await saveProductApi( + toSavePayload(options.editorForm, options.selectedStoreId.value), + ); + message.success( + options.editorDrawerMode.value === 'edit' ? '商品已保存' : '商品已添加', + ); + options.isEditorDrawerOpen.value = false; + options.clearSelection(); + await options.reloadCurrentStoreData(); + } catch (error) { + console.error(error); + } finally { + options.isEditorSubmitting.value = false; + } + } + + /** 打开快速编辑抽屉。 */ + function openQuickEditDrawer(item: ProductListItemDto) { + options.currentQuickEditProduct.value = item; + Object.assign(options.quickEditForm, mapListItemToQuickEditForm(item)); + options.isQuickEditDrawerOpen.value = true; + } + + /** 提交快速编辑。 */ + async function submitQuickEdit() { + if (!options.selectedStoreId.value) return; + if (!options.currentQuickEditProduct.value) return; + + const current = options.currentQuickEditProduct.value; + options.isQuickEditSubmitting.value = true; + try { + await saveProductApi({ + id: current.id, + storeId: options.selectedStoreId.value, + categoryId: current.categoryId, + kind: current.kind, + name: current.name, + subtitle: current.subtitle, + description: current.subtitle, + price: Number(options.quickEditForm.price || 0), + originalPrice: + Number(options.quickEditForm.originalPrice || 0) > 0 + ? Number(options.quickEditForm.originalPrice) + : null, + stock: Math.max( + 0, + Math.floor(Number(options.quickEditForm.stock || 0)), + ), + tags: [...current.tags], + status: options.quickEditForm.isOnSale ? 'on_sale' : 'off_shelf', + shelfMode: options.quickEditForm.isOnSale ? 'now' : 'draft', + spuCode: current.spuCode, + }); + message.success('商品已更新'); + options.isQuickEditDrawerOpen.value = false; + options.clearSelection(); + await options.reloadCurrentStoreData(); + } catch (error) { + console.error(error); + } finally { + options.isQuickEditSubmitting.value = false; + } + } + + /** 打开沽清抽屉。 */ + function openSoldoutDrawer(item: ProductListItemDto) { + options.currentSoldoutProduct.value = item; + Object.assign(options.soldoutForm, mapListItemToSoldoutForm(item)); + options.isSoldoutDrawerOpen.value = true; + } + + /** 提交沽清设置。 */ + async function submitSoldout() { + if (!options.selectedStoreId.value) return; + if (!options.currentSoldoutProduct.value) return; + + if ( + options.soldoutForm.mode === 'timed' && + !options.soldoutForm.recoverAt + ) { + message.warning('请选择恢复时间'); + return; + } + + options.isSoldoutSubmitting.value = true; + try { + await soldoutProductApi({ + storeId: options.selectedStoreId.value, + productId: options.currentSoldoutProduct.value.id, + mode: options.soldoutForm.mode, + remainStock: Math.max(0, Math.floor(options.soldoutForm.remainStock)), + reason: options.soldoutForm.reason.trim(), + recoverAt: + options.soldoutForm.mode === 'timed' && options.soldoutForm.recoverAt + ? formatDateTime(options.soldoutForm.recoverAt) + : undefined, + syncToPlatform: options.soldoutForm.syncToPlatform, + notifyManager: options.soldoutForm.notifyManager, + }); + message.success('商品已沽清'); + options.isSoldoutDrawerOpen.value = false; + options.clearSelection(); + await options.reloadCurrentStoreData(); + } catch (error) { + console.error(error); + } finally { + options.isSoldoutSubmitting.value = false; + } + } + + /** 删除单个商品。 */ + async function deleteProduct(item: ProductListItemDto) { + if (!options.selectedStoreId.value) return; + try { + await deleteProductApi({ + storeId: options.selectedStoreId.value, + productId: item.id, + }); + message.success('商品已删除'); + options.clearSelection(); + await options.reloadCurrentStoreData(); + } catch (error) { + console.error(error); + } + } + + /** 切换单个商品状态。 */ + async function changeProductStatus( + item: ProductListItemDto, + status: ProductStatus, + ) { + if (!options.selectedStoreId.value) return; + try { + await changeProductStatusApi({ + storeId: options.selectedStoreId.value, + productId: item.id, + status, + }); + message.success(status === 'on_sale' ? '商品已上架' : '商品已下架'); + options.clearSelection(); + await options.reloadCurrentStoreData(); + } catch (error) { + console.error(error); + } + } + + /** 切换添加/编辑抽屉显隐。 */ + function setEditorDrawerOpen(value: boolean) { + options.isEditorDrawerOpen.value = value; + } + + /** 切换快速编辑抽屉显隐。 */ + function setQuickEditDrawerOpen(value: boolean) { + options.isQuickEditDrawerOpen.value = value; + } + + /** 切换沽清抽屉显隐。 */ + function setSoldoutDrawerOpen(value: boolean) { + options.isSoldoutDrawerOpen.value = value; + } + + return { + changeProductStatus, + deleteProduct, + openCreateDrawer, + openEditDrawer, + openQuickEditDrawer, + openSoldoutDrawer, + setEditorDrawerOpen, + setQuickEditDrawerOpen, + setSoldoutDrawerOpen, + submitEditor, + submitQuickEdit, + submitSoldout, + }; +} diff --git a/apps/web-antd/src/views/product/list/composables/product-list-page/helpers.ts b/apps/web-antd/src/views/product/list/composables/product-list-page/helpers.ts new file mode 100644 index 0000000..c0a27ac --- /dev/null +++ b/apps/web-antd/src/views/product/list/composables/product-list-page/helpers.ts @@ -0,0 +1,247 @@ +/** + * 文件职责:商品列表页面纯函数工具。 + * 1. 处理表单克隆、DTO 映射、字段归一化。 + * 2. 提供状态/库存展示与日期格式化工具。 + */ +import type { + ProductDetailDto, + ProductListItemDto, + ProductStatus, + SaveProductDto, +} from '#/api/product'; +import type { + ProductEditorFormState, + ProductFilterState, + ProductQuickEditFormState, + ProductSoldoutFormState, +} from '#/views/product/list/types'; + +import dayjs from 'dayjs'; + +import { + DEFAULT_EDITOR_FORM, + DEFAULT_FILTER_STATE, + DEFAULT_QUICK_EDIT_FORM, + DEFAULT_SOLDOUT_FORM, + PRODUCT_STATUS_META_MAP, +} from './constants'; + +/** 克隆筛选状态,避免引用污染。 */ +export function cloneFilterState( + source: ProductFilterState = DEFAULT_FILTER_STATE, +): ProductFilterState { + return { + categoryId: source.categoryId, + keyword: source.keyword, + status: source.status, + kind: source.kind, + page: source.page, + pageSize: source.pageSize, + }; +} + +/** 克隆编辑表单。 */ +export function cloneEditorForm( + source: ProductEditorFormState = DEFAULT_EDITOR_FORM, +): ProductEditorFormState { + return { + id: source.id, + name: source.name, + subtitle: source.subtitle, + description: source.description, + categoryId: source.categoryId, + kind: source.kind, + price: source.price, + originalPrice: source.originalPrice, + stock: source.stock, + tagsText: source.tagsText, + status: source.status, + shelfMode: source.shelfMode, + timedOnShelfAt: source.timedOnShelfAt, + }; +} + +/** 克隆快速编辑表单。 */ +export function cloneQuickEditForm( + source: ProductQuickEditFormState = DEFAULT_QUICK_EDIT_FORM, +): ProductQuickEditFormState { + return { + id: source.id, + price: source.price, + originalPrice: source.originalPrice, + stock: source.stock, + isOnSale: source.isOnSale, + }; +} + +/** 克隆沽清表单。 */ +export function cloneSoldoutForm( + source: ProductSoldoutFormState = DEFAULT_SOLDOUT_FORM, +): ProductSoldoutFormState { + return { + mode: source.mode, + remainStock: source.remainStock, + reason: source.reason, + recoverAt: source.recoverAt, + syncToPlatform: source.syncToPlatform, + notifyManager: source.notifyManager, + }; +} + +/** 货币格式化。 */ +export function formatCurrency(value: null | number) { + const amount = Number(value ?? 0); + if (!Number.isFinite(amount)) return '¥0.00'; + return `¥${amount.toFixed(2)}`; +} + +/** 状态元信息解析。 */ +export function resolveStatusMeta(status: ProductStatus) { + return PRODUCT_STATUS_META_MAP[status] ?? PRODUCT_STATUS_META_MAP.off_shelf; +} + +/** 按库存返回展示文本。 */ +export function formatStockText(stock: number) { + if (stock <= 0) return '0 售罄'; + if (stock <= 10) return `${stock} 紧张`; + return `${stock} 充足`; +} + +/** 按库存返回样式类。 */ +export function resolveStockClass(stock: number) { + if (stock <= 0) return 'stock-out'; + if (stock <= 10) return 'stock-low'; + return 'stock-ok'; +} + +/** 标签数组转输入框文本。 */ +export function tagsToText(tags: string[]) { + return tags.join(', '); +} + +/** 输入框文本转标签数组。 */ +export function textToTags(source: string) { + return [ + ...new Set( + source + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + .slice(0, 8), + ), + ]; +} + +/** 统一格式化为 yyyy-MM-dd HH:mm:ss。 */ +export function formatDateTime(value: Date | dayjs.Dayjs | string) { + const parsed = dayjs(value); + if (!parsed.isValid()) return ''; + return parsed.format('YYYY-MM-DD HH:mm:ss'); +} + +/** 列表项映射到编辑表单(兜底)。 */ +export function mapListItemToEditorForm( + item: ProductListItemDto, +): ProductEditorFormState { + return { + id: item.id, + name: item.name, + subtitle: item.subtitle, + description: item.subtitle, + categoryId: item.categoryId, + kind: item.kind, + price: item.price, + originalPrice: item.originalPrice, + stock: item.stock, + tagsText: tagsToText(item.tags), + status: item.status, + shelfMode: 'draft', + timedOnShelfAt: '', + }; +} + +/** 详情映射到编辑表单。 */ +export function mapDetailToEditorForm( + detail: ProductDetailDto, +): ProductEditorFormState { + return { + id: detail.id, + name: detail.name, + subtitle: detail.subtitle, + description: detail.description, + categoryId: detail.categoryId, + kind: detail.kind, + price: detail.price, + originalPrice: detail.originalPrice, + stock: detail.stock, + tagsText: tagsToText(detail.tags), + status: detail.status, + shelfMode: detail.status === 'on_sale' ? 'now' : 'draft', + timedOnShelfAt: '', + }; +} + +/** 列表项映射到快速编辑表单。 */ +export function mapListItemToQuickEditForm( + item: ProductListItemDto, +): ProductQuickEditFormState { + return { + id: item.id, + price: item.price, + originalPrice: item.originalPrice, + stock: item.stock, + isOnSale: item.status === 'on_sale', + }; +} + +/** 列表项映射到沽清表单。 */ +export function mapListItemToSoldoutForm( + item: ProductListItemDto, +): ProductSoldoutFormState { + return { + mode: item.soldoutMode ?? 'today', + remainStock: Math.max(0, item.stock), + reason: item.soldoutMode ? '库存紧张' : '', + recoverAt: '', + syncToPlatform: true, + notifyManager: false, + }; +} + +/** 编辑表单映射为保存请求参数。 */ +export function toSavePayload( + form: ProductEditorFormState, + storeId: string, +): SaveProductDto { + const price = Number(form.price || 0); + const originalPrice = Number(form.originalPrice ?? 0); + let normalizedStatus: ProductStatus = form.status; + if (form.shelfMode === 'now') { + normalizedStatus = 'on_sale'; + } else if (form.shelfMode === 'scheduled') { + normalizedStatus = 'off_shelf'; + } + + return { + id: form.id || undefined, + storeId, + categoryId: form.categoryId, + name: form.name.trim(), + subtitle: form.subtitle.trim(), + description: form.description.trim(), + kind: form.kind, + price: Number.isFinite(price) ? Number(price.toFixed(2)) : 0, + originalPrice: + originalPrice > 0 && Number.isFinite(originalPrice) + ? Number(originalPrice.toFixed(2)) + : null, + stock: Math.max(0, Math.floor(Number(form.stock || 0))), + tags: textToTags(form.tagsText), + status: normalizedStatus, + shelfMode: form.shelfMode, + timedOnShelfAt: + form.shelfMode === 'scheduled' && form.timedOnShelfAt + ? formatDateTime(form.timedOnShelfAt) + : undefined, + }; +} diff --git a/apps/web-antd/src/views/product/list/composables/product-list-page/list-actions.ts b/apps/web-antd/src/views/product/list/composables/product-list-page/list-actions.ts new file mode 100644 index 0000000..958a329 --- /dev/null +++ b/apps/web-antd/src/views/product/list/composables/product-list-page/list-actions.ts @@ -0,0 +1,86 @@ +/** + * 文件职责:商品列表交互动作。 + * 1. 管理筛选应用、重置、分页切换。 + * 2. 管理列表勾选状态(单选、全选、清空)。 + */ +import type { Ref } from 'vue'; + +import type { + ProductFilterState, + ProductPaginationChangePayload, +} from '#/views/product/list/types'; + +interface CreateListActionsOptions { + filters: ProductFilterState; + loadProducts: () => Promise; + selectedProductIds: Ref; +} + +export function createListActions(options: CreateListActionsOptions) { + /** 清空当前勾选。 */ + function clearSelection() { + options.selectedProductIds.value = []; + } + + /** 切换单个商品勾选。 */ + function toggleSelectProduct(productId: string, checked: boolean) { + const idSet = new Set(options.selectedProductIds.value); + if (checked) { + idSet.add(productId); + } else { + idSet.delete(productId); + } + options.selectedProductIds.value = [...idSet]; + } + + /** 按当前页 ID 执行全选/反选。 */ + function toggleSelectAllOnPage(currentPageIds: string[], checked: boolean) { + const idSet = new Set(options.selectedProductIds.value); + if (checked) { + for (const id of currentPageIds) { + idSet.add(id); + } + } else { + for (const id of currentPageIds) { + idSet.delete(id); + } + } + options.selectedProductIds.value = [...idSet]; + } + + /** 应用筛选条件并回到第一页。 */ + async function applyFilters() { + options.filters.page = 1; + await options.loadProducts(); + clearSelection(); + } + + /** 重置筛选后重新查询列表。 */ + async function resetFilters() { + options.filters.keyword = ''; + options.filters.status = ''; + options.filters.kind = ''; + options.filters.categoryId = ''; + options.filters.page = 1; + options.filters.pageSize = 12; + await options.loadProducts(); + clearSelection(); + } + + /** 处理分页变化。 */ + async function changePage(payload: ProductPaginationChangePayload) { + options.filters.page = payload.page; + options.filters.pageSize = payload.pageSize; + await options.loadProducts(); + clearSelection(); + } + + return { + applyFilters, + changePage, + clearSelection, + resetFilters, + toggleSelectAllOnPage, + toggleSelectProduct, + }; +} diff --git a/apps/web-antd/src/views/product/list/composables/useProductListPage.ts b/apps/web-antd/src/views/product/list/composables/useProductListPage.ts new file mode 100644 index 0000000..d6bbe0b --- /dev/null +++ b/apps/web-antd/src/views/product/list/composables/useProductListPage.ts @@ -0,0 +1,489 @@ +/** + * 文件职责:商品列表页面主编排。 + * 1. 维护门店、分类、列表、筛选、勾选、抽屉状态。 + * 2. 组装数据加载、列表交互、抽屉动作、批量动作。 + * 3. 对视图层暴露可直接绑定的状态与方法。 + */ +import type { + ProductCategoryDto, + ProductListItemDto, + ProductStatus, +} from '#/api/product'; +import type { StoreListItemDto } from '#/api/store'; +import type { + ProductBatchAction, + ProductFilterState, +} from '#/views/product/list/types'; + +import { computed, onMounted, reactive, ref, watch } from 'vue'; +import { useRouter } from 'vue-router'; + +import { createBatchActions } from './product-list-page/batch-actions'; +import { + DEFAULT_EDITOR_FORM, + DEFAULT_FILTER_STATE, + DEFAULT_QUICK_EDIT_FORM, + DEFAULT_SOLDOUT_FORM, + PAGE_SIZE_OPTIONS, + PRODUCT_KIND_OPTIONS, + PRODUCT_STATUS_OPTIONS, + PRODUCT_VIEW_OPTIONS, +} from './product-list-page/constants'; +import { createDataActions } from './product-list-page/data-actions'; +import { createDrawerActions } from './product-list-page/drawer-actions'; +import { + cloneEditorForm, + cloneFilterState, + cloneQuickEditForm, + cloneSoldoutForm, + formatCurrency, + resolveStatusMeta, +} from './product-list-page/helpers'; +import { createListActions } from './product-list-page/list-actions'; + +export function useProductListPage() { + const router = useRouter(); + + // 1. 页面加载状态。 + const isStoreLoading = ref(false); + const isCategoryLoading = ref(false); + const isListLoading = ref(false); + const isEditorSubmitting = ref(false); + const isQuickEditSubmitting = ref(false); + const isSoldoutSubmitting = ref(false); + + // 2. 核心业务数据。 + const stores = ref([]); + const selectedStoreId = ref(''); + const categories = ref([]); + const products = ref([]); + const total = ref(0); + + const filters = reactive( + cloneFilterState(DEFAULT_FILTER_STATE), + ); + const viewMode = ref<'card' | 'list'>('list'); + const selectedProductIds = ref([]); + + // 3. 抽屉状态与表单。 + const isEditorDrawerOpen = ref(false); + const editorDrawerMode = ref<'create' | 'edit'>('create'); + const editorForm = reactive(cloneEditorForm(DEFAULT_EDITOR_FORM)); + + const isQuickEditDrawerOpen = ref(false); + const quickEditForm = reactive(cloneQuickEditForm(DEFAULT_QUICK_EDIT_FORM)); + const currentQuickEditProduct = ref(null); + + const isSoldoutDrawerOpen = ref(false); + const soldoutForm = reactive(cloneSoldoutForm(DEFAULT_SOLDOUT_FORM)); + const currentSoldoutProduct = ref(null); + + // 4. 衍生状态。 + const storeOptions = computed(() => + stores.value.map((item) => ({ label: item.name, value: item.id })), + ); + + const categorySidebarItems = computed(() => { + const allCount = categories.value.reduce( + (sum, item) => sum + item.productCount, + 0, + ); + return [ + { id: '', name: '全部商品', productCount: allCount }, + ...categories.value.map((item) => ({ + id: item.id, + name: item.name, + productCount: item.productCount, + })), + ]; + }); + + const categoryOptions = computed(() => + categories.value.map((item) => ({ + label: item.name, + value: item.id, + })), + ); + + const currentPageIds = computed(() => products.value.map((item) => item.id)); + + const isAllCurrentPageChecked = computed(() => { + if (currentPageIds.value.length === 0) return false; + return currentPageIds.value.every((id) => + selectedProductIds.value.includes(id), + ); + }); + + const isCurrentPageIndeterminate = computed(() => { + const selectedCount = currentPageIds.value.filter((id) => + selectedProductIds.value.includes(id), + ).length; + return selectedCount > 0 && selectedCount < currentPageIds.value.length; + }); + + const selectedCount = computed(() => selectedProductIds.value.length); + + const editorDrawerTitle = computed(() => + editorDrawerMode.value === 'edit' ? '编辑商品' : '添加商品', + ); + + const editorSubmitText = computed(() => + editorDrawerMode.value === 'edit' ? '保存修改' : '确认添加', + ); + + const quickEditSummary = computed(() => currentQuickEditProduct.value); + const soldoutSummary = computed(() => currentSoldoutProduct.value); + + // 5. 动作装配。 + const { loadProducts, loadStores, reloadCurrentStoreData } = + createDataActions({ + categories, + filters, + isCategoryLoading, + isListLoading, + isStoreLoading, + products, + selectedStoreId, + stores, + total, + }); + + const { + applyFilters, + changePage, + clearSelection, + resetFilters, + toggleSelectAllOnPage, + toggleSelectProduct, + } = createListActions({ + filters, + loadProducts, + selectedProductIds, + }); + + const { + changeProductStatus, + deleteProduct, + openCreateDrawer, + openEditDrawer, + openQuickEditDrawer, + openSoldoutDrawer, + setEditorDrawerOpen, + setQuickEditDrawerOpen, + setSoldoutDrawerOpen, + submitEditor, + submitQuickEdit, + submitSoldout, + } = createDrawerActions({ + clearSelection, + currentQuickEditProduct, + currentSoldoutProduct, + editorDrawerMode, + editorForm, + isEditorDrawerOpen, + isEditorSubmitting, + isQuickEditDrawerOpen, + isQuickEditSubmitting, + isSoldoutDrawerOpen, + isSoldoutSubmitting, + quickEditForm, + reloadCurrentStoreData, + selectedStoreId, + soldoutForm, + }); + + const { handleBatchAction } = createBatchActions({ + clearSelection, + reloadCurrentStoreData, + selectedProductIds, + selectedStoreId, + }); + + // 6. 字段更新方法。 + function setSelectedStoreId(value: string) { + selectedStoreId.value = value; + } + + async function setSelectedCategoryId(value: string) { + filters.categoryId = value; + filters.page = 1; + await loadProducts(); + clearSelection(); + } + + function setFilterKeyword(value: string) { + filters.keyword = value; + } + + function setFilterStatus(value: '' | ProductStatus) { + filters.status = value; + } + + function setFilterKind(value: '' | 'combo' | 'single') { + filters.kind = value; + } + + function setViewMode(value: 'card' | 'list') { + viewMode.value = value; + } + + function setEditorName(value: string) { + editorForm.name = value; + } + + function setEditorSubtitle(value: string) { + editorForm.subtitle = value; + } + + function setEditorCategoryId(value: string) { + editorForm.categoryId = value; + } + + function setEditorDescription(value: string) { + editorForm.description = value; + } + + function setEditorKind(value: 'combo' | 'single') { + editorForm.kind = value; + } + + function setEditorPrice(value: number) { + editorForm.price = Math.max(0, Number(value || 0)); + } + + function setEditorOriginalPrice(value: null | number) { + if (value === null || value === undefined || Number(value) <= 0) { + editorForm.originalPrice = null; + return; + } + editorForm.originalPrice = Math.max(0, Number(value)); + } + + function setEditorStock(value: number) { + editorForm.stock = Math.max(0, Math.floor(Number(value || 0))); + } + + function setEditorTagsText(value: string) { + editorForm.tagsText = value; + } + + function setEditorShelfMode(value: 'draft' | 'now' | 'scheduled') { + editorForm.shelfMode = value; + } + + function setEditorTimedOnShelfAt(value: string) { + editorForm.timedOnShelfAt = value; + } + + function setQuickEditPrice(value: number) { + quickEditForm.price = Math.max(0, Number(value || 0)); + } + + function setQuickEditOriginalPrice(value: null | number) { + if (value === null || value === undefined || Number(value) <= 0) { + quickEditForm.originalPrice = null; + return; + } + quickEditForm.originalPrice = Math.max(0, Number(value)); + } + + function setQuickEditStock(value: number) { + quickEditForm.stock = Math.max(0, Math.floor(Number(value || 0))); + } + + function setQuickEditOnSale(value: boolean) { + quickEditForm.isOnSale = value; + } + + function setSoldoutMode(value: 'permanent' | 'timed' | 'today') { + soldoutForm.mode = value; + } + + function setSoldoutRemainStock(value: number) { + soldoutForm.remainStock = Math.max(0, Math.floor(Number(value || 0))); + } + + function setSoldoutReason(value: string) { + soldoutForm.reason = value; + } + + function setSoldoutRecoverAt(value: string) { + soldoutForm.recoverAt = value; + } + + function setSoldoutSyncToPlatform(value: boolean) { + soldoutForm.syncToPlatform = value; + } + + function setSoldoutNotifyManager(value: boolean) { + soldoutForm.notifyManager = value; + } + + // 7. 页面动作封装。 + async function openCreateProductDrawer() { + const fallbackCategoryId = + filters.categoryId || categoryOptions.value[0]?.value || ''; + openCreateDrawer(fallbackCategoryId); + } + + /** 按商品 ID 跳转详情页。 */ + function openProductDetailById(productId: string) { + if (!selectedStoreId.value || !productId) return; + router.push({ + path: '/product/detail', + query: { + storeId: selectedStoreId.value, + productId, + }, + }); + } + + function openProductDetail(item: ProductListItemDto) { + openProductDetailById(item.id); + } + + async function applySearchFilters() { + await applyFilters(); + } + + async function resetSearchFilters() { + await resetFilters(); + } + + async function handlePageChange(payload: { page: number; pageSize: number }) { + await changePage(payload); + } + + function handleToggleSelect(payload: { + checked: boolean; + productId: string; + }) { + toggleSelectProduct(payload.productId, payload.checked); + } + + function handleToggleSelectAll(checked: boolean) { + toggleSelectAllOnPage(currentPageIds.value, checked); + } + + async function handleSingleStatusChange(payload: { + item: ProductListItemDto; + status: ProductStatus; + }) { + await changeProductStatus(payload.item, payload.status); + } + + async function handleDeleteProduct(item: ProductListItemDto) { + await deleteProduct(item); + } + + async function handleBatchCommand(action: ProductBatchAction) { + await handleBatchAction(action); + } + + // 8. 监听门店切换并刷新页面。 + watch(selectedStoreId, async (storeId) => { + if (!storeId) { + categories.value = []; + products.value = []; + total.value = 0; + clearSelection(); + return; + } + + filters.page = 1; + filters.categoryId = ''; + clearSelection(); + await reloadCurrentStoreData(); + }); + + // 9. 页面初始化。 + onMounted(loadStores); + + return { + PAGE_SIZE_OPTIONS, + PRODUCT_KIND_OPTIONS, + PRODUCT_STATUS_OPTIONS, + PRODUCT_VIEW_OPTIONS, + applySearchFilters, + categories, + categoryOptions, + categorySidebarItems, + clearSelection, + currentPageIds, + deleteProduct: handleDeleteProduct, + editorDrawerMode, + editorDrawerTitle, + editorForm, + editorSubmitText, + filters, + formatCurrency, + handleBatchCommand, + handlePageChange, + handleSingleStatusChange, + handleToggleSelect, + handleToggleSelectAll, + isAllCurrentPageChecked, + isCategoryLoading, + isCurrentPageIndeterminate, + isEditorDrawerOpen, + isEditorSubmitting, + isListLoading, + isQuickEditDrawerOpen, + isQuickEditSubmitting, + isSoldoutDrawerOpen, + isSoldoutSubmitting, + isStoreLoading, + openCreateProductDrawer, + openEditDrawer, + openProductDetail, + openProductDetailById, + openQuickEditDrawer, + openSoldoutDrawer, + products, + quickEditForm, + quickEditSummary, + resetSearchFilters, + resolveStatusMeta, + selectedCount, + selectedProductIds, + selectedStoreId, + setEditorCategoryId, + setEditorDescription, + setEditorDrawerOpen, + setEditorKind, + setEditorName, + setEditorOriginalPrice, + setEditorPrice, + setEditorShelfMode, + setEditorStock, + setEditorSubtitle, + setEditorTagsText, + setEditorTimedOnShelfAt, + setFilterKind, + setFilterKeyword, + setFilterStatus, + setQuickEditDrawerOpen, + setQuickEditOnSale, + setQuickEditOriginalPrice, + setQuickEditPrice, + setQuickEditStock, + setSelectedCategoryId, + setSelectedStoreId, + setSoldoutDrawerOpen, + setSoldoutMode, + setSoldoutNotifyManager, + setSoldoutReason, + setSoldoutRecoverAt, + setSoldoutRemainStock, + setSoldoutSyncToPlatform, + setViewMode, + soldoutForm, + soldoutSummary, + storeOptions, + submitEditor, + submitQuickEdit, + submitSoldout, + total, + viewMode, + }; +} diff --git a/apps/web-antd/src/views/product/list/index.vue b/apps/web-antd/src/views/product/list/index.vue new file mode 100644 index 0000000..a66b042 --- /dev/null +++ b/apps/web-antd/src/views/product/list/index.vue @@ -0,0 +1,253 @@ + + + + + diff --git a/apps/web-antd/src/views/product/list/styles/actionbar.less b/apps/web-antd/src/views/product/list/styles/actionbar.less new file mode 100644 index 0000000..e9fe4e2 --- /dev/null +++ b/apps/web-antd/src/views/product/list/styles/actionbar.less @@ -0,0 +1,24 @@ +/* 文件职责:商品列表动作条样式。 */ +.page-product-list { + .product-action-bar { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + margin: 12px 0; + } + + .product-action-left { + display: flex; + gap: 10px; + align-items: center; + } + + .product-action-selected { + display: flex; + gap: 4px; + align-items: center; + font-size: 13px; + color: #6b7280; + } +} diff --git a/apps/web-antd/src/views/product/list/styles/base.less b/apps/web-antd/src/views/product/list/styles/base.less new file mode 100644 index 0000000..0828a46 --- /dev/null +++ b/apps/web-antd/src/views/product/list/styles/base.less @@ -0,0 +1,28 @@ +/* 文件职责:商品列表页面基础布局样式。 */ +.page-product-list { + .product-page-layout { + display: flex; + gap: 16px; + align-items: flex-start; + } + + .product-page-content { + flex: 1; + min-width: 0; + } + + .section-title { + font-size: 15px; + font-weight: 600; + color: #1f2937; + } +} + +.product-category-sidebar-card, +.product-filter-toolbar-card { + border-radius: 10px; +} + +.full-width { + width: 100%; +} diff --git a/apps/web-antd/src/views/product/list/styles/drawer.less b/apps/web-antd/src/views/product/list/styles/drawer.less new file mode 100644 index 0000000..55863f6 --- /dev/null +++ b/apps/web-antd/src/views/product/list/styles/drawer.less @@ -0,0 +1,207 @@ +/* 文件职责:商品页面抽屉样式。 */ +.product-editor-drawer, +.product-quick-edit-drawer, +.product-soldout-drawer { + .ant-drawer-header { + min-height: 56px; + padding: 0 22px; + border-bottom: 1px solid #f0f0f0; + } + + .ant-drawer-title { + font-size: 16px; + font-weight: 600; + color: #1f2937; + } + + .ant-drawer-body { + padding: 18px 20px 12px; + } + + .ant-drawer-footer { + padding: 12px 20px; + border-top: 1px solid #f0f0f0; + } + + .product-drawer-section { + margin-bottom: 16px; + } + + .product-drawer-section-title { + margin-bottom: 10px; + font-size: 14px; + font-weight: 600; + color: #1f2937; + } + + .drawer-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + } + + .drawer-form-item.full { + grid-column: 1 / -1; + } + + .drawer-form-item.compact { + display: inline-flex; + gap: 8px; + align-items: center; + } + + .drawer-form-label { + display: block; + margin-bottom: 6px; + font-size: 13px; + font-weight: 500; + color: #374151; + } + + .drawer-form-label.required::before { + margin-right: 4px; + color: #ef4444; + content: '*'; + } + + .stock-input { + width: 120px; + } + + .unit { + font-size: 12px; + color: #6b7280; + } + + .hint { + margin-top: 6px; + font-size: 12px; + color: #9ca3af; + } + + .switch-row { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 8px; + font-size: 13px; + color: #4b5563; + } +} + +.product-editor-drawer { + .product-kind-radio-group { + width: 100%; + } + + .product-kind-radio-group .ant-radio-button-wrapper { + width: 96px; + text-align: center; + } + + .product-shelf-radio-group { + display: flex; + flex-direction: column; + gap: 10px; + } + + .shelf-radio-item { + padding: 8px 10px; + margin-inline-start: 0; + background: #f8fafc; + border: 1px solid #e5e7eb; + border-radius: 8px; + } + + .shelf-time-row { + margin-top: 10px; + } +} + +.product-quick-edit-drawer, +.product-soldout-drawer { + .product-quick-card, + .product-soldout-card { + display: flex; + gap: 10px; + align-items: center; + padding: 10px; + margin-bottom: 14px; + background: #f8fafc; + border: 1px solid #e5e7eb; + border-radius: 10px; + } + + .product-quick-cover { + display: inline-flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + font-size: 16px; + font-weight: 700; + color: #94a3b8; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 8px; + } + + .product-quick-meta { + flex: 1; + min-width: 0; + } + + .product-quick-meta .name { + overflow: hidden; + text-overflow: ellipsis; + font-size: 14px; + font-weight: 600; + color: #111827; + white-space: nowrap; + } + + .product-quick-meta .sub { + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + font-size: 12px; + color: #9ca3af; + white-space: nowrap; + } +} + +.product-soldout-drawer { + .soldout-mode-group { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 10px; + } + + .soldout-mode-item { + padding: 8px 10px; + margin-inline-start: 0; + background: #f8fafc; + border: 1px solid #e5e7eb; + border-radius: 8px; + } +} + +.product-drawer-footer { + display: flex; + align-items: center; + justify-content: space-between; +} + +.product-drawer-footer-right { + display: flex; + gap: 10px; + align-items: center; + margin-left: auto; +} + +.product-drawer-footer-right .ant-btn { + min-width: 92px; + height: 40px; + border-radius: 10px; +} diff --git a/apps/web-antd/src/views/product/list/styles/index.less b/apps/web-antd/src/views/product/list/styles/index.less new file mode 100644 index 0000000..876f6d9 --- /dev/null +++ b/apps/web-antd/src/views/product/list/styles/index.less @@ -0,0 +1,8 @@ +/* 文件职责:商品列表页面样式聚合入口(仅负责分片导入)。 */ +@import './base.less'; +@import './sidebar.less'; +@import './toolbar.less'; +@import './actionbar.less'; +@import './list.less'; +@import './drawer.less'; +@import './responsive.less'; diff --git a/apps/web-antd/src/views/product/list/styles/list.less b/apps/web-antd/src/views/product/list/styles/list.less new file mode 100644 index 0000000..21bd73c --- /dev/null +++ b/apps/web-antd/src/views/product/list/styles/list.less @@ -0,0 +1,347 @@ +/* 文件职责:商品卡片/列表展示区与分页样式。 */ +.page-product-list { + .product-list-section { + min-height: 240px; + } + + .product-card-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + } + + .product-card-item { + position: relative; + overflow: hidden; + background: #fff; + border: 1px solid #f0f0f0; + border-radius: 12px; + box-shadow: 0 3px 10px rgb(15 23 42 / 5%); + transition: all 0.2s ease; + } + + .product-card-item:hover { + box-shadow: 0 10px 24px rgb(15 23 42 / 10%); + transform: translateY(-2px); + } + + .product-card-item.is-soldout { + opacity: 0.75; + } + + .product-card-item.is-offshelf { + opacity: 0.9; + } + + .product-card-status-ribbon { + position: absolute; + top: 12px; + right: 0; + z-index: 2; + padding: 2px 10px; + font-size: 11px; + font-weight: 600; + color: #fff; + border-radius: 6px 0 0 6px; + } + + .product-card-check { + position: absolute; + top: 12px; + left: 12px; + z-index: 2; + } + + .product-card-cover { + display: flex; + align-items: center; + justify-content: center; + height: 156px; + font-size: 36px; + font-weight: 700; + color: #94a3b8; + background: linear-gradient(135deg, #f8fafc 0%, #eef2f7 100%); + } + + .product-card-body { + padding: 12px 14px 10px; + } + + .product-card-name { + margin-bottom: 2px; + overflow: hidden; + text-overflow: ellipsis; + font-size: 15px; + font-weight: 600; + color: #111827; + white-space: nowrap; + } + + .product-card-subtitle { + margin-bottom: 2px; + overflow: hidden; + text-overflow: ellipsis; + font-size: 12px; + color: #9ca3af; + white-space: nowrap; + } + + .product-card-spu { + margin-bottom: 8px; + font-family: + ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, + 'Liberation Mono', monospace; + font-size: 11px; + color: #c0c4cc; + } + + .product-card-price-row { + display: flex; + gap: 8px; + align-items: baseline; + margin-bottom: 8px; + } + + .price-now { + font-size: 16px; + font-weight: 700; + color: #ef4444; + } + + .price-old { + font-size: 12px; + color: #9ca3af; + text-decoration: line-through; + } + + .product-card-meta { + display: flex; + gap: 12px; + margin-bottom: 8px; + font-size: 12px; + color: #6b7280; + } + + .product-card-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 26px; + } + + .product-card-footer { + display: flex; + gap: 10px; + align-items: center; + padding: 10px 14px; + background: #fafbfc; + border-top: 1px solid #f3f4f6; + } + + .product-card-mask { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 30px; + font-weight: 700; + color: #ef4444; + letter-spacing: 6px; + pointer-events: none; + background: rgb(255 255 255 / 45%); + } + + .product-list-table { + overflow: hidden; + background: #fff; + border: 1px solid #f0f0f0; + border-radius: 10px; + } + + .product-list-header, + .product-list-row { + display: grid; + grid-template-columns: + 44px 66px minmax(130px, 1.2fr) 80px 110px 88px 84px minmax(90px, 1.1fr) + 74px 180px; + gap: 0; + align-items: center; + } + + .product-list-header { + min-height: 44px; + font-size: 12px; + font-weight: 600; + color: #6b7280; + background: #f8fafc; + border-bottom: 1px solid #f0f0f0; + } + + .product-list-row { + min-height: 72px; + font-size: 13px; + color: #374151; + border-bottom: 1px solid #f5f5f5; + transition: background 0.2s ease; + } + + .product-list-row:last-child { + border-bottom: none; + } + + .product-list-row:hover { + background: #fafcff; + } + + .cell { + padding: 8px 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .check-cell { + display: flex; + justify-content: center; + } + + .image-cell { + display: flex; + justify-content: center; + } + + .list-cover { + display: inline-flex; + align-items: center; + justify-content: center; + width: 46px; + height: 46px; + font-size: 18px; + font-weight: 700; + color: #94a3b8; + background: linear-gradient(135deg, #f8fafc 0%, #eef2f7 100%); + border-radius: 8px; + } + + .info-cell { + display: flex; + flex-direction: column; + gap: 2px; + line-height: 1.35; + } + + .info-cell .name { + overflow: hidden; + text-overflow: ellipsis; + font-weight: 600; + color: #111827; + white-space: nowrap; + } + + .info-cell .subtitle { + overflow: hidden; + text-overflow: ellipsis; + font-size: 12px; + color: #9ca3af; + white-space: nowrap; + } + + .info-cell .spu { + overflow: hidden; + text-overflow: ellipsis; + font-family: + ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, + 'Liberation Mono', monospace; + font-size: 11px; + color: #c0c4cc; + white-space: nowrap; + } + + .price-cell { + display: flex; + flex-direction: column; + gap: 2px; + } + + .tags-cell { + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; + white-space: normal; + } + + .actions-cell { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + } + + .product-pagination { + display: flex; + justify-content: flex-end; + padding-top: 16px; + } +} + +.product-link-btn { + padding: 0; + font-size: 12px; + color: #1677ff; + cursor: pointer; + background: transparent; + border: none; +} + +.product-link-btn:hover { + text-decoration: underline; +} + +.product-link-btn.danger { + color: #ef4444; +} + +.stock-ok { + color: #22c55e; +} + +.stock-low { + color: #f59e0b; +} + +.stock-out { + color: #ef4444; +} + +.product-status-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 52px; + padding: 2px 8px; + font-size: 12px; + font-weight: 600; + line-height: 1.5; + border: 1px solid transparent; + border-radius: 999px; +} + +.product-status-pill.status-on-sale { + color: #16a34a; + background: #f0fdf4; + border-color: #bbf7d0; +} + +.product-status-pill.status-off-shelf { + color: #6b7280; + background: #f3f4f6; + border-color: #d1d5db; +} + +.product-status-pill.status-sold-out { + color: #ef4444; + background: #fef2f2; + border-color: #fecaca; +} diff --git a/apps/web-antd/src/views/product/list/styles/responsive.less b/apps/web-antd/src/views/product/list/styles/responsive.less new file mode 100644 index 0000000..e4e9eeb --- /dev/null +++ b/apps/web-antd/src/views/product/list/styles/responsive.less @@ -0,0 +1,77 @@ +/* 文件职责:商品列表页面响应式样式。 */ +@media (max-width: 1200px) { + .page-product-list { + .product-card-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } +} + +@media (max-width: 992px) { + .page-product-list { + .product-page-layout { + flex-direction: column; + } + + .product-category-sidebar-card { + position: static; + width: 100%; + } + + .product-list-header, + .product-list-row { + grid-template-columns: 40px 54px minmax(120px, 1fr) 80px 88px 80px; + } + + .product-list-header .cell:nth-child(4), + .product-list-header .cell:nth-child(7), + .product-list-header .cell:nth-child(8), + .product-list-header .cell:nth-child(9), + .product-list-row .cell:nth-child(4), + .product-list-row .cell:nth-child(7), + .product-list-row .cell:nth-child(8), + .product-list-row .cell:nth-child(9) { + display: none; + } + } +} + +@media (max-width: 768px) { + .page-product-list { + .product-filter-toolbar { + align-items: stretch; + } + + .product-filter-store-select, + .product-filter-input, + .product-filter-select { + width: 100%; + } + + .product-filter-spacer { + display: none; + } + + .product-view-switch { + width: 100%; + } + + .product-view-btn { + flex: 1; + } + + .product-action-bar { + flex-direction: column; + align-items: stretch; + } + + .product-action-left { + justify-content: space-between; + width: 100%; + } + + .product-card-grid { + grid-template-columns: 1fr; + } + } +} diff --git a/apps/web-antd/src/views/product/list/styles/sidebar.less b/apps/web-antd/src/views/product/list/styles/sidebar.less new file mode 100644 index 0000000..b0a025a --- /dev/null +++ b/apps/web-antd/src/views/product/list/styles/sidebar.less @@ -0,0 +1,68 @@ +/* 文件职责:商品分类侧栏样式。 */ +.page-product-list { + .product-category-sidebar-card { + position: sticky; + top: 0; + flex-shrink: 0; + width: 220px; + } + + .product-category-sidebar { + display: flex; + flex-direction: column; + gap: 6px; + } + + .product-category-item { + display: flex; + gap: 10px; + align-items: center; + width: 100%; + padding: 8px 10px; + color: #4b5563; + cursor: pointer; + background: #fff; + border: none; + border-left: 3px solid transparent; + border-radius: 8px; + transition: all 0.2s ease; + } + + .product-category-item:hover { + background: #f8fafc; + } + + .product-category-item.active { + color: #1677ff; + background: #f0f6ff; + border-left-color: #1677ff; + } + + .category-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + font-size: 13px; + font-weight: 500; + text-align: left; + white-space: nowrap; + } + + .category-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 20px; + padding: 0 7px; + font-size: 11px; + color: #6b7280; + background: #f3f4f6; + border-radius: 999px; + } + + .product-category-item.active .category-count { + color: #1677ff; + background: #dbeafe; + } +} diff --git a/apps/web-antd/src/views/product/list/styles/toolbar.less b/apps/web-antd/src/views/product/list/styles/toolbar.less new file mode 100644 index 0000000..45aeb79 --- /dev/null +++ b/apps/web-antd/src/views/product/list/styles/toolbar.less @@ -0,0 +1,54 @@ +/* 文件职责:商品列表筛选条与视图切换样式。 */ +.page-product-list { + .product-filter-toolbar { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + } + + .product-filter-store-select { + width: 220px; + } + + .product-filter-input { + width: 220px; + } + + .product-filter-select { + width: 130px; + } + + .product-filter-spacer { + flex: 1; + } + + .product-view-switch { + display: inline-flex; + gap: 8px; + } + + .product-view-btn { + min-width: 62px; + height: 32px; + padding: 0 14px; + font-size: 13px; + color: #6b7280; + cursor: pointer; + background: #fff; + border: 1px solid #d9d9d9; + border-radius: 8px; + transition: all 0.2s ease; + } + + .product-view-btn:hover { + color: #1677ff; + border-color: #1677ff; + } + + .product-view-btn.active { + color: #1677ff; + background: #f0f6ff; + border-color: #1677ff; + } +} diff --git a/apps/web-antd/src/views/product/list/types.ts b/apps/web-antd/src/views/product/list/types.ts new file mode 100644 index 0000000..0e18ed4 --- /dev/null +++ b/apps/web-antd/src/views/product/list/types.ts @@ -0,0 +1,86 @@ +/** + * 文件职责:商品列表页面类型定义。 + * 1. 维护筛选、分页、抽屉表单等页面状态类型。 + * 2. 约束组件通信与批量动作入参。 + */ +import type { + BatchProductActionType, + ProductKind, + ProductSoldoutMode, + ProductStatus, +} from '#/api/product'; + +/** 页面视图模式。 */ +export type ProductViewMode = 'card' | 'list'; + +/** 商品筛选条件。 */ +export interface ProductFilterState { + categoryId: string; + kind: '' | ProductKind; + keyword: string; + page: number; + pageSize: number; + status: '' | ProductStatus; +} + +/** 侧栏分类项。 */ +export interface ProductCategorySidebarItem { + id: string; + name: string; + productCount: number; +} + +/** 商品编辑抽屉模式。 */ +export type ProductEditorDrawerMode = 'create' | 'edit'; + +/** 商品编辑表单。 */ +export interface ProductEditorFormState { + categoryId: string; + description: string; + id: string; + kind: ProductKind; + name: string; + originalPrice: null | number; + price: number; + shelfMode: 'draft' | 'now' | 'scheduled'; + status: ProductStatus; + stock: number; + subtitle: string; + tagsText: string; + timedOnShelfAt: string; +} + +/** 快速编辑表单。 */ +export interface ProductQuickEditFormState { + id: string; + isOnSale: boolean; + originalPrice: null | number; + price: number; + stock: number; +} + +/** 沽清表单。 */ +export interface ProductSoldoutFormState { + mode: ProductSoldoutMode; + notifyManager: boolean; + reason: string; + recoverAt: string; + remainStock: number; + syncToPlatform: boolean; +} + +/** 列表分页变更参数。 */ +export interface ProductPaginationChangePayload { + page: number; + pageSize: number; +} + +/** 批量动作类型(页面层)。 */ +export type ProductBatchAction = BatchProductActionType; + +/** 商品状态展示元信息。 */ +export interface ProductStatusMeta { + badgeClass: string; + color: string; + label: string; +} diff --git a/apps/web-antd/src/views/store/delivery/components/DeliveryModeCard.vue b/apps/web-antd/src/views/store/delivery/components/DeliveryModeCard.vue index adc5907..c782bc8 100644 --- a/apps/web-antd/src/views/store/delivery/components/DeliveryModeCard.vue +++ b/apps/web-antd/src/views/store/delivery/components/DeliveryModeCard.vue @@ -2,19 +2,20 @@ /** * 文件职责:配送模式区块。 * 1. 展示当前启用配送模式,并提供独立切换入口。 - * 2. 展示配置编辑视图切换与半径/区域两种预览。 + * 2. 展示配置编辑视图切换与半径中心点输入。 */ -import type { DeliveryMode, RadiusTierDto } from '#/api/store-delivery'; +import type { DeliveryMode } from '#/api/store-delivery'; import { computed } from 'vue'; -import { Card } from 'ant-design-vue'; +import { Card, InputNumber } from 'ant-design-vue'; interface Props { activeMode: DeliveryMode; configMode: DeliveryMode; modeOptions: Array<{ label: string; value: DeliveryMode }>; - radiusTiers: RadiusTierDto[]; + radiusCenterLatitude: null | number; + radiusCenterLongitude: null | number; } const props = defineProps(); @@ -22,6 +23,8 @@ const props = defineProps(); const emit = defineEmits<{ (event: 'changeActiveMode', mode: DeliveryMode): void; (event: 'changeConfigMode', mode: DeliveryMode): void; + (event: 'changeRadiusCenterLatitude', value: null | number): void; + (event: 'changeRadiusCenterLongitude', value: null | number): void; }>(); const activeModeLabel = computed(() => { @@ -31,19 +34,13 @@ const activeModeLabel = computed(() => { ); }); -const radiusLabels = computed(() => { - const fallback = ['1km', '3km', '5km']; - const sorted = props.radiusTiers - .toSorted((a, b) => a.maxDistance - b.maxDistance) - .slice(0, 3); - if (sorted.length === 0) return fallback; - - const labels = sorted.map((item) => `${item.maxDistance}km`); - while (labels.length < 3) { - labels.push(fallback[labels.length] ?? '5km'); +function toNumber(value: null | number | string) { + if (value === null || value === undefined || value === '') { + return null; } - return labels; -}); + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} diff --git a/apps/web-antd/src/views/store/delivery/components/DeliveryPolygonMapModal.vue b/apps/web-antd/src/views/store/delivery/components/DeliveryPolygonMapModal.vue new file mode 100644 index 0000000..fea5d9e --- /dev/null +++ b/apps/web-antd/src/views/store/delivery/components/DeliveryPolygonMapModal.vue @@ -0,0 +1,731 @@ + + + diff --git a/apps/web-antd/src/views/store/delivery/components/DeliveryZoneDrawer.vue b/apps/web-antd/src/views/store/delivery/components/DeliveryZoneDrawer.vue index 88d01ef..ead6ed7 100644 --- a/apps/web-antd/src/views/store/delivery/components/DeliveryZoneDrawer.vue +++ b/apps/web-antd/src/views/store/delivery/components/DeliveryZoneDrawer.vue @@ -6,8 +6,13 @@ */ import type { PolygonZoneFormState } from '../types'; +import { computed, ref } from 'vue'; + import { Button, Drawer, Input, InputNumber } from 'ant-design-vue'; +import { countPolygonsInGeoJson } from '../composables/delivery-page/geojson'; +import DeliveryPolygonMapModal from './DeliveryPolygonMapModal.vue'; + interface Props { colorPalette: string[]; form: PolygonZoneFormState; @@ -16,7 +21,12 @@ interface Props { onSetEtaMinutes: (value: number) => void; onSetMinOrderAmount: (value: number) => void; onSetName: (value: string) => void; + onSetPolygonGeoJson: (value: string) => void; onSetPriority: (value: number) => void; + initialCenterAddress: string; + initialCenterLatitude: null | number; + initialCenterLongitude: null | number; + fallbackCityText: string; open: boolean; title: string; } @@ -37,6 +47,31 @@ function readInputValue(event: Event) { const target = event.target as HTMLInputElement | null; return target?.value ?? ''; } + +const isMapModalOpen = ref(false); + +const polygonCount = computed(() => + countPolygonsInGeoJson(props.form.polygonGeoJson), +); + +const formattedPolygonGeoJson = computed(() => { + const raw = props.form.polygonGeoJson?.trim() ?? ''; + if (!raw) return ''; + + try { + return JSON.stringify(JSON.parse(raw), null, 2); + } catch { + return raw; + } +}); + +function openMapModal() { + isMapModalOpen.value = true; +} + +function handleMapConfirm(geoJson: string) { + props.onSetPolygonGeoJson(geoJson); +} diff --git a/apps/web-antd/src/views/store/delivery/components/PolygonZoneSection.vue b/apps/web-antd/src/views/store/delivery/components/PolygonZoneSection.vue index b731103..36a3bf3 100644 --- a/apps/web-antd/src/views/store/delivery/components/PolygonZoneSection.vue +++ b/apps/web-antd/src/views/store/delivery/components/PolygonZoneSection.vue @@ -8,6 +8,8 @@ import type { PolygonZoneDto } from '#/api/store-delivery'; import { Button, Card, Empty, Popconfirm } from 'ant-design-vue'; +import { countPolygonsInGeoJson } from '../composables/delivery-page/geojson'; + interface Props { formatCurrency: (value: number) => string; isSaving: boolean; @@ -21,6 +23,10 @@ const emit = defineEmits<{ (event: 'delete', zoneId: string): void; (event: 'edit', zone: PolygonZoneDto): void; }>(); + +function getPolygonCount(geoJson: string) { + return countPolygonsInGeoJson(geoJson); +} + + @@ -197,6 +227,14 @@ function formatCreatedDate(value: string) { > {{ getToggleBusinessStatusText(record) }} + = { [ServiceTypeEnum.Pickup]: { color: 'green', text: '自提' }, }; +export const geoStatusMap: Record = { + [GeoLocationStatusEnum.Pending]: { color: 'orange', text: '待定位' }, + [GeoLocationStatusEnum.Success]: { color: 'green', text: '已定位' }, + [GeoLocationStatusEnum.Failed]: { color: 'red', text: '定位失败' }, +}; + export const businessStatusOptions: SelectOptionItem[] = [ { label: '营业中', value: StoreBusinessStatusEnum.Operating }, { label: '休息中', value: StoreBusinessStatusEnum.Resting }, @@ -125,8 +132,9 @@ export const columns = [ { dataIndex: 'managerName', key: 'managerName', title: '店长', width: 80 }, { dataIndex: 'address', ellipsis: true, key: 'address', title: '地址' }, { key: 'serviceTypes', title: '服务方式', width: 180 }, + { key: 'geoStatus', title: '定位状态', width: 110 }, { key: 'businessStatus', title: '营业状态', width: 100 }, { key: 'auditStatus', title: '审核状态', width: 100 }, { dataIndex: 'createdAt', key: 'createdAt', title: '创建时间', width: 120 }, - { fixed: 'right' as const, key: 'action', title: '操作', width: 180 }, + { fixed: 'right' as const, key: 'action', title: '操作', width: 240 }, ]; diff --git a/apps/web-antd/src/views/store/list/composables/store-list-page/drawer-actions.ts b/apps/web-antd/src/views/store/list/composables/store-list-page/drawer-actions.ts index 657c540..5d21d63 100644 --- a/apps/web-antd/src/views/store/list/composables/store-list-page/drawer-actions.ts +++ b/apps/web-antd/src/views/store/list/composables/store-list-page/drawer-actions.ts @@ -15,6 +15,7 @@ import { message } from 'ant-design-vue'; import { createStoreApi, deleteStoreApi, + retryStoreGeocodeApi, StoreBusinessStatus as StoreBusinessStatusEnum, toggleStoreBusinessStatusApi, updateStoreApi, @@ -117,8 +118,20 @@ export function createDrawerActions(options: CreateDrawerActionsOptions) { } } + /** 手动重试门店定位。 */ + async function handleRetryGeocode(record: StoreListItemDto) { + try { + await retryStoreGeocodeApi(record.id); + message.success('已触发定位重试'); + await options.loadList(); + } catch (error) { + console.error(error); + } + } + return { handleDelete, + handleRetryGeocode, handleSubmit, handleToggleBusinessStatus, openDrawer, diff --git a/apps/web-antd/src/views/store/list/composables/useStoreListPage.ts b/apps/web-antd/src/views/store/list/composables/useStoreListPage.ts index 7cb9375..484b632 100644 --- a/apps/web-antd/src/views/store/list/composables/useStoreListPage.ts +++ b/apps/web-antd/src/views/store/list/composables/useStoreListPage.ts @@ -33,6 +33,7 @@ import { DEFAULT_FORM_STATE, DEFAULT_PAGINATION, DEFAULT_STATS, + geoStatusMap, serviceTypeMap, serviceTypeOptions, THEME_COLORS, @@ -108,15 +109,20 @@ export function useStoreListPage() { }); // 6. 组装抽屉与删除动作。 - const { handleDelete, handleSubmit, handleToggleBusinessStatus, openDrawer } = - createDrawerActions({ - drawerMode, - formState, - isDrawerVisible, - isSubmitting, - loadList, - loadStats, - }); + const { + handleDelete, + handleRetryGeocode, + handleSubmit, + handleToggleBusinessStatus, + openDrawer, + } = createDrawerActions({ + drawerMode, + formState, + isDrawerVisible, + isSubmitting, + loadList, + loadStats, + }); // 7. 筛选字段更新方法。 function setKeyword(value: string) { @@ -186,6 +192,7 @@ export function useStoreListPage() { formState, getAvatarColor, handleDeleteStore: handleDelete, + handleRetryStoreGeocode: handleRetryGeocode, handleToggleStoreBusinessStatus: handleToggleBusinessStatus, handleReset, handleSearch, @@ -197,6 +204,7 @@ export function useStoreListPage() { openCreateDrawer, openEditDrawer, pagination, + geoStatusMap, serviceTypeMap, serviceTypeOptions, setAuditStatus, diff --git a/apps/web-antd/src/views/store/list/index.vue b/apps/web-antd/src/views/store/list/index.vue index 7fb169a..1f7faa1 100644 --- a/apps/web-antd/src/views/store/list/index.vue +++ b/apps/web-antd/src/views/store/list/index.vue @@ -23,8 +23,10 @@ const { drawerTitle, filters, formState, + geoStatusMap, getAvatarColor, handleDeleteStore, + handleRetryStoreGeocode, handleToggleStoreBusinessStatus, handleReset, handleSearch, @@ -91,12 +93,14 @@ function handleExport() { :is-loading="isLoading" :pagination="tablePagination" :service-type-map="serviceTypeMap" + :geo-status-map="geoStatusMap" :business-status-map="businessStatusMap" :audit-status-map="auditStatusMap" :get-avatar-color="getAvatarColor" @table-change="handleTableChange" @edit="openEditDrawer" @delete="handleDeleteStore" + @retry-geocode="handleRetryStoreGeocode" @toggle-business-status="handleToggleStoreBusinessStatus" /> diff --git a/apps/web-antd/src/views/store/pickup/composables/useStorePickupPage.ts b/apps/web-antd/src/views/store/pickup/composables/useStorePickupPage.ts index 617884c..a457394 100644 --- a/apps/web-antd/src/views/store/pickup/composables/useStorePickupPage.ts +++ b/apps/web-antd/src/views/store/pickup/composables/useStorePickupPage.ts @@ -12,7 +12,7 @@ import type { PickupSlotFormState, } from '#/views/store/pickup/types'; -import { computed, onMounted, reactive, ref, watch } from 'vue'; +import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue'; import { ALL_WEEK_DAYS, @@ -278,6 +278,8 @@ export function useStorePickupPage() { // 9. 页面首屏初始化。 onMounted(loadStores); + // 10. 路由回到当前页时刷新门店列表,避免使用旧缓存。 + onActivated(loadStores); return { FINE_INTERVAL_OPTIONS, diff --git a/apps/web-antd/src/views/store/staff/composables/useStoreStaffPage.ts b/apps/web-antd/src/views/store/staff/composables/useStoreStaffPage.ts index b83b26e..366a6bf 100644 --- a/apps/web-antd/src/views/store/staff/composables/useStoreStaffPage.ts +++ b/apps/web-antd/src/views/store/staff/composables/useStoreStaffPage.ts @@ -22,7 +22,7 @@ import type { WeekEditorRow, } from '#/views/store/staff/types'; -import { computed, onMounted, reactive, ref, watch } from 'vue'; +import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue'; import { DAY_OPTIONS, @@ -420,6 +420,8 @@ export function useStoreStaffPage() { // 8. 页面首屏加载。 onMounted(loadStores); + // 9. 路由回到当前页时刷新门店列表,避免使用旧缓存。 + onActivated(loadStores); return { DAY_OPTIONS,