feat(project): align store delivery pages with live APIs and geocode status
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
194
apps/web-antd/src/api/product/index.ts
Normal file
194
apps/web-antd/src/api/product/index.ts
Normal file
@@ -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<ProductCategoryDto[]>('/product/category/list', {
|
||||
params: { storeId },
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取商品列表。 */
|
||||
export async function getProductListApi(params: ProductListQuery) {
|
||||
return requestClient.get<PaginatedResult<ProductListItemDto>>(
|
||||
'/product/list',
|
||||
{
|
||||
params,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取商品详情。 */
|
||||
export async function getProductDetailApi(params: ProductDetailQuery) {
|
||||
return requestClient.get<ProductDetailDto>('/product/detail', {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/** 保存商品(新增/编辑)。 */
|
||||
export async function saveProductApi(data: SaveProductDto) {
|
||||
return requestClient.post<ProductDetailDto>('/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<BatchProductActionResultDto>(
|
||||
'/product/batch',
|
||||
data,
|
||||
);
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -60,3 +60,9 @@ export enum MerchantAuditAction {
|
||||
Unknown = 0,
|
||||
Update = 2,
|
||||
}
|
||||
|
||||
export enum GeoLocationStatus {
|
||||
Failed = 2,
|
||||
Pending = 0,
|
||||
Success = 1,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
879
apps/web-antd/src/mock/product.ts
Normal file
879
apps/web-antd/src/mock/product.ts
Normal file
@@ -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<string, ProductStoreState>();
|
||||
|
||||
/** 解析 URL 查询参数。 */
|
||||
function parseUrlParams(url: string) {
|
||||
const parsed = new URL(url, 'http://localhost');
|
||||
const params: Record<string, string> = {};
|
||||
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<string, unknown>;
|
||||
} 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<string, number>();
|
||||
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,
|
||||
},
|
||||
};
|
||||
});
|
||||
36
apps/web-antd/src/router/routes/modules/product.ts
Normal file
36
apps/web-antd/src/router/routes/modules/product.ts
Normal file
@@ -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;
|
||||
144
apps/web-antd/src/views/product/detail/index.vue
Normal file
144
apps/web-antd/src/views/product/detail/index.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:商品详情骨架页。
|
||||
* 1. 根据路由参数加载商品详情数据。
|
||||
* 2. 展示基础信息并提供返回入口。
|
||||
*/
|
||||
import type { ProductDetailDto } from '#/api/product';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Button, Card, Descriptions, Empty, Spin, Tag } from 'ant-design-vue';
|
||||
|
||||
import { getProductDetailApi } from '#/api/product';
|
||||
|
||||
import {
|
||||
formatCurrency,
|
||||
resolveStatusMeta,
|
||||
} from '../list/composables/product-list-page/helpers';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const isLoading = ref(false);
|
||||
const detail = ref<null | ProductDetailDto>(null);
|
||||
|
||||
const storeId = computed(() => String(route.query.storeId || ''));
|
||||
const productId = computed(() => String(route.query.productId || ''));
|
||||
|
||||
const statusMeta = computed(() => {
|
||||
if (!detail.value) return resolveStatusMeta('off_shelf');
|
||||
return resolveStatusMeta(detail.value.status);
|
||||
});
|
||||
|
||||
/** 加载商品详情。 */
|
||||
async function loadDetail() {
|
||||
if (!storeId.value || !productId.value) {
|
||||
detail.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
detail.value = await getProductDetailApi({
|
||||
storeId: storeId.value,
|
||||
productId: productId.value,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
detail.value = null;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 返回商品列表页。 */
|
||||
function goBack() {
|
||||
router.push('/product/list');
|
||||
}
|
||||
|
||||
watch([storeId, productId], loadDetail, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page title="商品详情" content-class="space-y-4 page-product-detail">
|
||||
<Card :bordered="false">
|
||||
<Button @click="goBack">返回商品列表</Button>
|
||||
</Card>
|
||||
|
||||
<Spin :spinning="isLoading">
|
||||
<Card v-if="detail" :bordered="false">
|
||||
<div class="product-detail-header">
|
||||
<div class="product-detail-cover">
|
||||
{{ detail.name.slice(0, 1) }}
|
||||
</div>
|
||||
<div class="product-detail-title-wrap">
|
||||
<div class="title">{{ detail.name }}</div>
|
||||
<div class="sub">{{ detail.subtitle || '--' }}</div>
|
||||
<div class="spu">{{ detail.spuCode }}</div>
|
||||
</div>
|
||||
<Tag class="product-detail-status" :color="statusMeta.color">
|
||||
{{ statusMeta.label }}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
<Descriptions :column="2" bordered class="product-detail-descriptions">
|
||||
<Descriptions.Item label="商品ID">{{ detail.id }}</Descriptions.Item>
|
||||
<Descriptions.Item label="分类">
|
||||
{{ detail.categoryName || '--' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="商品类型">
|
||||
{{ detail.kind === 'combo' ? '套餐' : '单品' }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="售价">
|
||||
{{ formatCurrency(detail.price) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="原价">
|
||||
{{
|
||||
detail.originalPrice ? formatCurrency(detail.originalPrice) : '--'
|
||||
}}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="库存">
|
||||
{{ detail.stock }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="月销">
|
||||
{{ detail.salesMonthly }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="沽清模式">
|
||||
{{
|
||||
detail.soldoutMode === 'today'
|
||||
? '今日沽清'
|
||||
: detail.soldoutMode === 'timed'
|
||||
? '定时沽清'
|
||||
: detail.soldoutMode === 'permanent'
|
||||
? '永久沽清'
|
||||
: '--'
|
||||
}}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="标签" :span="2">
|
||||
<div class="product-detail-tags">
|
||||
<Tag v-for="tag in detail.tags" :key="`${detail.id}-${tag}`">
|
||||
{{ tag }}
|
||||
</Tag>
|
||||
<span v-if="detail.tags.length === 0">--</span>
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="描述" :span="2">
|
||||
{{ detail.description || '--' }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card v-else :bordered="false">
|
||||
<Empty description="未找到商品详情" />
|
||||
</Card>
|
||||
</Spin>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style lang="less">
|
||||
@import './styles/index.less';
|
||||
</style>
|
||||
74
apps/web-antd/src/views/product/detail/styles/index.less
Normal file
74
apps/web-antd/src/views/product/detail/styles/index.less
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:商品列表动作条。
|
||||
* 1. 提供批量操作入口与添加商品按钮。
|
||||
* 2. 展示当前勾选数量并支持一键清空。
|
||||
*/
|
||||
import type { ProductBatchAction } from '../types';
|
||||
|
||||
import { Button, Dropdown, Menu } from 'ant-design-vue';
|
||||
|
||||
interface BatchActionOption {
|
||||
label: string;
|
||||
value: ProductBatchAction;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
batchActionOptions: BatchActionOption[];
|
||||
batchDisabled: boolean;
|
||||
selectedCount: number;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'add'): void;
|
||||
(event: 'batchAction', action: ProductBatchAction): void;
|
||||
(event: 'clearSelection'): void;
|
||||
}>();
|
||||
|
||||
/** 透传批量动作点击。 */
|
||||
function handleBatchMenuClick(payload: { key: number | string }) {
|
||||
emit('batchAction', String(payload.key) as ProductBatchAction);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="product-action-bar">
|
||||
<div class="product-action-left">
|
||||
<Dropdown :disabled="props.batchDisabled" :trigger="['click']">
|
||||
<Button :disabled="props.batchDisabled"> 批量操作 ▾ </Button>
|
||||
<template #overlay>
|
||||
<Menu @click="handleBatchMenuClick">
|
||||
<Menu.Item
|
||||
v-for="item in props.batchActionOptions"
|
||||
:key="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</template>
|
||||
</Dropdown>
|
||||
|
||||
<Button type="primary" @click="emit('add')">+ 添加商品</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="props.selectedCount > 0" class="product-action-selected">
|
||||
<span>已选择 {{ props.selectedCount }} 项</span>
|
||||
<Button type="link" @click="emit('clearSelection')">清空</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:商品分类侧栏。
|
||||
* 1. 展示分类与数量统计。
|
||||
* 2. 透出分类切换事件。
|
||||
*/
|
||||
import type { ProductCategorySidebarItem } from '../types';
|
||||
|
||||
import { Card, Empty, Spin } from 'ant-design-vue';
|
||||
|
||||
interface Props {
|
||||
categories: ProductCategorySidebarItem[];
|
||||
isLoading: boolean;
|
||||
selectedCategoryId: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'select', categoryId: string): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card :bordered="false" class="product-category-sidebar-card">
|
||||
<template #title>
|
||||
<span class="section-title">商品分类</span>
|
||||
</template>
|
||||
|
||||
<Spin :spinning="props.isLoading">
|
||||
<div v-if="props.categories.length > 0" class="product-category-sidebar">
|
||||
<button
|
||||
v-for="item in props.categories"
|
||||
:key="item.id || 'all'"
|
||||
type="button"
|
||||
class="product-category-item"
|
||||
:class="{ active: props.selectedCategoryId === item.id }"
|
||||
@click="emit('select', item.id)"
|
||||
>
|
||||
<span class="category-name">{{ item.name }}</span>
|
||||
<span class="category-count">{{ item.productCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Empty v-else description="暂无分类" />
|
||||
</Spin>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
/**
|
||||
* 文件职责:商品添加/编辑抽屉。
|
||||
* 1. 展示商品核心信息与上架方式表单。
|
||||
* 2. 通过回调更新父级状态并触发提交。
|
||||
*/
|
||||
import type { ProductEditorFormState } from '../types';
|
||||
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
Drawer,
|
||||
Input,
|
||||
InputNumber,
|
||||
Radio,
|
||||
Select,
|
||||
} from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface CategoryOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
categoryOptions: CategoryOption[];
|
||||
form: ProductEditorFormState;
|
||||
isSaving: boolean;
|
||||
onSetCategoryId: (value: string) => void;
|
||||
onSetDescription: (value: string) => void;
|
||||
onSetKind: (value: 'combo' | 'single') => void;
|
||||
onSetName: (value: string) => void;
|
||||
onSetOriginalPrice: (value: null | number) => void;
|
||||
onSetPrice: (value: number) => void;
|
||||
onSetShelfMode: (value: 'draft' | 'now' | 'scheduled') => void;
|
||||
onSetStock: (value: number) => void;
|
||||
onSetSubtitle: (value: string) => void;
|
||||
onSetTagsText: (value: string) => void;
|
||||
onSetTimedOnShelfAt: (value: string) => void;
|
||||
open: boolean;
|
||||
showDetailLink: boolean;
|
||||
submitText: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'detail'): void;
|
||||
(event: 'submit'): void;
|
||||
(event: 'update:open', value: boolean): void;
|
||||
}>();
|
||||
|
||||
/** 数值输入归一化。 */
|
||||
function toNumber(value: null | number | string, fallback = 0) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
/** 解析商品类型。 */
|
||||
function handleKindChange(value: unknown) {
|
||||
if (value === 'combo' || value === 'single') {
|
||||
props.onSetKind(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析分类选择。 */
|
||||
function handleCategoryChange(value: unknown) {
|
||||
if (typeof value === 'number' || typeof value === 'string') {
|
||||
props.onSetCategoryId(String(value));
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析上架方式。 */
|
||||
function handleShelfModeChange(value: unknown) {
|
||||
if (value === 'draft' || value === 'now' || value === 'scheduled') {
|
||||
props.onSetShelfMode(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析定时上架时间。 */
|
||||
function handleTimedOnShelfAtChange(value: Dayjs | null | undefined) {
|
||||
if (!value || !dayjs(value).isValid()) {
|
||||
props.onSetTimedOnShelfAt('');
|
||||
return;
|
||||
}
|
||||
props.onSetTimedOnShelfAt(dayjs(value).format('YYYY-MM-DD HH:mm:ss'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer
|
||||
class="product-editor-drawer"
|
||||
:open="props.open"
|
||||
:title="props.title"
|
||||
:width="560"
|
||||
:mask-closable="true"
|
||||
@update:open="(value) => emit('update:open', value)"
|
||||
>
|
||||
<div class="product-drawer-section">
|
||||
<div class="product-drawer-section-title">商品类型</div>
|
||||
<Radio.Group
|
||||
:value="props.form.kind"
|
||||
class="product-kind-radio-group"
|
||||
@update:value="(value) => handleKindChange(value)"
|
||||
>
|
||||
<Radio.Button value="single">单品</Radio.Button>
|
||||
<Radio.Button value="combo">套餐</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
<div class="product-drawer-section">
|
||||
<div class="product-drawer-section-title">商品信息</div>
|
||||
<div class="drawer-form-grid">
|
||||
<div class="drawer-form-item full">
|
||||
<label class="drawer-form-label required">商品名称</label>
|
||||
<Input
|
||||
:value="props.form.name"
|
||||
placeholder="请输入商品名称"
|
||||
@update:value="(value) => props.onSetName(String(value || ''))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="drawer-form-item">
|
||||
<label class="drawer-form-label required">分类</label>
|
||||
<Select
|
||||
:value="props.form.categoryId"
|
||||
:options="props.categoryOptions"
|
||||
placeholder="请选择分类"
|
||||
@update:value="(value) => handleCategoryChange(value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="drawer-form-item">
|
||||
<label class="drawer-form-label">副标题</label>
|
||||
<Input
|
||||
:value="props.form.subtitle"
|
||||
placeholder="选填"
|
||||
@update:value="(value) => props.onSetSubtitle(String(value || ''))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="drawer-form-item full">
|
||||
<label class="drawer-form-label">商品简介</label>
|
||||
<Input.TextArea
|
||||
:value="props.form.description"
|
||||
:rows="3"
|
||||
:maxlength="120"
|
||||
placeholder="请输入商品简介"
|
||||
@update:value="
|
||||
(value) => props.onSetDescription(String(value || ''))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-drawer-section">
|
||||
<div class="product-drawer-section-title">价格与库存</div>
|
||||
<div class="drawer-form-grid">
|
||||
<div class="drawer-form-item">
|
||||
<label class="drawer-form-label required">售价</label>
|
||||
<InputNumber
|
||||
:value="props.form.price"
|
||||
class="full-width"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="1"
|
||||
:controls="false"
|
||||
@update:value="
|
||||
(value) => props.onSetPrice(toNumber(value, props.form.price))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="drawer-form-item">
|
||||
<label class="drawer-form-label">原价</label>
|
||||
<InputNumber
|
||||
:value="props.form.originalPrice ?? undefined"
|
||||
class="full-width"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="1"
|
||||
:controls="false"
|
||||
placeholder="选填"
|
||||
@update:value="
|
||||
(value) =>
|
||||
props.onSetOriginalPrice(
|
||||
value === null || value === undefined
|
||||
? null
|
||||
: toNumber(value, 0),
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="drawer-form-item">
|
||||
<label class="drawer-form-label required">库存</label>
|
||||
<InputNumber
|
||||
:value="props.form.stock"
|
||||
class="full-width"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
:step="1"
|
||||
:controls="false"
|
||||
@update:value="
|
||||
(value) => props.onSetStock(toNumber(value, props.form.stock))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="drawer-form-item">
|
||||
<label class="drawer-form-label">标签</label>
|
||||
<Input
|
||||
:value="props.form.tagsText"
|
||||
placeholder="多个标签用英文逗号分隔"
|
||||
@update:value="(value) => props.onSetTagsText(String(value || ''))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-drawer-section">
|
||||
<div class="product-drawer-section-title">上架方式</div>
|
||||
<Radio.Group
|
||||
:value="props.form.shelfMode"
|
||||
class="product-shelf-radio-group"
|
||||
@update:value="(value) => handleShelfModeChange(value)"
|
||||
>
|
||||
<Radio class="shelf-radio-item" value="draft">存为草稿</Radio>
|
||||
<Radio class="shelf-radio-item" value="now">立即上架</Radio>
|
||||
<Radio class="shelf-radio-item" value="scheduled">定时上架</Radio>
|
||||
</Radio.Group>
|
||||
|
||||
<div v-if="props.form.shelfMode === 'scheduled'" class="shelf-time-row">
|
||||
<DatePicker
|
||||
:value="
|
||||
props.form.timedOnShelfAt
|
||||
? dayjs(props.form.timedOnShelfAt)
|
||||
: undefined
|
||||
"
|
||||
class="full-width"
|
||||
show-time
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="请选择上架时间"
|
||||
@update:value="
|
||||
(value) =>
|
||||
handleTimedOnShelfAtChange(value as Dayjs | null | undefined)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="product-drawer-footer">
|
||||
<Button v-if="props.showDetailLink" type="link" @click="emit('detail')">
|
||||
前往详情页
|
||||
</Button>
|
||||
<div class="product-drawer-footer-right">
|
||||
<Button @click="emit('update:open', false)">取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="props.isSaving"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
{{ props.submitText }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Drawer>
|
||||
</template>
|
||||
@@ -0,0 +1,148 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:商品列表顶部筛选条。
|
||||
* 1. 提供门店、关键词、状态、类型筛选。
|
||||
* 2. 提供卡片/列表视图切换。
|
||||
*/
|
||||
import type { ProductKind, ProductStatus } from '#/api/product';
|
||||
import type { ProductViewMode } from '#/views/product/list/types';
|
||||
|
||||
import { Button, Card, Input, Select } from 'ant-design-vue';
|
||||
|
||||
interface StoreOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface KindOption {
|
||||
label: string;
|
||||
value: '' | ProductKind;
|
||||
}
|
||||
|
||||
interface StatusOption {
|
||||
label: string;
|
||||
value: '' | ProductStatus;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isLoading: boolean;
|
||||
isStoreLoading: boolean;
|
||||
kind: '' | ProductKind;
|
||||
kindOptions: KindOption[];
|
||||
keyword: string;
|
||||
selectedStoreId: string;
|
||||
status: '' | ProductStatus;
|
||||
statusOptions: StatusOption[];
|
||||
storeOptions: StoreOption[];
|
||||
viewMode: ProductViewMode;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'reset'): void;
|
||||
(event: 'search'): void;
|
||||
(event: 'update:kind', value: '' | ProductKind): void;
|
||||
(event: 'update:keyword', value: string): void;
|
||||
(event: 'update:selectedStoreId', value: string): void;
|
||||
(event: 'update:status', value: '' | ProductStatus): void;
|
||||
(event: 'update:viewMode', value: ProductViewMode): void;
|
||||
}>();
|
||||
|
||||
/** 统一解析门店选择值。 */
|
||||
function handleStoreChange(value: unknown) {
|
||||
if (typeof value === 'number' || typeof value === 'string') {
|
||||
emit('update:selectedStoreId', String(value));
|
||||
return;
|
||||
}
|
||||
emit('update:selectedStoreId', '');
|
||||
}
|
||||
|
||||
/** 统一解析状态筛选值。 */
|
||||
function handleStatusChange(value: unknown) {
|
||||
if (typeof value === 'string') {
|
||||
emit('update:status', value as '' | ProductStatus);
|
||||
return;
|
||||
}
|
||||
emit('update:status', '');
|
||||
}
|
||||
|
||||
/** 统一解析类型筛选值。 */
|
||||
function handleKindChange(value: unknown) {
|
||||
if (typeof value === 'string') {
|
||||
emit('update:kind', value as '' | ProductKind);
|
||||
return;
|
||||
}
|
||||
emit('update:kind', '');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card :bordered="false" class="product-filter-toolbar-card">
|
||||
<div class="product-filter-toolbar">
|
||||
<Select
|
||||
:value="props.selectedStoreId"
|
||||
class="product-filter-select product-filter-store-select"
|
||||
:loading="props.isStoreLoading"
|
||||
:options="props.storeOptions"
|
||||
placeholder="请选择门店"
|
||||
:disabled="props.isStoreLoading || props.storeOptions.length === 0"
|
||||
@update:value="(value) => handleStoreChange(value)"
|
||||
/>
|
||||
|
||||
<Input
|
||||
:value="props.keyword"
|
||||
class="product-filter-input"
|
||||
allow-clear
|
||||
placeholder="搜索商品名称/编码"
|
||||
@press-enter="emit('search')"
|
||||
@update:value="(value) => emit('update:keyword', String(value || ''))"
|
||||
/>
|
||||
|
||||
<Select
|
||||
:value="props.status"
|
||||
class="product-filter-select"
|
||||
:options="props.statusOptions"
|
||||
@update:value="(value) => handleStatusChange(value)"
|
||||
/>
|
||||
|
||||
<Select
|
||||
:value="props.kind"
|
||||
class="product-filter-select"
|
||||
:options="props.kindOptions"
|
||||
@update:value="(value) => handleKindChange(value)"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="props.isLoading"
|
||||
:disabled="!props.selectedStoreId"
|
||||
@click="emit('search')"
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Button :disabled="props.isLoading" @click="emit('reset')">重置</Button>
|
||||
|
||||
<div class="product-filter-spacer"></div>
|
||||
|
||||
<div class="product-view-switch">
|
||||
<button
|
||||
type="button"
|
||||
class="product-view-btn"
|
||||
:class="{ active: props.viewMode === 'card' }"
|
||||
@click="emit('update:viewMode', 'card')"
|
||||
>
|
||||
卡片
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="product-view-btn"
|
||||
:class="{ active: props.viewMode === 'list' }"
|
||||
@click="emit('update:viewMode', 'list')"
|
||||
>
|
||||
列表
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,380 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:商品列表主区域。
|
||||
* 1. 根据视图模式渲染卡片或列表。
|
||||
* 2. 透出勾选、分页、编辑、详情、删除、状态动作事件。
|
||||
*/
|
||||
import type { ProductListItemDto, ProductStatus } from '#/api/product';
|
||||
import type {
|
||||
ProductPaginationChangePayload,
|
||||
ProductViewMode,
|
||||
} from '#/views/product/list/types';
|
||||
|
||||
import {
|
||||
Checkbox,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Menu,
|
||||
Pagination,
|
||||
Popconfirm,
|
||||
Spin,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
formatCurrency,
|
||||
formatStockText,
|
||||
resolveStatusMeta,
|
||||
resolveStockClass,
|
||||
} from '../composables/product-list-page/helpers';
|
||||
|
||||
interface Props {
|
||||
allChecked: boolean;
|
||||
indeterminate: boolean;
|
||||
isLoading: boolean;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
pageSizeOptions: string[];
|
||||
rows: ProductListItemDto[];
|
||||
selectedProductIds: string[];
|
||||
total: number;
|
||||
viewMode: ProductViewMode;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
event: 'changeStatus',
|
||||
payload: { item: ProductListItemDto; status: ProductStatus },
|
||||
): void;
|
||||
(event: 'delete', item: ProductListItemDto): void;
|
||||
(event: 'detail', item: ProductListItemDto): void;
|
||||
(event: 'edit', item: ProductListItemDto): void;
|
||||
(event: 'pageChange', payload: ProductPaginationChangePayload): void;
|
||||
(event: 'quickEdit', item: ProductListItemDto): void;
|
||||
(event: 'soldout', item: ProductListItemDto): void;
|
||||
(
|
||||
event: 'toggleSelect',
|
||||
payload: { checked: boolean; productId: string },
|
||||
): void;
|
||||
(event: 'toggleSelectAll', checked: boolean): void;
|
||||
}>();
|
||||
|
||||
/** 当前商品是否被勾选。 */
|
||||
function isChecked(productId: string) {
|
||||
return props.selectedProductIds.includes(productId);
|
||||
}
|
||||
|
||||
/** 解析“更多”动作菜单。 */
|
||||
function getMoreActionOptions(item: ProductListItemDto) {
|
||||
const actions: Array<{ key: string; label: string }> = [];
|
||||
if (item.status === 'on_sale') {
|
||||
actions.push({ key: 'off', label: '下架' });
|
||||
} else {
|
||||
actions.push({ key: 'on', label: '上架' });
|
||||
}
|
||||
if (item.status !== 'sold_out') {
|
||||
actions.push({ key: 'soldout', label: '沽清' });
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
/** 执行“更多”菜单动作。 */
|
||||
function handleMoreAction(
|
||||
item: ProductListItemDto,
|
||||
payload: { key: number | string },
|
||||
) {
|
||||
const key = String(payload.key);
|
||||
if (key === 'on') {
|
||||
emit('changeStatus', { item, status: 'on_sale' });
|
||||
return;
|
||||
}
|
||||
if (key === 'off') {
|
||||
emit('changeStatus', { item, status: 'off_shelf' });
|
||||
return;
|
||||
}
|
||||
if (key === 'soldout') {
|
||||
emit('soldout', item);
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理单项勾选变化。 */
|
||||
function handleSingleCheck(productId: string, event: unknown) {
|
||||
const checked = Boolean(
|
||||
(event as null | { target?: { checked?: boolean } })?.target?.checked,
|
||||
);
|
||||
emit('toggleSelect', { productId, checked });
|
||||
}
|
||||
|
||||
/** 处理全选变化。 */
|
||||
function handleCheckAll(event: unknown) {
|
||||
const checked = Boolean(
|
||||
(event as null | { target?: { checked?: boolean } })?.target?.checked,
|
||||
);
|
||||
emit('toggleSelectAll', checked);
|
||||
}
|
||||
|
||||
/** 处理分页变化。 */
|
||||
function handlePageChange(page: number, pageSize: number) {
|
||||
emit('pageChange', { page, pageSize });
|
||||
}
|
||||
|
||||
/** 处理分页尺寸变化。 */
|
||||
function handlePageSizeChange(current: number, size: number) {
|
||||
emit('pageChange', { page: current, pageSize: size });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Spin :spinning="props.isLoading">
|
||||
<Empty v-if="props.rows.length === 0" description="暂无商品,请先添加" />
|
||||
|
||||
<div v-else class="product-list-section">
|
||||
<div v-if="props.viewMode === 'card'" class="product-card-grid">
|
||||
<article
|
||||
v-for="item in props.rows"
|
||||
:key="item.id"
|
||||
class="product-card-item"
|
||||
:class="{
|
||||
'is-soldout': item.status === 'sold_out',
|
||||
'is-offshelf': item.status === 'off_shelf',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="product-card-status-ribbon"
|
||||
:style="{ background: resolveStatusMeta(item.status).color }"
|
||||
>
|
||||
{{ resolveStatusMeta(item.status).label }}
|
||||
</div>
|
||||
|
||||
<Checkbox
|
||||
class="product-card-check"
|
||||
:checked="isChecked(item.id)"
|
||||
@change="(event) => handleSingleCheck(item.id, event)"
|
||||
/>
|
||||
|
||||
<div class="product-card-cover">
|
||||
<span>{{ item.name.slice(0, 1) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="product-card-body">
|
||||
<div class="product-card-name">{{ item.name }}</div>
|
||||
<div class="product-card-subtitle">{{ item.subtitle || '--' }}</div>
|
||||
<div class="product-card-spu">{{ item.spuCode }}</div>
|
||||
|
||||
<div class="product-card-price-row">
|
||||
<span class="price-now">{{ formatCurrency(item.price) }}</span>
|
||||
<span v-if="item.originalPrice" class="price-old">
|
||||
{{ formatCurrency(item.originalPrice) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="product-card-meta">
|
||||
<span :class="resolveStockClass(item.stock)">
|
||||
{{ formatStockText(item.stock) }}
|
||||
</span>
|
||||
<span>月销 {{ item.salesMonthly }}</span>
|
||||
</div>
|
||||
|
||||
<div class="product-card-tags">
|
||||
<Tag v-for="tag in item.tags" :key="`${item.id}-${tag}`">
|
||||
{{ tag }}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-card-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="product-link-btn"
|
||||
@click="emit('edit', item)"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="product-link-btn"
|
||||
@click="emit('quickEdit', item)"
|
||||
>
|
||||
快编
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="product-link-btn"
|
||||
@click="emit('detail', item)"
|
||||
>
|
||||
详情
|
||||
</button>
|
||||
<Popconfirm
|
||||
title="确认删除该商品吗?"
|
||||
ok-text="确认"
|
||||
cancel-text="取消"
|
||||
@confirm="emit('delete', item)"
|
||||
>
|
||||
<button type="button" class="product-link-btn danger">
|
||||
删除
|
||||
</button>
|
||||
</Popconfirm>
|
||||
<Dropdown :trigger="['click']">
|
||||
<button type="button" class="product-link-btn">更多</button>
|
||||
<template #overlay>
|
||||
<Menu @click="(payload) => handleMoreAction(item, payload)">
|
||||
<Menu.Item
|
||||
v-for="action in getMoreActionOptions(item)"
|
||||
:key="action.key"
|
||||
>
|
||||
{{ action.label }}
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<div v-if="item.status === 'sold_out'" class="product-card-mask">
|
||||
售罄
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else class="product-list-table">
|
||||
<div class="product-list-header">
|
||||
<span class="cell check-cell">
|
||||
<Checkbox
|
||||
:checked="props.allChecked"
|
||||
:indeterminate="props.indeterminate"
|
||||
@change="(event) => handleCheckAll(event)"
|
||||
/>
|
||||
</span>
|
||||
<span class="cell">图片</span>
|
||||
<span class="cell">商品信息</span>
|
||||
<span class="cell">分类</span>
|
||||
<span class="cell">价格</span>
|
||||
<span class="cell">库存</span>
|
||||
<span class="cell">销量</span>
|
||||
<span class="cell">标签</span>
|
||||
<span class="cell">状态</span>
|
||||
<span class="cell">操作</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="item in props.rows"
|
||||
:key="item.id"
|
||||
class="product-list-row"
|
||||
:class="{
|
||||
'is-soldout': item.status === 'sold_out',
|
||||
'is-offshelf': item.status === 'off_shelf',
|
||||
}"
|
||||
>
|
||||
<span class="cell check-cell">
|
||||
<Checkbox
|
||||
:checked="isChecked(item.id)"
|
||||
@change="(event) => handleSingleCheck(item.id, event)"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span class="cell image-cell">
|
||||
<span class="list-cover">{{ item.name.slice(0, 1) }}</span>
|
||||
</span>
|
||||
|
||||
<span class="cell info-cell">
|
||||
<span class="name">{{ item.name }}</span>
|
||||
<span class="subtitle">{{ item.subtitle || '--' }}</span>
|
||||
<span class="spu">{{ item.spuCode }}</span>
|
||||
</span>
|
||||
|
||||
<span class="cell">{{ item.categoryName || '--' }}</span>
|
||||
|
||||
<span class="cell price-cell">
|
||||
<span class="price-now">{{ formatCurrency(item.price) }}</span>
|
||||
<span v-if="item.originalPrice" class="price-old">
|
||||
{{ formatCurrency(item.originalPrice) }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="cell">
|
||||
<span :class="resolveStockClass(item.stock)">
|
||||
{{ formatStockText(item.stock) }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="cell">月销 {{ item.salesMonthly }}</span>
|
||||
|
||||
<span class="cell tags-cell">
|
||||
<Tag v-for="tag in item.tags" :key="`${item.id}-${tag}`">
|
||||
{{ tag }}
|
||||
</Tag>
|
||||
</span>
|
||||
|
||||
<span class="cell">
|
||||
<span
|
||||
class="product-status-pill"
|
||||
:class="resolveStatusMeta(item.status).badgeClass"
|
||||
>
|
||||
{{ resolveStatusMeta(item.status).label }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span class="cell actions-cell">
|
||||
<button
|
||||
type="button"
|
||||
class="product-link-btn"
|
||||
@click="emit('edit', item)"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="product-link-btn"
|
||||
@click="emit('quickEdit', item)"
|
||||
>
|
||||
快编
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="product-link-btn"
|
||||
@click="emit('detail', item)"
|
||||
>
|
||||
详情
|
||||
</button>
|
||||
<Popconfirm
|
||||
title="确认删除该商品吗?"
|
||||
ok-text="确认"
|
||||
cancel-text="取消"
|
||||
@confirm="emit('delete', item)"
|
||||
>
|
||||
<button type="button" class="product-link-btn danger">
|
||||
删除
|
||||
</button>
|
||||
</Popconfirm>
|
||||
<Dropdown :trigger="['click']">
|
||||
<button type="button" class="product-link-btn">更多</button>
|
||||
<template #overlay>
|
||||
<Menu @click="(payload) => handleMoreAction(item, payload)">
|
||||
<Menu.Item
|
||||
v-for="action in getMoreActionOptions(item)"
|
||||
:key="action.key"
|
||||
>
|
||||
{{ action.label }}
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-pagination">
|
||||
<Pagination
|
||||
:current="props.page"
|
||||
:page-size="props.pageSize"
|
||||
:total="props.total"
|
||||
:page-size-options="props.pageSizeOptions"
|
||||
show-size-changer
|
||||
@change="handlePageChange"
|
||||
@show-size-change="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
</template>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:商品快速编辑抽屉。
|
||||
* 1. 快速调整价格、库存、在售状态。
|
||||
* 2. 支持跳转商品详情页。
|
||||
*/
|
||||
import type { ProductQuickEditFormState } from '../types';
|
||||
|
||||
import type { ProductListItemDto } from '#/api/product';
|
||||
|
||||
import { Button, Drawer, InputNumber, Switch } from 'ant-design-vue';
|
||||
|
||||
import { resolveStatusMeta } from '../composables/product-list-page/helpers';
|
||||
|
||||
interface Props {
|
||||
form: ProductQuickEditFormState;
|
||||
isSaving: boolean;
|
||||
onSetIsOnSale: (value: boolean) => void;
|
||||
onSetOriginalPrice: (value: null | number) => void;
|
||||
onSetPrice: (value: number) => void;
|
||||
onSetStock: (value: number) => void;
|
||||
open: boolean;
|
||||
product: null | ProductListItemDto;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'detail'): void;
|
||||
(event: 'submit'): void;
|
||||
(event: 'update:open', value: boolean): void;
|
||||
}>();
|
||||
|
||||
/** 数值输入归一化。 */
|
||||
function toNumber(value: null | number | string, fallback = 0) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer
|
||||
class="product-quick-edit-drawer"
|
||||
:open="props.open"
|
||||
title="快速编辑"
|
||||
:width="500"
|
||||
:mask-closable="true"
|
||||
@update:open="(value) => emit('update:open', value)"
|
||||
>
|
||||
<div v-if="props.product" class="product-quick-card">
|
||||
<div class="product-quick-cover">
|
||||
{{ props.product.name.slice(0, 1) }}
|
||||
</div>
|
||||
<div class="product-quick-meta">
|
||||
<div class="name">{{ props.product.name }}</div>
|
||||
<div class="sub">
|
||||
{{ props.product.spuCode }} · {{ props.product.categoryName }}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="product-status-pill"
|
||||
:class="resolveStatusMeta(props.product.status).badgeClass"
|
||||
>
|
||||
{{ resolveStatusMeta(props.product.status).label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="product-drawer-section">
|
||||
<div class="product-drawer-section-title">价格</div>
|
||||
<div class="drawer-form-grid">
|
||||
<div class="drawer-form-item">
|
||||
<label class="drawer-form-label">售价</label>
|
||||
<InputNumber
|
||||
:value="props.form.price"
|
||||
class="full-width"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="1"
|
||||
:controls="false"
|
||||
@update:value="
|
||||
(value) => props.onSetPrice(toNumber(value, props.form.price))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="drawer-form-item">
|
||||
<label class="drawer-form-label">原价</label>
|
||||
<InputNumber
|
||||
:value="props.form.originalPrice ?? undefined"
|
||||
class="full-width"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="1"
|
||||
:controls="false"
|
||||
placeholder="选填"
|
||||
@update:value="
|
||||
(value) =>
|
||||
props.onSetOriginalPrice(
|
||||
value === null || value === undefined
|
||||
? null
|
||||
: toNumber(value, 0),
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-drawer-section">
|
||||
<div class="product-drawer-section-title">库存</div>
|
||||
<div class="drawer-form-item compact">
|
||||
<InputNumber
|
||||
:value="props.form.stock"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
:step="1"
|
||||
:controls="false"
|
||||
class="stock-input"
|
||||
@update:value="
|
||||
(value) => props.onSetStock(toNumber(value, props.form.stock))
|
||||
"
|
||||
/>
|
||||
<span class="unit">份</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="product-drawer-section">
|
||||
<div class="product-drawer-section-title">商品状态</div>
|
||||
<div class="switch-row">
|
||||
<Switch
|
||||
:checked="props.form.isOnSale"
|
||||
checked-children="在售"
|
||||
un-checked-children="下架"
|
||||
@update:checked="(value) => props.onSetIsOnSale(Boolean(value))"
|
||||
/>
|
||||
<span class="hint">关闭后商品将下架</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="product-drawer-footer">
|
||||
<Button v-if="props.product" type="link" @click="emit('detail')">
|
||||
前往详情页
|
||||
</Button>
|
||||
<div class="product-drawer-footer-right">
|
||||
<Button @click="emit('update:open', false)">取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="props.isSaving"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
保存修改
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Drawer>
|
||||
</template>
|
||||
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
/**
|
||||
* 文件职责:商品沽清抽屉。
|
||||
* 1. 配置沽清方式、恢复时间、可售数量与通知选项。
|
||||
* 2. 透出提交流程与关闭事件。
|
||||
*/
|
||||
import type { ProductSoldoutFormState } from '../types';
|
||||
|
||||
import type { ProductListItemDto, ProductSoldoutMode } from '#/api/product';
|
||||
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
Drawer,
|
||||
Input,
|
||||
InputNumber,
|
||||
Radio,
|
||||
Switch,
|
||||
} from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { resolveStatusMeta } from '../composables/product-list-page/helpers';
|
||||
|
||||
interface Props {
|
||||
form: ProductSoldoutFormState;
|
||||
isSaving: boolean;
|
||||
onSetMode: (value: ProductSoldoutMode) => void;
|
||||
onSetNotifyManager: (value: boolean) => void;
|
||||
onSetReason: (value: string) => void;
|
||||
onSetRecoverAt: (value: string) => void;
|
||||
onSetRemainStock: (value: number) => void;
|
||||
onSetSyncToPlatform: (value: boolean) => void;
|
||||
open: boolean;
|
||||
product: null | ProductListItemDto;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'submit'): void;
|
||||
(event: 'update:open', value: boolean): void;
|
||||
}>();
|
||||
|
||||
/** 数值输入归一化。 */
|
||||
function toNumber(value: null | number | string, fallback = 0) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
/** 解析沽清方式。 */
|
||||
function handleModeChange(value: unknown) {
|
||||
if (value === 'today' || value === 'timed' || value === 'permanent') {
|
||||
props.onSetMode(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析恢复时间。 */
|
||||
function handleRecoverAtChange(value: Dayjs | null | undefined) {
|
||||
if (!value || !dayjs(value).isValid()) {
|
||||
props.onSetRecoverAt('');
|
||||
return;
|
||||
}
|
||||
props.onSetRecoverAt(dayjs(value).format('YYYY-MM-DD HH:mm:ss'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer
|
||||
class="product-soldout-drawer"
|
||||
:open="props.open"
|
||||
title="商品沽清"
|
||||
:width="460"
|
||||
:mask-closable="true"
|
||||
@update:open="(value) => emit('update:open', value)"
|
||||
>
|
||||
<div v-if="props.product" class="product-soldout-card">
|
||||
<div class="product-quick-cover">
|
||||
{{ props.product.name.slice(0, 1) }}
|
||||
</div>
|
||||
<div class="product-quick-meta">
|
||||
<div class="name">{{ props.product.name }}</div>
|
||||
<div class="sub">{{ props.product.spuCode }}</div>
|
||||
</div>
|
||||
<span
|
||||
class="product-status-pill"
|
||||
:class="resolveStatusMeta(props.product.status).badgeClass"
|
||||
>
|
||||
{{ resolveStatusMeta(props.product.status).label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="product-drawer-section">
|
||||
<div class="product-drawer-section-title">沽清方式</div>
|
||||
<Radio.Group
|
||||
:value="props.form.mode"
|
||||
class="soldout-mode-group"
|
||||
@update:value="(value) => handleModeChange(value)"
|
||||
>
|
||||
<Radio class="soldout-mode-item" value="today">今日沽清</Radio>
|
||||
<Radio class="soldout-mode-item" value="timed">定时沽清</Radio>
|
||||
<Radio class="soldout-mode-item" value="permanent">永久沽清</Radio>
|
||||
</Radio.Group>
|
||||
|
||||
<DatePicker
|
||||
v-if="props.form.mode === 'timed'"
|
||||
:value="props.form.recoverAt ? dayjs(props.form.recoverAt) : undefined"
|
||||
class="full-width"
|
||||
show-time
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="请选择恢复时间"
|
||||
@update:value="
|
||||
(value) => handleRecoverAtChange(value as Dayjs | null | undefined)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="product-drawer-section">
|
||||
<div class="product-drawer-section-title">剩余可售</div>
|
||||
<div class="drawer-form-item compact">
|
||||
<InputNumber
|
||||
:value="props.form.remainStock"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
:step="1"
|
||||
:controls="false"
|
||||
class="stock-input"
|
||||
@update:value="
|
||||
(value) =>
|
||||
props.onSetRemainStock(toNumber(value, props.form.remainStock))
|
||||
"
|
||||
/>
|
||||
<span class="unit">份</span>
|
||||
</div>
|
||||
<div class="hint">设为 0 表示完全沽清,不再接单</div>
|
||||
</div>
|
||||
|
||||
<div class="product-drawer-section">
|
||||
<div class="product-drawer-section-title">沽清原因</div>
|
||||
<Input.TextArea
|
||||
:value="props.form.reason"
|
||||
:rows="3"
|
||||
:maxlength="120"
|
||||
placeholder="选填,如:食材用完、设备检修等"
|
||||
@update:value="(value) => props.onSetReason(String(value || ''))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="product-drawer-section">
|
||||
<div class="product-drawer-section-title">通知设置</div>
|
||||
<div class="switch-row">
|
||||
<Switch
|
||||
:checked="props.form.syncToPlatform"
|
||||
@update:checked="(value) => props.onSetSyncToPlatform(Boolean(value))"
|
||||
/>
|
||||
<span>同步通知外卖平台</span>
|
||||
</div>
|
||||
<div class="switch-row">
|
||||
<Switch
|
||||
:checked="props.form.notifyManager"
|
||||
@update:checked="(value) => props.onSetNotifyManager(Boolean(value))"
|
||||
/>
|
||||
<span>通知店长(短信提醒)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="product-drawer-footer">
|
||||
<div class="product-drawer-footer-right">
|
||||
<Button @click="emit('update:open', false)">取消</Button>
|
||||
<Button
|
||||
danger
|
||||
type="primary"
|
||||
:loading="props.isSaving"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
确认沽清
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Drawer>
|
||||
</template>
|
||||
@@ -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<void>;
|
||||
selectedProductIds: Ref<string[]>;
|
||||
selectedStoreId: Ref<string>;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<ProductStatus, ProductStatusMeta> =
|
||||
{
|
||||
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' },
|
||||
];
|
||||
@@ -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<ProductCategoryDto[]>;
|
||||
filters: ProductFilterState;
|
||||
isCategoryLoading: Ref<boolean>;
|
||||
isListLoading: Ref<boolean>;
|
||||
isStoreLoading: Ref<boolean>;
|
||||
products: Ref<ProductListItemDto[]>;
|
||||
selectedStoreId: Ref<string>;
|
||||
stores: Ref<StoreListItemDto[]>;
|
||||
total: Ref<number>;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<null | ProductListItemDto>;
|
||||
currentSoldoutProduct: Ref<null | ProductListItemDto>;
|
||||
editorDrawerMode: Ref<ProductEditorDrawerMode>;
|
||||
editorForm: ProductEditorFormState;
|
||||
isEditorDrawerOpen: Ref<boolean>;
|
||||
isEditorSubmitting: Ref<boolean>;
|
||||
isQuickEditDrawerOpen: Ref<boolean>;
|
||||
isQuickEditSubmitting: Ref<boolean>;
|
||||
isSoldoutDrawerOpen: Ref<boolean>;
|
||||
isSoldoutSubmitting: Ref<boolean>;
|
||||
quickEditForm: ProductQuickEditFormState;
|
||||
reloadCurrentStoreData: () => Promise<void>;
|
||||
selectedStoreId: Ref<string>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<void>;
|
||||
selectedProductIds: Ref<string[]>;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<StoreListItemDto[]>([]);
|
||||
const selectedStoreId = ref('');
|
||||
const categories = ref<ProductCategoryDto[]>([]);
|
||||
const products = ref<ProductListItemDto[]>([]);
|
||||
const total = ref(0);
|
||||
|
||||
const filters = reactive<ProductFilterState>(
|
||||
cloneFilterState(DEFAULT_FILTER_STATE),
|
||||
);
|
||||
const viewMode = ref<'card' | 'list'>('list');
|
||||
const selectedProductIds = ref<string[]>([]);
|
||||
|
||||
// 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 | ProductListItemDto>(null);
|
||||
|
||||
const isSoldoutDrawerOpen = ref(false);
|
||||
const soldoutForm = reactive(cloneSoldoutForm(DEFAULT_SOLDOUT_FORM));
|
||||
const currentSoldoutProduct = ref<null | ProductListItemDto>(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,
|
||||
};
|
||||
}
|
||||
253
apps/web-antd/src/views/product/list/index.vue
Normal file
253
apps/web-antd/src/views/product/list/index.vue
Normal file
@@ -0,0 +1,253 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 文件职责:商品列表页面主视图。
|
||||
* 1. 组装分类侧栏、筛选条、动作条、列表区。
|
||||
* 2. 挂载添加/编辑、快速编辑、沽清抽屉。
|
||||
*/
|
||||
import type { ProductBatchAction } from './types';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Card, Empty } from 'ant-design-vue';
|
||||
|
||||
import ProductActionBar from './components/ProductActionBar.vue';
|
||||
import ProductCategorySidebar from './components/ProductCategorySidebar.vue';
|
||||
import ProductEditorDrawer from './components/ProductEditorDrawer.vue';
|
||||
import ProductFilterToolbar from './components/ProductFilterToolbar.vue';
|
||||
import ProductListSection from './components/ProductListSection.vue';
|
||||
import ProductQuickEditDrawer from './components/ProductQuickEditDrawer.vue';
|
||||
import ProductSoldoutDrawer from './components/ProductSoldoutDrawer.vue';
|
||||
import { PRODUCT_BATCH_ACTION_OPTIONS } from './composables/product-list-page/constants';
|
||||
import { useProductListPage } from './composables/useProductListPage';
|
||||
|
||||
const {
|
||||
PAGE_SIZE_OPTIONS,
|
||||
PRODUCT_KIND_OPTIONS,
|
||||
PRODUCT_STATUS_OPTIONS,
|
||||
applySearchFilters,
|
||||
categoryOptions,
|
||||
categorySidebarItems,
|
||||
clearSelection,
|
||||
deleteProduct,
|
||||
editorDrawerMode,
|
||||
editorDrawerTitle,
|
||||
editorForm,
|
||||
editorSubmitText,
|
||||
filters,
|
||||
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,
|
||||
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,
|
||||
} = useProductListPage();
|
||||
|
||||
/** 快速编辑抽屉跳转详情。 */
|
||||
function handleQuickEditDetail() {
|
||||
if (!quickEditSummary.value) return;
|
||||
openProductDetail(quickEditSummary.value);
|
||||
}
|
||||
|
||||
/** 添加/编辑抽屉跳转详情。 */
|
||||
function handleEditorDetail() {
|
||||
if (!editorForm.id) return;
|
||||
openProductDetailById(editorForm.id);
|
||||
}
|
||||
|
||||
/** 批量动作透传。 */
|
||||
function onBatchAction(action: ProductBatchAction) {
|
||||
handleBatchCommand(action);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page title="商品列表" content-class="space-y-4 page-product-list">
|
||||
<template v-if="storeOptions.length === 0">
|
||||
<Card :bordered="false">
|
||||
<Empty description="暂无门店,请先创建门店" />
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="product-page-layout">
|
||||
<ProductCategorySidebar
|
||||
:categories="categorySidebarItems"
|
||||
:selected-category-id="filters.categoryId"
|
||||
:is-loading="isCategoryLoading"
|
||||
@select="setSelectedCategoryId"
|
||||
/>
|
||||
|
||||
<div class="product-page-content">
|
||||
<ProductFilterToolbar
|
||||
:selected-store-id="selectedStoreId"
|
||||
:store-options="storeOptions"
|
||||
:is-store-loading="isStoreLoading"
|
||||
:keyword="filters.keyword"
|
||||
:status="filters.status"
|
||||
:kind="filters.kind"
|
||||
:status-options="PRODUCT_STATUS_OPTIONS"
|
||||
:kind-options="PRODUCT_KIND_OPTIONS"
|
||||
:view-mode="viewMode"
|
||||
:is-loading="isListLoading"
|
||||
@update:selected-store-id="setSelectedStoreId"
|
||||
@update:keyword="setFilterKeyword"
|
||||
@update:status="setFilterStatus"
|
||||
@update:kind="setFilterKind"
|
||||
@update:view-mode="setViewMode"
|
||||
@search="applySearchFilters"
|
||||
@reset="resetSearchFilters"
|
||||
/>
|
||||
|
||||
<ProductActionBar
|
||||
:selected-count="selectedCount"
|
||||
:batch-disabled="selectedCount === 0"
|
||||
:batch-action-options="PRODUCT_BATCH_ACTION_OPTIONS"
|
||||
@add="openCreateProductDrawer"
|
||||
@batch-action="onBatchAction"
|
||||
@clear-selection="clearSelection"
|
||||
/>
|
||||
|
||||
<ProductListSection
|
||||
:rows="products"
|
||||
:view-mode="viewMode"
|
||||
:selected-product-ids="selectedProductIds"
|
||||
:is-loading="isListLoading"
|
||||
:all-checked="isAllCurrentPageChecked"
|
||||
:indeterminate="isCurrentPageIndeterminate"
|
||||
:page="filters.page"
|
||||
:page-size="filters.pageSize"
|
||||
:page-size-options="PAGE_SIZE_OPTIONS"
|
||||
:total="total"
|
||||
@toggle-select="handleToggleSelect"
|
||||
@toggle-select-all="handleToggleSelectAll"
|
||||
@page-change="handlePageChange"
|
||||
@edit="openEditDrawer"
|
||||
@quick-edit="openQuickEditDrawer"
|
||||
@detail="openProductDetail"
|
||||
@delete="deleteProduct"
|
||||
@change-status="handleSingleStatusChange"
|
||||
@soldout="openSoldoutDrawer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ProductEditorDrawer
|
||||
:open="isEditorDrawerOpen"
|
||||
:title="editorDrawerTitle"
|
||||
:submit-text="editorSubmitText"
|
||||
:show-detail-link="editorDrawerMode === 'edit'"
|
||||
:form="editorForm"
|
||||
:category-options="categoryOptions"
|
||||
:is-saving="isEditorSubmitting"
|
||||
:on-set-name="setEditorName"
|
||||
:on-set-subtitle="setEditorSubtitle"
|
||||
:on-set-category-id="setEditorCategoryId"
|
||||
:on-set-description="setEditorDescription"
|
||||
:on-set-kind="setEditorKind"
|
||||
:on-set-price="setEditorPrice"
|
||||
:on-set-original-price="setEditorOriginalPrice"
|
||||
:on-set-stock="setEditorStock"
|
||||
:on-set-tags-text="setEditorTagsText"
|
||||
:on-set-shelf-mode="setEditorShelfMode"
|
||||
:on-set-timed-on-shelf-at="setEditorTimedOnShelfAt"
|
||||
@update:open="setEditorDrawerOpen"
|
||||
@submit="submitEditor"
|
||||
@detail="handleEditorDetail"
|
||||
/>
|
||||
|
||||
<ProductQuickEditDrawer
|
||||
:open="isQuickEditDrawerOpen"
|
||||
:form="quickEditForm"
|
||||
:product="quickEditSummary"
|
||||
:is-saving="isQuickEditSubmitting"
|
||||
:on-set-price="setQuickEditPrice"
|
||||
:on-set-original-price="setQuickEditOriginalPrice"
|
||||
:on-set-stock="setQuickEditStock"
|
||||
:on-set-is-on-sale="setQuickEditOnSale"
|
||||
@update:open="setQuickEditDrawerOpen"
|
||||
@submit="submitQuickEdit"
|
||||
@detail="handleQuickEditDetail"
|
||||
/>
|
||||
|
||||
<ProductSoldoutDrawer
|
||||
:open="isSoldoutDrawerOpen"
|
||||
:form="soldoutForm"
|
||||
:product="soldoutSummary"
|
||||
:is-saving="isSoldoutSubmitting"
|
||||
:on-set-mode="setSoldoutMode"
|
||||
:on-set-remain-stock="setSoldoutRemainStock"
|
||||
:on-set-reason="setSoldoutReason"
|
||||
:on-set-recover-at="setSoldoutRecoverAt"
|
||||
:on-set-sync-to-platform="setSoldoutSyncToPlatform"
|
||||
:on-set-notify-manager="setSoldoutNotifyManager"
|
||||
@update:open="setSoldoutDrawerOpen"
|
||||
@submit="submitSoldout"
|
||||
/>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style lang="less">
|
||||
@import './styles/index.less';
|
||||
</style>
|
||||
24
apps/web-antd/src/views/product/list/styles/actionbar.less
Normal file
24
apps/web-antd/src/views/product/list/styles/actionbar.less
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
28
apps/web-antd/src/views/product/list/styles/base.less
Normal file
28
apps/web-antd/src/views/product/list/styles/base.less
Normal file
@@ -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%;
|
||||
}
|
||||
207
apps/web-antd/src/views/product/list/styles/drawer.less
Normal file
207
apps/web-antd/src/views/product/list/styles/drawer.less
Normal file
@@ -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;
|
||||
}
|
||||
8
apps/web-antd/src/views/product/list/styles/index.less
Normal file
8
apps/web-antd/src/views/product/list/styles/index.less
Normal file
@@ -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';
|
||||
347
apps/web-antd/src/views/product/list/styles/list.less
Normal file
347
apps/web-antd/src/views/product/list/styles/list.less
Normal file
@@ -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;
|
||||
}
|
||||
77
apps/web-antd/src/views/product/list/styles/responsive.less
Normal file
77
apps/web-antd/src/views/product/list/styles/responsive.less
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
68
apps/web-antd/src/views/product/list/styles/sidebar.less
Normal file
68
apps/web-antd/src/views/product/list/styles/sidebar.less
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
54
apps/web-antd/src/views/product/list/styles/toolbar.less
Normal file
54
apps/web-antd/src/views/product/list/styles/toolbar.less
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
86
apps/web-antd/src/views/product/list/types.ts
Normal file
86
apps/web-antd/src/views/product/list/types.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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<Props>();
|
||||
@@ -22,6 +23,8 @@ const props = defineProps<Props>();
|
||||
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;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -87,34 +84,49 @@ const radiusLabels = computed(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="delivery-map-area">
|
||||
<span class="map-grid grid-h map-grid-h-1"></span>
|
||||
<span class="map-grid grid-h map-grid-h-2"></span>
|
||||
<span class="map-grid grid-h map-grid-h-3"></span>
|
||||
<span class="map-grid grid-v map-grid-v-1"></span>
|
||||
<span class="map-grid grid-v map-grid-v-2"></span>
|
||||
<span class="map-grid grid-v map-grid-v-3"></span>
|
||||
|
||||
<span class="map-pin">●</span>
|
||||
|
||||
<template v-if="props.configMode === 'radius'">
|
||||
<span class="radius-circle radius-3">
|
||||
<span class="radius-label">{{ radiusLabels[2] }}</span>
|
||||
</span>
|
||||
<span class="radius-circle radius-2">
|
||||
<span class="radius-label">{{ radiusLabels[1] }}</span>
|
||||
</span>
|
||||
<span class="radius-circle radius-1">
|
||||
<span class="radius-label">{{ radiusLabels[0] }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div v-else class="polygon-hint">
|
||||
<div class="polygon-hint-title">多边形区域模式</div>
|
||||
<div class="polygon-hint-desc">
|
||||
点击“绘制新区域”后可在地图上框选配送范围
|
||||
<div v-if="props.configMode === 'radius'" class="radius-center-panel">
|
||||
<div class="radius-center-title">半径配送中心点</div>
|
||||
<div class="radius-center-grid">
|
||||
<div class="radius-center-field">
|
||||
<label>纬度</label>
|
||||
<InputNumber
|
||||
:value="props.radiusCenterLatitude ?? undefined"
|
||||
:min="-90"
|
||||
:max="90"
|
||||
:precision="7"
|
||||
:step="0.000001"
|
||||
:controls="false"
|
||||
placeholder="如:39.9042000"
|
||||
class="field-input"
|
||||
@update:value="
|
||||
(value) => emit('changeRadiusCenterLatitude', toNumber(value))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="radius-center-field">
|
||||
<label>经度</label>
|
||||
<InputNumber
|
||||
:value="props.radiusCenterLongitude ?? undefined"
|
||||
:min="-180"
|
||||
:max="180"
|
||||
:precision="7"
|
||||
:step="0.000001"
|
||||
:controls="false"
|
||||
placeholder="如:116.4074000"
|
||||
class="field-input"
|
||||
@update:value="
|
||||
(value) => emit('changeRadiusCenterLongitude', toNumber(value))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="radius-center-hint">
|
||||
请输入配送中心点经纬度;半径梯度将基于该点计算可配送范围。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="polygon-mode-hint">
|
||||
当前为按区域配送(多边形)配置视图,无需设置半径中心点。
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,731 @@
|
||||
<script setup lang="ts">
|
||||
import type { LngLatTuple } from '../composables/delivery-page/geojson';
|
||||
|
||||
/**
|
||||
* 文件职责:配送区域地图绘制弹窗。
|
||||
* 1. 使用腾讯地图 GeometryEditor 提供标准的多边形绘制交互。
|
||||
* 2. 每个区域仅保留一块独立多边形,确认后回传 GeoJSON。
|
||||
*/
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import { Alert, Button, message, Modal, Space } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
parsePolygonGeoJson,
|
||||
stringifyPolygonGeoJson,
|
||||
} from '../composables/delivery-page/geojson';
|
||||
import { geocodeAddressToLngLat } from '../composables/useTencentGeocoder';
|
||||
import { loadTencentMapSdk } from '../composables/useTencentMapLoader';
|
||||
|
||||
interface Props {
|
||||
fallbackCityText: string;
|
||||
initialCenterAddress: string;
|
||||
initialCenterLatitude: null | number;
|
||||
initialCenterLongitude: null | number;
|
||||
initialGeoJson: string;
|
||||
open: boolean;
|
||||
zoneColor: string;
|
||||
}
|
||||
|
||||
type LatLngLiteral = { lat: number; lng: number };
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'confirm', geoJson: string): void;
|
||||
(event: 'update:open', value: boolean): void;
|
||||
}>();
|
||||
|
||||
const DEFAULT_CENTER: LngLatTuple = [116.397_428, 39.909_23];
|
||||
const MAP_MODAL_Z_INDEX = 10_000;
|
||||
const MAP_DEBUG_PREFIX = '[TenantUI-DeliveryMap]';
|
||||
|
||||
const mapContainerRef = ref<HTMLDivElement | null>(null);
|
||||
const mapInstance = shallowRef<any>(null);
|
||||
const polygonLayer = shallowRef<any>(null);
|
||||
const geometryEditor = shallowRef<any>(null);
|
||||
|
||||
const isMapLoading = ref(false);
|
||||
const mapError = ref('');
|
||||
const isDrawing = ref(false);
|
||||
const polygonCount = ref(0);
|
||||
const latestGeoJson = ref('');
|
||||
|
||||
const hasPolygon = computed(() => polygonCount.value > 0);
|
||||
const canStartDrawing = computed(
|
||||
() =>
|
||||
!isMapLoading.value &&
|
||||
!mapError.value &&
|
||||
!!mapInstance.value &&
|
||||
!!geometryEditor.value,
|
||||
);
|
||||
|
||||
function logMapDebug(step: string, payload?: unknown) {
|
||||
if (!import.meta.env.DEV) return;
|
||||
if (payload === undefined) {
|
||||
console.warn(MAP_DEBUG_PREFIX, step);
|
||||
return;
|
||||
}
|
||||
console.warn(MAP_DEBUG_PREFIX, step, payload);
|
||||
}
|
||||
|
||||
function refreshMapViewport(center?: LngLatTuple) {
|
||||
const TMap = window.TMap;
|
||||
if (!mapInstance.value) return;
|
||||
|
||||
// 弹窗二次打开时,地图容器尺寸可能在过渡动画中变化,手动触发重排避免灰底图。
|
||||
if (typeof mapInstance.value.resize === 'function') {
|
||||
mapInstance.value.resize();
|
||||
}
|
||||
|
||||
if (
|
||||
center &&
|
||||
TMap?.LatLng &&
|
||||
typeof mapInstance.value.setCenter === 'function'
|
||||
) {
|
||||
mapInstance.value.setCenter(new TMap.LatLng(center[1], center[0]));
|
||||
}
|
||||
|
||||
forceMapRepaint(center);
|
||||
}
|
||||
|
||||
function forceMapRepaint(center?: LngLatTuple) {
|
||||
const TMap = window.TMap;
|
||||
const map = mapInstance.value;
|
||||
if (!map) return;
|
||||
|
||||
if (center && TMap?.LatLng && typeof map.panTo === 'function') {
|
||||
map.panTo(new TMap.LatLng(center[1], center[0]));
|
||||
}
|
||||
|
||||
if (typeof map.getZoom === 'function' && typeof map.setZoom === 'function') {
|
||||
const currentZoom = Number(map.getZoom());
|
||||
if (Number.isFinite(currentZoom)) {
|
||||
const bumpedZoom = Math.min(20, Math.max(3, currentZoom + 1));
|
||||
map.setZoom(bumpedZoom);
|
||||
window.setTimeout(() => {
|
||||
if (!props.open || map !== mapInstance.value) return;
|
||||
map.setZoom(currentZoom);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForMapContainerReady() {
|
||||
const maxAttempts = 20;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
await nextTick();
|
||||
const container = mapContainerRef.value;
|
||||
if (container && container.clientWidth > 0 && container.clientHeight > 0) {
|
||||
return container;
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 40));
|
||||
}
|
||||
|
||||
return mapContainerRef.value;
|
||||
}
|
||||
|
||||
function toLayerPoint([lng, lat]: LngLatTuple): any | LatLngLiteral {
|
||||
const TMap = window.TMap;
|
||||
if (TMap?.LatLng) {
|
||||
return new TMap.LatLng(lat, lng);
|
||||
}
|
||||
return { lat, lng };
|
||||
}
|
||||
|
||||
function resolvePoint(point: any): LngLatTuple | null {
|
||||
if (!point) return null;
|
||||
const lng =
|
||||
typeof point.getLng === 'function' ? point.getLng() : Number(point.lng);
|
||||
const lat =
|
||||
typeof point.getLat === 'function' ? point.getLat() : Number(point.lat);
|
||||
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null;
|
||||
if (lng < -180 || lng > 180 || lat < -90 || lat > 90) return null;
|
||||
return [lng, lat];
|
||||
}
|
||||
|
||||
function normalizePolygon(points: LngLatTuple[]) {
|
||||
if (points.length < 3) return [] as LngLatTuple[];
|
||||
const normalized = points.map(([lng, lat]) => [lng, lat] as LngLatTuple);
|
||||
const first = normalized[0];
|
||||
const last = normalized[normalized.length - 1];
|
||||
if (!first || !last) return [] as LngLatTuple[];
|
||||
if (first[0] !== last[0] || first[1] !== last[1]) {
|
||||
normalized.push([first[0], first[1]]);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function unwrapPolygonPath(paths: any): any[] {
|
||||
if (!Array.isArray(paths) || paths.length === 0) return [];
|
||||
return Array.isArray(paths[0]) ? (paths[0] as any[]) : (paths as any[]);
|
||||
}
|
||||
|
||||
function buildGeometry(points: LngLatTuple[], id = 'polygon-1') {
|
||||
return {
|
||||
id,
|
||||
styleId: 'highlight',
|
||||
// MultiPolygon 单个区域也使用“二维 paths(外环)”结构,避免编辑器回填后丢失图形。
|
||||
paths: [points.map((point) => toLayerPoint(point))],
|
||||
};
|
||||
}
|
||||
|
||||
function setRawGeometries(geometries: any[]) {
|
||||
if (
|
||||
!polygonLayer.value ||
|
||||
typeof polygonLayer.value.setGeometries !== 'function'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
polygonLayer.value.setGeometries(geometries);
|
||||
polygonCount.value = geometries.length;
|
||||
}
|
||||
|
||||
function setPolygonToLayer(points: LngLatTuple[] | null) {
|
||||
if (!points) {
|
||||
setRawGeometries([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const ring = normalizePolygon(points);
|
||||
if (ring.length < 4) {
|
||||
setRawGeometries([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setRawGeometries([buildGeometry(ring)]);
|
||||
}
|
||||
|
||||
function getMapCenterFromGeoJson(raw: string): LngLatTuple | null {
|
||||
const parsed = parsePolygonGeoJson(raw);
|
||||
const firstPoint = parsed[0]?.[0];
|
||||
if (!firstPoint) return null;
|
||||
// 0,0 常见于后端空坐标默认值,作为无效点处理。
|
||||
if (firstPoint[0] === 0 && firstPoint[1] === 0) return null;
|
||||
return firstPoint ?? null;
|
||||
}
|
||||
|
||||
function getMapCenterFromCoordinates() {
|
||||
const lng = Number(props.initialCenterLongitude);
|
||||
const lat = Number(props.initialCenterLatitude);
|
||||
if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null;
|
||||
if (lng < -180 || lng > 180 || lat < -90 || lat > 90) return null;
|
||||
// 0,0 常见于后端空坐标默认值,作为无效点处理。
|
||||
if (lng === 0 && lat === 0) return null;
|
||||
return [lng, lat] as LngLatTuple;
|
||||
}
|
||||
|
||||
async function resolveInitialCenter() {
|
||||
const centerFromGeoJson = getMapCenterFromGeoJson(props.initialGeoJson);
|
||||
if (centerFromGeoJson) {
|
||||
return { center: centerFromGeoJson, shouldResolveAsync: false };
|
||||
}
|
||||
|
||||
const centerFromCoordinates = getMapCenterFromCoordinates();
|
||||
if (centerFromCoordinates) {
|
||||
return { center: centerFromCoordinates, shouldResolveAsync: false };
|
||||
}
|
||||
|
||||
// 优先保证地图首屏可见,地址编码异步纠偏中心点,避免白屏等待。
|
||||
return { center: DEFAULT_CENTER, shouldResolveAsync: true };
|
||||
}
|
||||
|
||||
async function geocodeAddressWithTimeout(address: string, timeoutMs: number) {
|
||||
const timeoutPromise = new Promise<LngLatTuple | null>((resolve) => {
|
||||
window.setTimeout(() => resolve(null), timeoutMs);
|
||||
});
|
||||
|
||||
return Promise.race([geocodeAddressToLngLat(address), timeoutPromise]);
|
||||
}
|
||||
|
||||
async function resolveAsyncCenterByAddress() {
|
||||
const centerFromStoreAddress = await geocodeAddressWithTimeout(
|
||||
props.initialCenterAddress,
|
||||
1800,
|
||||
);
|
||||
if (centerFromStoreAddress) return centerFromStoreAddress;
|
||||
|
||||
const centerFromMerchantCity = await geocodeAddressWithTimeout(
|
||||
props.fallbackCityText,
|
||||
1800,
|
||||
);
|
||||
if (centerFromMerchantCity) return centerFromMerchantCity;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function createHighlightPolygonStyle(TMap: any) {
|
||||
return new TMap.PolygonStyle({
|
||||
color: 'rgba(24, 144, 255, 0.28)',
|
||||
showBorder: true,
|
||||
borderWidth: 2,
|
||||
borderColor: '#1d4ed8',
|
||||
});
|
||||
}
|
||||
|
||||
function setEditorActionMode(mode: 'DRAW' | 'INTERACT') {
|
||||
const TMap = window.TMap;
|
||||
if (!TMap?.tools?.constants?.EDITOR_ACTION || !geometryEditor.value) return;
|
||||
|
||||
const actionMode = TMap.tools.constants.EDITOR_ACTION[mode];
|
||||
if (actionMode === undefined) return;
|
||||
|
||||
if (
|
||||
mode === 'DRAW' &&
|
||||
typeof geometryEditor.value.setActiveOverlay === 'function'
|
||||
) {
|
||||
geometryEditor.value.setActiveOverlay('polygon-group');
|
||||
}
|
||||
|
||||
geometryEditor.value.setActionMode(actionMode);
|
||||
}
|
||||
|
||||
function geometryToPolygon(geometry: any) {
|
||||
const pathList = unwrapPolygonPath(geometry?.paths);
|
||||
if (!Array.isArray(pathList) || pathList.length < 3) return null;
|
||||
|
||||
const points = pathList
|
||||
.map((point) => resolvePoint(point))
|
||||
.filter((point): point is LngLatTuple => !!point);
|
||||
const ring = normalizePolygon(points);
|
||||
if (ring.length < 4) return null;
|
||||
return ring;
|
||||
}
|
||||
|
||||
function syncGeoJsonFromGeometry(eventGeometry?: any) {
|
||||
// 与 AdminUI 对齐:优先使用事件 geometry,避免依赖 overlay 的异步落盘时序。
|
||||
const fromEvent = geometryToPolygon(eventGeometry);
|
||||
|
||||
let fromLayer: LngLatTuple[] | null = null;
|
||||
if (!fromEvent) {
|
||||
const layerGeometries =
|
||||
typeof polygonLayer.value?.getGeometries === 'function'
|
||||
? polygonLayer.value.getGeometries()
|
||||
: polygonLayer.value?.geometries;
|
||||
const firstGeometry = Array.isArray(layerGeometries)
|
||||
? layerGeometries[0]
|
||||
: null;
|
||||
fromLayer = geometryToPolygon(firstGeometry);
|
||||
}
|
||||
|
||||
const polygon = fromEvent ?? fromLayer;
|
||||
if (!polygon) {
|
||||
latestGeoJson.value = '';
|
||||
polygonCount.value = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
latestGeoJson.value = stringifyPolygonGeoJson([polygon]);
|
||||
polygonCount.value = 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
function onDrawComplete(geometry?: any) {
|
||||
const synced = syncGeoJsonFromGeometry(geometry);
|
||||
if (!synced && typeof window !== 'undefined') {
|
||||
// draw_complete 可能先于图层状态可读,延后一拍兜底读取。
|
||||
window.setTimeout(() => {
|
||||
const recovered = syncGeoJsonFromGeometry();
|
||||
if (!recovered) {
|
||||
message.warning('未识别到有效区域,请重新绘制');
|
||||
return;
|
||||
}
|
||||
isDrawing.value = false;
|
||||
setEditorActionMode('INTERACT');
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!synced) {
|
||||
message.warning('未识别到有效区域,请重新绘制');
|
||||
return;
|
||||
}
|
||||
|
||||
isDrawing.value = false;
|
||||
setEditorActionMode('INTERACT');
|
||||
}
|
||||
|
||||
function onAdjustComplete(geometry?: any) {
|
||||
void syncGeoJsonFromGeometry(geometry);
|
||||
}
|
||||
|
||||
function onDeleteComplete() {
|
||||
latestGeoJson.value = '';
|
||||
polygonCount.value = 0;
|
||||
isDrawing.value = false;
|
||||
setEditorActionMode('INTERACT');
|
||||
}
|
||||
|
||||
function destroyEditor() {
|
||||
if (
|
||||
geometryEditor.value &&
|
||||
typeof geometryEditor.value.destroy === 'function'
|
||||
) {
|
||||
geometryEditor.value.destroy();
|
||||
}
|
||||
geometryEditor.value = null;
|
||||
}
|
||||
|
||||
function destroyMapResources() {
|
||||
destroyEditor();
|
||||
|
||||
if (polygonLayer.value && typeof polygonLayer.value.setMap === 'function') {
|
||||
polygonLayer.value.setMap(null);
|
||||
}
|
||||
polygonLayer.value = null;
|
||||
|
||||
if (mapInstance.value && typeof mapInstance.value.destroy === 'function') {
|
||||
mapInstance.value.destroy();
|
||||
}
|
||||
mapInstance.value = null;
|
||||
}
|
||||
|
||||
function createEditor() {
|
||||
const TMap = window.TMap;
|
||||
if (!TMap?.tools?.GeometryEditor || !mapInstance.value || !polygonLayer.value)
|
||||
return;
|
||||
|
||||
destroyEditor();
|
||||
geometryEditor.value = new TMap.tools.GeometryEditor({
|
||||
map: mapInstance.value,
|
||||
overlayList: [
|
||||
{
|
||||
id: 'polygon-group',
|
||||
overlay: polygonLayer.value,
|
||||
selectedStyleId: 'highlight',
|
||||
drawingStyleId: 'highlight',
|
||||
},
|
||||
],
|
||||
activeOverlayId: 'polygon-group',
|
||||
actionMode: TMap.tools.constants.EDITOR_ACTION.INTERACT,
|
||||
selectable: true,
|
||||
snappable: true,
|
||||
});
|
||||
|
||||
geometryEditor.value.on('draw_complete', onDrawComplete);
|
||||
geometryEditor.value.on('adjust_complete', onAdjustComplete);
|
||||
geometryEditor.value.on('delete_complete', onDeleteComplete);
|
||||
}
|
||||
|
||||
function fitMapToPolygon(points: LngLatTuple[]) {
|
||||
const TMap = window.TMap;
|
||||
if (
|
||||
!TMap?.LatLngBounds ||
|
||||
!mapInstance.value ||
|
||||
typeof mapInstance.value.fitBounds !== 'function'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ring = normalizePolygon(points);
|
||||
if (ring.length < 4) return;
|
||||
|
||||
const bounds = new TMap.LatLngBounds();
|
||||
ring.forEach(([lng, lat]) => {
|
||||
bounds.extend(new TMap.LatLng(lat, lng));
|
||||
});
|
||||
mapInstance.value.fitBounds(bounds, { padding: 50 });
|
||||
}
|
||||
|
||||
function loadInitialPolygon() {
|
||||
const initialPolygons = parsePolygonGeoJson(props.initialGeoJson);
|
||||
const first = initialPolygons[0];
|
||||
if (!first) {
|
||||
setRawGeometries([]);
|
||||
latestGeoJson.value = '';
|
||||
polygonCount.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
setPolygonToLayer(first);
|
||||
latestGeoJson.value = stringifyPolygonGeoJson([first]);
|
||||
polygonCount.value = 1;
|
||||
fitMapToPolygon(first);
|
||||
}
|
||||
|
||||
async function ensureMapReady() {
|
||||
if (!props.open) return;
|
||||
mapError.value = '';
|
||||
isMapLoading.value = true;
|
||||
logMapDebug('ensureMapReady:start', { open: props.open });
|
||||
|
||||
try {
|
||||
const TMap = await loadTencentMapSdk();
|
||||
logMapDebug('ensureMapReady:sdk-loaded', {
|
||||
hasMapClass: !!TMap?.Map,
|
||||
hasGeometryEditor: !!TMap?.tools?.GeometryEditor,
|
||||
});
|
||||
const container = await waitForMapContainerReady();
|
||||
if (!container) {
|
||||
throw new Error('地图容器尚未就绪,请关闭后重试');
|
||||
}
|
||||
logMapDebug('ensureMapReady:container-ready', {
|
||||
width: container.clientWidth,
|
||||
height: container.clientHeight,
|
||||
});
|
||||
|
||||
const {
|
||||
center: [centerLng, centerLat],
|
||||
shouldResolveAsync,
|
||||
} = await resolveInitialCenter();
|
||||
logMapDebug('ensureMapReady:center-resolved', {
|
||||
centerLng,
|
||||
centerLat,
|
||||
shouldResolveAsync,
|
||||
});
|
||||
|
||||
if (!mapInstance.value) {
|
||||
mapInstance.value = new TMap.Map(container, {
|
||||
center: new TMap.LatLng(centerLat, centerLng),
|
||||
zoom: 12,
|
||||
pitch: 0,
|
||||
rotation: 0,
|
||||
});
|
||||
logMapDebug('ensureMapReady:map-created');
|
||||
} else if (typeof mapInstance.value.setCenter === 'function') {
|
||||
mapInstance.value.setCenter(new TMap.LatLng(centerLat, centerLng));
|
||||
logMapDebug('ensureMapReady:map-recentered');
|
||||
}
|
||||
|
||||
if (!polygonLayer.value) {
|
||||
polygonLayer.value = new TMap.MultiPolygon({
|
||||
map: mapInstance.value,
|
||||
styles: {
|
||||
highlight: createHighlightPolygonStyle(TMap),
|
||||
},
|
||||
geometries: [],
|
||||
});
|
||||
logMapDebug('ensureMapReady:polygon-layer-created');
|
||||
}
|
||||
|
||||
if (!TMap.tools?.GeometryEditor) {
|
||||
throw new Error('腾讯地图绘图工具库未加载');
|
||||
}
|
||||
|
||||
createEditor();
|
||||
logMapDebug('ensureMapReady:editor-created', {
|
||||
editorReady: !!geometryEditor.value,
|
||||
});
|
||||
loadInitialPolygon();
|
||||
setEditorActionMode('INTERACT');
|
||||
window.setTimeout(() => {
|
||||
refreshMapViewport([centerLng, centerLat]);
|
||||
}, 80);
|
||||
window.setTimeout(() => {
|
||||
refreshMapViewport([centerLng, centerLat]);
|
||||
}, 360);
|
||||
window.setTimeout(() => {
|
||||
refreshMapViewport([centerLng, centerLat]);
|
||||
}, 900);
|
||||
|
||||
if (shouldResolveAsync) {
|
||||
void resolveAsyncCenterByAddress().then((asyncCenter) => {
|
||||
if (!props.open || !mapInstance.value || !asyncCenter) return;
|
||||
|
||||
const [asyncLng, asyncLat] = asyncCenter;
|
||||
if (typeof mapInstance.value.setCenter === 'function') {
|
||||
mapInstance.value.setCenter(new TMap.LatLng(asyncLat, asyncLng));
|
||||
}
|
||||
refreshMapViewport(asyncCenter);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('腾讯地图初始化失败', error);
|
||||
logMapDebug('ensureMapReady:failed', error);
|
||||
mapError.value =
|
||||
error instanceof Error ? error.message : '腾讯地图加载失败,请稍后重试';
|
||||
} finally {
|
||||
isMapLoading.value = false;
|
||||
logMapDebug('ensureMapReady:finished', {
|
||||
mapReady: !!mapInstance.value,
|
||||
editorReady: !!geometryEditor.value,
|
||||
mapError: mapError.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function startDrawing() {
|
||||
logMapDebug('startDrawing:clicked', {
|
||||
mapError: mapError.value,
|
||||
isMapLoading: isMapLoading.value,
|
||||
hasMap: !!mapInstance.value,
|
||||
hasEditor: !!geometryEditor.value,
|
||||
polygonCount: polygonCount.value,
|
||||
});
|
||||
if (mapError.value) return;
|
||||
if (isMapLoading.value) {
|
||||
message.info('地图加载中,请稍后再试');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mapInstance.value) {
|
||||
logMapDebug('startDrawing:ensureMapReady');
|
||||
await ensureMapReady();
|
||||
}
|
||||
|
||||
if (!geometryEditor.value) {
|
||||
createEditor();
|
||||
}
|
||||
if (!geometryEditor.value) {
|
||||
message.error('绘图器尚未就绪,请稍后重试');
|
||||
logMapDebug('startDrawing:editor-not-ready');
|
||||
return;
|
||||
}
|
||||
|
||||
if (polygonCount.value > 0) {
|
||||
setRawGeometries([]);
|
||||
latestGeoJson.value = '';
|
||||
polygonCount.value = 0;
|
||||
}
|
||||
setEditorActionMode('DRAW');
|
||||
isDrawing.value = true;
|
||||
message.info('请在地图上依次点击,双击完成区域');
|
||||
logMapDebug('startDrawing:entered-draw-mode');
|
||||
}
|
||||
|
||||
function clearPolygon() {
|
||||
if (!geometryEditor.value) {
|
||||
createEditor();
|
||||
}
|
||||
|
||||
setRawGeometries([]);
|
||||
latestGeoJson.value = '';
|
||||
polygonCount.value = 0;
|
||||
setEditorActionMode('INTERACT');
|
||||
isDrawing.value = false;
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (!latestGeoJson.value) {
|
||||
syncGeoJsonFromGeometry();
|
||||
}
|
||||
if (!latestGeoJson.value) {
|
||||
message.error('请先绘制配送区域');
|
||||
return;
|
||||
}
|
||||
|
||||
emit('confirm', latestGeoJson.value);
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function getModalContainer(): HTMLElement {
|
||||
return document.body;
|
||||
}
|
||||
|
||||
let openLifecycleToken = 0;
|
||||
|
||||
function scheduleViewportRefresh(token: number) {
|
||||
// 连续两次重排,兼容不同机器上的弹窗动画时机差异。
|
||||
window.setTimeout(() => {
|
||||
if (!props.open || token !== openLifecycleToken) return;
|
||||
refreshMapViewport();
|
||||
}, 80);
|
||||
window.setTimeout(() => {
|
||||
if (!props.open || token !== openLifecycleToken) return;
|
||||
refreshMapViewport();
|
||||
}, 260);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
async (open) => {
|
||||
const token = ++openLifecycleToken;
|
||||
logMapDebug('watch:open-changed', { open, token });
|
||||
if (!open) {
|
||||
isDrawing.value = false;
|
||||
latestGeoJson.value = '';
|
||||
polygonCount.value = 0;
|
||||
destroyMapResources();
|
||||
logMapDebug('watch:closed-resources-destroyed', { token });
|
||||
return;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
if (!props.open || token !== openLifecycleToken) return;
|
||||
|
||||
await ensureMapReady();
|
||||
if (!props.open || token !== openLifecycleToken) return;
|
||||
|
||||
scheduleViewportRefresh(token);
|
||||
logMapDebug('watch:open-init-finished', { token });
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
destroyMapResources();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:open="props.open"
|
||||
title="绘制配送区域"
|
||||
width="980px"
|
||||
:mask-closable="false"
|
||||
:z-index="MAP_MODAL_Z_INDEX"
|
||||
:get-container="getModalContainer"
|
||||
:destroy-on-close="true"
|
||||
@cancel="handleClose"
|
||||
>
|
||||
<div class="delivery-map-modal">
|
||||
<div class="delivery-map-toolbar">
|
||||
<Space size="small" wrap>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="!canStartDrawing"
|
||||
@click="startDrawing"
|
||||
>
|
||||
开始绘制
|
||||
</Button>
|
||||
<Button size="small" danger ghost @click="clearPolygon">清空</Button>
|
||||
</Space>
|
||||
<div class="delivery-map-status">
|
||||
<span
|
||||
class="delivery-map-status-pill"
|
||||
:class="{ drawing: isDrawing }"
|
||||
>
|
||||
{{ isDrawing ? '绘制中(双击结束)' : '已暂停' }}
|
||||
</span>
|
||||
<span class="delivery-map-status-text">
|
||||
已绘制 {{ polygonCount }} 块,单区域模式(新绘制会覆盖旧区域)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
v-if="mapError"
|
||||
type="error"
|
||||
:message="mapError"
|
||||
show-icon
|
||||
class="delivery-map-alert"
|
||||
/>
|
||||
|
||||
<div
|
||||
ref="mapContainerRef"
|
||||
class="delivery-map-canvas"
|
||||
:class="{ loading: isMapLoading }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button @click="handleClose">取消</Button>
|
||||
<Button type="primary" :disabled="!hasPolygon" @click="handleConfirm">
|
||||
确认使用该区域
|
||||
</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -45,6 +80,7 @@ function readInputValue(event: Event) {
|
||||
:open="props.open"
|
||||
:title="props.title"
|
||||
:width="460"
|
||||
:z-index="4000"
|
||||
:mask-closable="true"
|
||||
@update:open="(value) => emit('update:open', value)"
|
||||
>
|
||||
@@ -150,6 +186,28 @@ function readInputValue(event: Event) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-form-block">
|
||||
<label class="drawer-form-label required">区域范围</label>
|
||||
<div class="zone-map-actions">
|
||||
<Button type="primary" ghost @click="openMapModal">
|
||||
{{ polygonCount > 0 ? '重新绘制区域' : '绘制配送区域' }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="zone-map-summary">
|
||||
{{
|
||||
polygonCount > 0 ? `已绘制 ${polygonCount} 块区域` : '暂未绘制区域'
|
||||
}}
|
||||
</div>
|
||||
<div class="zone-geojson-preview">
|
||||
<Input.TextArea
|
||||
:value="formattedPolygonGeoJson"
|
||||
:auto-size="{ minRows: 7, maxRows: 11 }"
|
||||
readonly
|
||||
placeholder="绘制完成后,这里会显示格式化坐标 GeoJSON 数据"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<Button @click="emit('update:open', false)">取消</Button>
|
||||
@@ -159,4 +217,15 @@ function readInputValue(event: Event) {
|
||||
</div>
|
||||
</template>
|
||||
</Drawer>
|
||||
|
||||
<DeliveryPolygonMapModal
|
||||
v-model:open="isMapModalOpen"
|
||||
:initial-geo-json="props.form.polygonGeoJson"
|
||||
:initial-center-latitude="props.initialCenterLatitude"
|
||||
:initial-center-longitude="props.initialCenterLongitude"
|
||||
:initial-center-address="props.initialCenterAddress"
|
||||
:fallback-city-text="props.fallbackCityText"
|
||||
:zone-color="props.form.color"
|
||||
@confirm="handleMapConfirm"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -54,6 +60,9 @@ const emit = defineEmits<{
|
||||
:style="{ background: zone.color }"
|
||||
></span>
|
||||
{{ zone.name }}
|
||||
<span class="zone-shape-count">
|
||||
({{ getPolygonCount(zone.polygonGeoJson) }}块)
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ props.formatCurrency(zone.deliveryFee) }}</td>
|
||||
<td>{{ props.formatCurrency(zone.minOrderAmount) }}</td>
|
||||
|
||||
@@ -58,6 +58,22 @@ export const DEFAULT_RADIUS_TIERS: RadiusTierDto[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function createPolygonGeoJson(coordinates: Array<[number, number]>) {
|
||||
return JSON.stringify({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [coordinates],
|
||||
},
|
||||
properties: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export const DEFAULT_POLYGON_ZONES: PolygonZoneDto[] = [
|
||||
{
|
||||
id: 'zone-core',
|
||||
@@ -67,6 +83,13 @@ export const DEFAULT_POLYGON_ZONES: PolygonZoneDto[] = [
|
||||
minOrderAmount: 15,
|
||||
etaMinutes: 20,
|
||||
priority: 1,
|
||||
polygonGeoJson: createPolygonGeoJson([
|
||||
[116.389, 39.907],
|
||||
[116.397, 39.907],
|
||||
[116.397, 39.913],
|
||||
[116.389, 39.913],
|
||||
[116.389, 39.907],
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'zone-cbd',
|
||||
@@ -76,6 +99,13 @@ export const DEFAULT_POLYGON_ZONES: PolygonZoneDto[] = [
|
||||
minOrderAmount: 20,
|
||||
etaMinutes: 35,
|
||||
priority: 2,
|
||||
polygonGeoJson: createPolygonGeoJson([
|
||||
[116.456, 39.914],
|
||||
[116.468, 39.914],
|
||||
[116.468, 39.923],
|
||||
[116.456, 39.923],
|
||||
[116.456, 39.914],
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'zone-slt',
|
||||
@@ -85,6 +115,13 @@ export const DEFAULT_POLYGON_ZONES: PolygonZoneDto[] = [
|
||||
minOrderAmount: 25,
|
||||
etaMinutes: 40,
|
||||
priority: 3,
|
||||
polygonGeoJson: createPolygonGeoJson([
|
||||
[116.445, 39.928],
|
||||
[116.455, 39.928],
|
||||
[116.455, 39.936],
|
||||
[116.445, 39.936],
|
||||
[116.445, 39.928],
|
||||
]),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ interface CreateDataActionsOptions {
|
||||
isSettingsLoading: Ref<boolean>;
|
||||
isStoreLoading: Ref<boolean>;
|
||||
mode: Ref<DeliveryMode>;
|
||||
radiusCenterLatitude: Ref<null | number>;
|
||||
radiusCenterLongitude: Ref<null | number>;
|
||||
polygonZones: Ref<PolygonZoneDto[]>;
|
||||
radiusTiers: Ref<RadiusTierDto[]>;
|
||||
selectedStoreId: Ref<string>;
|
||||
@@ -64,6 +66,8 @@ export function createDataActions(options: CreateDataActionsOptions) {
|
||||
function applySnapshot(snapshot: DeliverySettingsSnapshot) {
|
||||
options.mode.value = snapshot.mode;
|
||||
options.editingMode.value = snapshot.mode;
|
||||
options.radiusCenterLatitude.value = snapshot.radiusCenterLatitude;
|
||||
options.radiusCenterLongitude.value = snapshot.radiusCenterLongitude;
|
||||
options.radiusTiers.value = sortRadiusTiers(snapshot.radiusTiers);
|
||||
options.polygonZones.value = sortPolygonZones(snapshot.polygonZones);
|
||||
syncGeneralSettings(snapshot.generalSettings);
|
||||
@@ -73,6 +77,8 @@ export function createDataActions(options: CreateDataActionsOptions) {
|
||||
function buildCurrentSnapshot() {
|
||||
return createSettingsSnapshot({
|
||||
mode: options.mode.value,
|
||||
radiusCenterLatitude: options.radiusCenterLatitude.value,
|
||||
radiusCenterLongitude: options.radiusCenterLongitude.value,
|
||||
radiusTiers: options.radiusTiers.value,
|
||||
polygonZones: options.polygonZones.value,
|
||||
generalSettings: options.generalSettings,
|
||||
@@ -83,6 +89,8 @@ export function createDataActions(options: CreateDataActionsOptions) {
|
||||
function applyDefaultSettings() {
|
||||
options.mode.value = DEFAULT_DELIVERY_MODE;
|
||||
options.editingMode.value = DEFAULT_DELIVERY_MODE;
|
||||
options.radiusCenterLatitude.value = null;
|
||||
options.radiusCenterLongitude.value = null;
|
||||
options.radiusTiers.value = sortRadiusTiers(DEFAULT_RADIUS_TIERS);
|
||||
options.polygonZones.value = sortPolygonZones(DEFAULT_POLYGON_ZONES);
|
||||
syncGeneralSettings(cloneGeneralSettings(DEFAULT_GENERAL_SETTINGS));
|
||||
@@ -98,6 +106,9 @@ export function createDataActions(options: CreateDataActionsOptions) {
|
||||
|
||||
options.mode.value = result.mode ?? DEFAULT_DELIVERY_MODE;
|
||||
options.editingMode.value = options.mode.value;
|
||||
options.radiusCenterLatitude.value = result.radiusCenterLatitude ?? null;
|
||||
options.radiusCenterLongitude.value =
|
||||
result.radiusCenterLongitude ?? null;
|
||||
options.radiusTiers.value = sortRadiusTiers(
|
||||
result.radiusTiers?.length ? result.radiusTiers : DEFAULT_RADIUS_TIERS,
|
||||
);
|
||||
@@ -177,6 +188,8 @@ export function createDataActions(options: CreateDataActionsOptions) {
|
||||
await saveStoreDeliverySettingsApi({
|
||||
storeId: options.selectedStoreId.value,
|
||||
mode: options.mode.value,
|
||||
radiusCenterLatitude: options.radiusCenterLatitude.value,
|
||||
radiusCenterLongitude: options.radiusCenterLongitude.value,
|
||||
radiusTiers: cloneRadiusTiers(options.radiusTiers.value),
|
||||
polygonZones: clonePolygonZones(options.polygonZones.value),
|
||||
generalSettings: cloneGeneralSettings(options.generalSettings),
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 文件职责:配送区域 GeoJSON 工具。
|
||||
* 1. 负责 Polygon FeatureCollection 与坐标数组互转。
|
||||
* 2. 提供绘制结果统计能力,供 UI 展示已绘制数量。
|
||||
*/
|
||||
|
||||
export type LngLatTuple = [number, number];
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function isLngLatTuple(value: unknown): value is LngLatTuple {
|
||||
if (!Array.isArray(value) || value.length < 2) return false;
|
||||
const [lng, lat] = value;
|
||||
if (!isFiniteNumber(lng) || !isFiniteNumber(lat)) return false;
|
||||
return lng >= -180 && lng <= 180 && lat >= -90 && lat <= 90;
|
||||
}
|
||||
|
||||
function closePolygonRing(points: LngLatTuple[]) {
|
||||
if (points.length < 3) return [];
|
||||
const normalized = points.map(([lng, lat]) => [lng, lat] as LngLatTuple);
|
||||
const first = normalized[0];
|
||||
const last = normalized[normalized.length - 1];
|
||||
if (!first || !last) return [];
|
||||
if (first[0] !== last[0] || first[1] !== last[1]) {
|
||||
normalized.push([first[0], first[1]]);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将多边形坐标序列转换为 FeatureCollection 字符串。
|
||||
*/
|
||||
export function stringifyPolygonGeoJson(polygons: LngLatTuple[][]) {
|
||||
const features = polygons
|
||||
.map((points) => closePolygonRing(points))
|
||||
.filter((ring) => ring.length >= 4)
|
||||
.map((ring) => ({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [ring],
|
||||
},
|
||||
properties: {},
|
||||
}));
|
||||
|
||||
return JSON.stringify({
|
||||
type: 'FeatureCollection',
|
||||
features,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 FeatureCollection 中解析外环点位,供地图回显与编辑。
|
||||
*/
|
||||
export function parsePolygonGeoJson(raw: string) {
|
||||
if (!raw) return [] as LngLatTuple[][];
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as JsonRecord;
|
||||
if (parsed.type !== 'FeatureCollection') return [] as LngLatTuple[][];
|
||||
|
||||
const features = parsed.features;
|
||||
if (!Array.isArray(features)) return [] as LngLatTuple[][];
|
||||
|
||||
const polygons: LngLatTuple[][] = [];
|
||||
for (const feature of features) {
|
||||
const featureRecord =
|
||||
feature && typeof feature === 'object'
|
||||
? (feature as JsonRecord)
|
||||
: undefined;
|
||||
const geometry =
|
||||
featureRecord?.geometry && typeof featureRecord.geometry === 'object'
|
||||
? (featureRecord.geometry as JsonRecord)
|
||||
: undefined;
|
||||
if (!geometry || geometry.type !== 'Polygon') continue;
|
||||
|
||||
const coordinates = geometry.coordinates;
|
||||
if (!Array.isArray(coordinates) || coordinates.length === 0) continue;
|
||||
|
||||
const outerRing = coordinates[0];
|
||||
if (!Array.isArray(outerRing)) continue;
|
||||
const points = outerRing.filter((point) => isLngLatTuple(point)) as LngLatTuple[];
|
||||
if (points.length >= 4) {
|
||||
polygons.push(points.map(([lng, lat]) => [lng, lat]));
|
||||
}
|
||||
}
|
||||
|
||||
return polygons;
|
||||
} catch {
|
||||
return [] as LngLatTuple[][];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计 GeoJSON 内有效 Polygon 数量。
|
||||
*/
|
||||
export function countPolygonsInGeoJson(raw: string) {
|
||||
return parsePolygonGeoJson(raw).length;
|
||||
}
|
||||
@@ -33,10 +33,14 @@ export function createSettingsSnapshot(payload: {
|
||||
generalSettings: DeliveryGeneralSettingsDto;
|
||||
mode: DeliveryMode;
|
||||
polygonZones: PolygonZoneDto[];
|
||||
radiusCenterLatitude: null | number;
|
||||
radiusCenterLongitude: null | number;
|
||||
radiusTiers: RadiusTierDto[];
|
||||
}): DeliverySettingsSnapshot {
|
||||
return {
|
||||
mode: payload.mode,
|
||||
radiusCenterLatitude: payload.radiusCenterLatitude,
|
||||
radiusCenterLongitude: payload.radiusCenterLongitude,
|
||||
radiusTiers: cloneRadiusTiers(payload.radiusTiers),
|
||||
polygonZones: clonePolygonZones(payload.polygonZones),
|
||||
generalSettings: cloneGeneralSettings(payload.generalSettings),
|
||||
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { countPolygonsInGeoJson } from './geojson';
|
||||
|
||||
interface CreateZoneActionsOptions {
|
||||
createZoneId: () => string;
|
||||
getTierColorByIndex: (index: number) => string;
|
||||
@@ -36,6 +38,7 @@ export function createZoneActions(options: CreateZoneActionsOptions) {
|
||||
options.zoneForm.etaMinutes = zone.etaMinutes;
|
||||
options.zoneForm.priority = zone.priority;
|
||||
options.zoneForm.color = zone.color;
|
||||
options.zoneForm.polygonGeoJson = zone.polygonGeoJson ?? '';
|
||||
options.isZoneDrawerOpen.value = true;
|
||||
return;
|
||||
}
|
||||
@@ -48,6 +51,7 @@ export function createZoneActions(options: CreateZoneActionsOptions) {
|
||||
options.zoneForm.etaMinutes = 30;
|
||||
options.zoneForm.priority = nextPriority;
|
||||
options.zoneForm.color = options.getTierColorByIndex(nextPriority - 1);
|
||||
options.zoneForm.polygonGeoJson = '';
|
||||
options.isZoneDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
@@ -86,6 +90,11 @@ export function createZoneActions(options: CreateZoneActionsOptions) {
|
||||
options.zoneForm.color = value || '#1677ff';
|
||||
}
|
||||
|
||||
/** 更新区域多边形 GeoJSON。 */
|
||||
function setZonePolygonGeoJson(value: string) {
|
||||
options.zoneForm.polygonGeoJson = value?.trim() ?? '';
|
||||
}
|
||||
|
||||
/** 提交区域表单并更新列表。 */
|
||||
function handleZoneSubmit() {
|
||||
// 1. 必填校验。
|
||||
@@ -95,6 +104,11 @@ export function createZoneActions(options: CreateZoneActionsOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (countPolygonsInGeoJson(options.zoneForm.polygonGeoJson) <= 0) {
|
||||
message.error('请先绘制配送区域');
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 优先级冲突校验。
|
||||
const hasPriorityConflict = options.polygonZones.value.some((item) => {
|
||||
if (item.id === options.zoneForm.id) return false;
|
||||
@@ -114,6 +128,7 @@ export function createZoneActions(options: CreateZoneActionsOptions) {
|
||||
etaMinutes: options.zoneForm.etaMinutes,
|
||||
priority: options.zoneForm.priority,
|
||||
color: options.zoneForm.color,
|
||||
polygonGeoJson: options.zoneForm.polygonGeoJson,
|
||||
};
|
||||
|
||||
options.polygonZones.value =
|
||||
@@ -149,6 +164,7 @@ export function createZoneActions(options: CreateZoneActionsOptions) {
|
||||
setZoneEtaMinutes,
|
||||
setZoneMinOrderAmount,
|
||||
setZoneName,
|
||||
setZonePolygonGeoJson,
|
||||
setZonePriority,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { Modal } from 'ant-design-vue';
|
||||
|
||||
import { getMerchantInfoApi } from '#/api/merchant';
|
||||
|
||||
import {
|
||||
DEFAULT_DELIVERY_MODE,
|
||||
DEFAULT_GENERAL_SETTINGS,
|
||||
@@ -49,6 +51,17 @@ import { createTierActions } from './delivery-page/tier-actions';
|
||||
import { createZoneActions } from './delivery-page/zone-actions';
|
||||
|
||||
export function useStoreDeliveryPage() {
|
||||
function buildRegionText(
|
||||
province?: string,
|
||||
city?: string,
|
||||
district?: string,
|
||||
) {
|
||||
return [province, city, district]
|
||||
.map((part) => part?.trim() ?? '')
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
}
|
||||
|
||||
// 1. 页面 loading / submitting 状态。
|
||||
const isStoreLoading = ref(false);
|
||||
const isSettingsLoading = ref(false);
|
||||
@@ -62,6 +75,9 @@ export function useStoreDeliveryPage() {
|
||||
const deliveryMode = ref<DeliveryMode>(DEFAULT_DELIVERY_MODE);
|
||||
// 当前编辑视图模式(仅影响页面展示,不直接落库)。
|
||||
const editingMode = ref<DeliveryMode>(DEFAULT_DELIVERY_MODE);
|
||||
// 半径配送中心点(仅半径模式使用)。
|
||||
const radiusCenterLatitude = ref<null | number>(null);
|
||||
const radiusCenterLongitude = ref<null | number>(null);
|
||||
const radiusTiers = ref<RadiusTierDto[]>(
|
||||
cloneRadiusTiers(DEFAULT_RADIUS_TIERS),
|
||||
);
|
||||
@@ -71,6 +87,8 @@ export function useStoreDeliveryPage() {
|
||||
const generalSettings = reactive<DeliveryGeneralSettingsDto>(
|
||||
cloneGeneralSettings(DEFAULT_GENERAL_SETTINGS),
|
||||
);
|
||||
const merchantCityText = ref('');
|
||||
const merchantRegisteredAddress = ref('');
|
||||
|
||||
// 3. 页面弹窗与抽屉状态。
|
||||
const isCopyModalOpen = ref(false);
|
||||
@@ -99,6 +117,7 @@ export function useStoreDeliveryPage() {
|
||||
etaMinutes: 30,
|
||||
priority: 1,
|
||||
color: getTierColorByIndex(0),
|
||||
polygonGeoJson: '',
|
||||
});
|
||||
|
||||
// 4. 页面衍生视图数据。
|
||||
@@ -111,6 +130,37 @@ export function useStoreDeliveryPage() {
|
||||
stores.value.find((store) => store.id === selectedStoreId.value)?.name ??
|
||||
'',
|
||||
);
|
||||
const selectedStore = computed(
|
||||
() =>
|
||||
stores.value.find((store) => store.id === selectedStoreId.value) ?? null,
|
||||
);
|
||||
const mapCenterLatitude = computed(
|
||||
() => selectedStore.value?.latitude ?? null,
|
||||
);
|
||||
const mapCenterLongitude = computed(
|
||||
() => selectedStore.value?.longitude ?? null,
|
||||
);
|
||||
const mapCenterAddress = computed(() => {
|
||||
const store = selectedStore.value;
|
||||
if (!store) return '';
|
||||
|
||||
const normalizedAddress = store.address?.trim() ?? '';
|
||||
if (normalizedAddress) return normalizedAddress;
|
||||
|
||||
return buildRegionText(store.province, store.city, store.district);
|
||||
});
|
||||
const mapFallbackCityText = computed(() => {
|
||||
const store = selectedStore.value;
|
||||
const storeCityText = buildRegionText(
|
||||
store?.province,
|
||||
store?.city,
|
||||
store?.district,
|
||||
);
|
||||
|
||||
if (storeCityText) return storeCityText;
|
||||
if (merchantCityText.value) return merchantCityText.value;
|
||||
return merchantRegisteredAddress.value;
|
||||
});
|
||||
|
||||
const copyCandidates = computed(() =>
|
||||
stores.value.filter((store) => store.id !== selectedStoreId.value),
|
||||
@@ -151,6 +201,8 @@ export function useStoreDeliveryPage() {
|
||||
isSettingsLoading,
|
||||
isStoreLoading,
|
||||
mode: deliveryMode,
|
||||
radiusCenterLatitude,
|
||||
radiusCenterLongitude,
|
||||
polygonZones,
|
||||
radiusTiers,
|
||||
selectedStoreId,
|
||||
@@ -202,6 +254,7 @@ export function useStoreDeliveryPage() {
|
||||
setZoneEtaMinutes,
|
||||
setZoneMinOrderAmount,
|
||||
setZoneName,
|
||||
setZonePolygonGeoJson,
|
||||
setZonePriority,
|
||||
} = createZoneActions({
|
||||
createZoneId,
|
||||
@@ -223,6 +276,22 @@ export function useStoreDeliveryPage() {
|
||||
editingMode.value = value;
|
||||
}
|
||||
|
||||
function setRadiusCenterLatitude(value: null | number) {
|
||||
if (value === null || value === undefined) {
|
||||
radiusCenterLatitude.value = null;
|
||||
return;
|
||||
}
|
||||
radiusCenterLatitude.value = Number(value);
|
||||
}
|
||||
|
||||
function setRadiusCenterLongitude(value: null | number) {
|
||||
if (value === null || value === undefined) {
|
||||
radiusCenterLongitude.value = null;
|
||||
return;
|
||||
}
|
||||
radiusCenterLongitude.value = Number(value);
|
||||
}
|
||||
|
||||
// 切换“当前生效模式”,二次确认后保存,防止误操作。
|
||||
function setDeliveryMode(value: DeliveryMode) {
|
||||
if (value === deliveryMode.value) return;
|
||||
@@ -274,11 +343,32 @@ export function useStoreDeliveryPage() {
|
||||
);
|
||||
}
|
||||
|
||||
async function loadMerchantLocation() {
|
||||
try {
|
||||
const result = await getMerchantInfoApi();
|
||||
const merchant = result.merchant;
|
||||
|
||||
merchantCityText.value = buildRegionText(
|
||||
merchant?.province,
|
||||
merchant?.city,
|
||||
merchant?.district,
|
||||
);
|
||||
merchantRegisteredAddress.value =
|
||||
merchant?.registeredAddress?.trim() ?? '';
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
merchantCityText.value = '';
|
||||
merchantRegisteredAddress.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 门店切换时自动刷新配置。
|
||||
watch(selectedStoreId, async (storeId) => {
|
||||
if (!storeId) {
|
||||
deliveryMode.value = DEFAULT_DELIVERY_MODE;
|
||||
editingMode.value = DEFAULT_DELIVERY_MODE;
|
||||
radiusCenterLatitude.value = null;
|
||||
radiusCenterLongitude.value = null;
|
||||
radiusTiers.value = cloneRadiusTiers(DEFAULT_RADIUS_TIERS);
|
||||
polygonZones.value = clonePolygonZones(DEFAULT_POLYGON_ZONES);
|
||||
Object.assign(
|
||||
@@ -291,16 +381,21 @@ export function useStoreDeliveryPage() {
|
||||
await loadStoreSettings(storeId);
|
||||
});
|
||||
|
||||
async function loadPageContext() {
|
||||
await Promise.allSettled([loadStores(), loadMerchantLocation()]);
|
||||
}
|
||||
|
||||
// 8. 页面首屏初始化。
|
||||
onMounted(loadStores);
|
||||
onMounted(loadPageContext);
|
||||
// 9. 路由回到当前页时刷新门店列表,避免使用旧缓存。
|
||||
onActivated(loadStores);
|
||||
onActivated(loadPageContext);
|
||||
|
||||
return {
|
||||
DELIVERY_MODE_OPTIONS,
|
||||
copyCandidates,
|
||||
copyTargetStoreIds,
|
||||
deliveryMode,
|
||||
editingMode,
|
||||
formatCurrency,
|
||||
formatDistanceRange,
|
||||
generalSettings,
|
||||
@@ -320,22 +415,29 @@ export function useStoreDeliveryPage() {
|
||||
isStoreLoading,
|
||||
isTierDrawerOpen,
|
||||
isZoneDrawerOpen,
|
||||
mapCenterAddress,
|
||||
mapCenterLatitude,
|
||||
mapCenterLongitude,
|
||||
mapFallbackCityText,
|
||||
openCopyModal,
|
||||
openTierDrawer,
|
||||
openZoneDrawer,
|
||||
polygonZones,
|
||||
radiusCenterLatitude,
|
||||
radiusCenterLongitude,
|
||||
radiusTiers,
|
||||
resetFromSnapshot,
|
||||
saveCurrentSettings,
|
||||
selectedStoreId,
|
||||
selectedStoreName,
|
||||
setDeliveryMode,
|
||||
editingMode,
|
||||
setEditingMode,
|
||||
setEtaAdjustmentMinutes,
|
||||
setFreeDeliveryThreshold,
|
||||
setHourlyCapacityLimit,
|
||||
setMaxDeliveryDistance,
|
||||
setRadiusCenterLatitude,
|
||||
setRadiusCenterLongitude,
|
||||
setSelectedStoreId,
|
||||
setTierColor,
|
||||
setTierDeliveryFee,
|
||||
@@ -350,6 +452,7 @@ export function useStoreDeliveryPage() {
|
||||
setZoneEtaMinutes,
|
||||
setZoneMinOrderAmount,
|
||||
setZoneName,
|
||||
setZonePolygonGeoJson,
|
||||
setZonePriority,
|
||||
storeOptions,
|
||||
tierColorPalette: TIER_COLOR_PALETTE,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 文件职责:按需加载腾讯地图 JS SDK。
|
||||
* 1. 使用全局 callback + 单例 Promise,避免重复注入脚本。
|
||||
* 2. 与 AdminUI 的加载策略保持一致,降低多实例冲突风险。
|
||||
*/
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
TMap?: any;
|
||||
__tenantTencentMapInit?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
const SCRIPT_ID = 'tenant-tencent-map-gljs-sdk';
|
||||
const CALLBACK_NAME = '__tenantTencentMapInit';
|
||||
const SCRIPT_LOAD_TIMEOUT_MS = 12_000;
|
||||
const TENCENT_MAP_LIBRARIES = 'visualization,geometry,vector,tools,service';
|
||||
|
||||
let mapSdkPromise: null | Promise<any> = null;
|
||||
let scriptLoading = false;
|
||||
const pendingResolvers: Array<(value: any) => void> = [];
|
||||
const pendingRejectors: Array<(error: Error) => void> = [];
|
||||
|
||||
function getTencentMapKey() {
|
||||
return (import.meta.env.VITE_TENCENT_MAP_KEY as string | undefined)?.trim();
|
||||
}
|
||||
|
||||
function flushSuccess(tmap: any) {
|
||||
const resolvers = pendingResolvers.splice(0);
|
||||
pendingRejectors.splice(0);
|
||||
resolvers.forEach((resolve) => resolve(tmap));
|
||||
}
|
||||
|
||||
function flushError(error: Error) {
|
||||
const rejectors = pendingRejectors.splice(0);
|
||||
pendingResolvers.splice(0);
|
||||
rejectors.forEach((reject) => reject(error));
|
||||
}
|
||||
|
||||
function buildScriptUrl(mapKey: string) {
|
||||
return `https://map.qq.com/api/gljs?v=1.exp&key=${encodeURIComponent(
|
||||
mapKey,
|
||||
)}&libraries=${TENCENT_MAP_LIBRARIES}&callback=${CALLBACK_NAME}`;
|
||||
}
|
||||
|
||||
export async function loadTencentMapSdk() {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new TypeError('当前环境不支持加载地图');
|
||||
}
|
||||
|
||||
if (window.TMap) {
|
||||
return window.TMap;
|
||||
}
|
||||
|
||||
const mapKey = getTencentMapKey();
|
||||
if (!mapKey) {
|
||||
throw new Error('未配置腾讯地图 Key(VITE_TENCENT_MAP_KEY)');
|
||||
}
|
||||
|
||||
if (mapSdkPromise) {
|
||||
return mapSdkPromise;
|
||||
}
|
||||
|
||||
mapSdkPromise = new Promise<any>((resolve, reject) => {
|
||||
pendingResolvers.push(resolve);
|
||||
pendingRejectors.push(reject);
|
||||
|
||||
if (scriptLoading) {
|
||||
return;
|
||||
}
|
||||
scriptLoading = true;
|
||||
|
||||
const completeWithError = (error: Error) => {
|
||||
scriptLoading = false;
|
||||
mapSdkPromise = null;
|
||||
flushError(error);
|
||||
};
|
||||
|
||||
const timeoutHandle = window.setTimeout(() => {
|
||||
completeWithError(new Error('腾讯地图 SDK 加载超时'));
|
||||
}, SCRIPT_LOAD_TIMEOUT_MS);
|
||||
|
||||
window[CALLBACK_NAME] = () => {
|
||||
window.clearTimeout(timeoutHandle);
|
||||
scriptLoading = false;
|
||||
|
||||
if (!window.TMap) {
|
||||
completeWithError(new Error('腾讯地图 SDK 加载失败'));
|
||||
return;
|
||||
}
|
||||
|
||||
flushSuccess(window.TMap);
|
||||
};
|
||||
|
||||
const existingScript = document.querySelector<HTMLScriptElement>(
|
||||
`#${SCRIPT_ID}`,
|
||||
);
|
||||
if (existingScript) {
|
||||
existingScript.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
window.clearTimeout(timeoutHandle);
|
||||
completeWithError(new Error('腾讯地图 SDK 加载失败'));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.id = SCRIPT_ID;
|
||||
script.type = 'text/javascript';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.src = buildScriptUrl(mapKey);
|
||||
script.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
window.clearTimeout(timeoutHandle);
|
||||
completeWithError(new Error('腾讯地图 SDK 加载失败'));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
document.body.append(script);
|
||||
});
|
||||
|
||||
return mapSdkPromise;
|
||||
}
|
||||
@@ -43,10 +43,16 @@ const {
|
||||
isStoreLoading,
|
||||
isTierDrawerOpen,
|
||||
isZoneDrawerOpen,
|
||||
mapCenterAddress,
|
||||
mapCenterLatitude,
|
||||
mapCenterLongitude,
|
||||
mapFallbackCityText,
|
||||
openCopyModal,
|
||||
openTierDrawer,
|
||||
openZoneDrawer,
|
||||
polygonZones,
|
||||
radiusCenterLatitude,
|
||||
radiusCenterLongitude,
|
||||
radiusTiers,
|
||||
resetFromSnapshot,
|
||||
saveCurrentSettings,
|
||||
@@ -58,6 +64,8 @@ const {
|
||||
setFreeDeliveryThreshold,
|
||||
setHourlyCapacityLimit,
|
||||
setMaxDeliveryDistance,
|
||||
setRadiusCenterLatitude,
|
||||
setRadiusCenterLongitude,
|
||||
setSelectedStoreId,
|
||||
setTierColor,
|
||||
setTierDeliveryFee,
|
||||
@@ -72,6 +80,7 @@ const {
|
||||
setZoneEtaMinutes,
|
||||
setZoneMinOrderAmount,
|
||||
setZoneName,
|
||||
setZonePolygonGeoJson,
|
||||
setZonePriority,
|
||||
storeOptions,
|
||||
tierColorPalette,
|
||||
@@ -102,46 +111,51 @@ const {
|
||||
|
||||
<template v-else>
|
||||
<Spin :spinning="isPageLoading">
|
||||
<DeliveryModeCard
|
||||
:active-mode="deliveryMode"
|
||||
:config-mode="editingMode"
|
||||
:mode-options="DELIVERY_MODE_OPTIONS"
|
||||
:radius-tiers="radiusTiers"
|
||||
@change-active-mode="setDeliveryMode"
|
||||
@change-config-mode="setEditingMode"
|
||||
/>
|
||||
<div class="delivery-cards-stack">
|
||||
<DeliveryModeCard
|
||||
:active-mode="deliveryMode"
|
||||
:config-mode="editingMode"
|
||||
:radius-center-latitude="radiusCenterLatitude"
|
||||
:radius-center-longitude="radiusCenterLongitude"
|
||||
:mode-options="DELIVERY_MODE_OPTIONS"
|
||||
@change-active-mode="setDeliveryMode"
|
||||
@change-config-mode="setEditingMode"
|
||||
@change-radius-center-latitude="setRadiusCenterLatitude"
|
||||
@change-radius-center-longitude="setRadiusCenterLongitude"
|
||||
/>
|
||||
|
||||
<RadiusTierSection
|
||||
v-if="isRadiusMode"
|
||||
:tiers="radiusTiers"
|
||||
:format-currency="formatCurrency"
|
||||
:format-distance-range="formatDistanceRange"
|
||||
:is-saving="isSaving"
|
||||
@add="openTierDrawer('create')"
|
||||
@edit="(tier) => openTierDrawer('edit', tier)"
|
||||
@delete="handleDeleteTier"
|
||||
/>
|
||||
<RadiusTierSection
|
||||
v-if="isRadiusMode"
|
||||
:tiers="radiusTiers"
|
||||
:format-currency="formatCurrency"
|
||||
:format-distance-range="formatDistanceRange"
|
||||
:is-saving="isSaving"
|
||||
@add="openTierDrawer('create')"
|
||||
@edit="(tier) => openTierDrawer('edit', tier)"
|
||||
@delete="handleDeleteTier"
|
||||
/>
|
||||
|
||||
<PolygonZoneSection
|
||||
v-else
|
||||
:zones="polygonZones"
|
||||
:format-currency="formatCurrency"
|
||||
:is-saving="isSaving"
|
||||
@add="openZoneDrawer('create')"
|
||||
@edit="(zone) => openZoneDrawer('edit', zone)"
|
||||
@delete="handleDeleteZone"
|
||||
/>
|
||||
<PolygonZoneSection
|
||||
v-else
|
||||
:zones="polygonZones"
|
||||
:format-currency="formatCurrency"
|
||||
:is-saving="isSaving"
|
||||
@add="openZoneDrawer('create')"
|
||||
@edit="(zone) => openZoneDrawer('edit', zone)"
|
||||
@delete="handleDeleteZone"
|
||||
/>
|
||||
|
||||
<DeliveryCommonSettingsCard
|
||||
:settings="generalSettings"
|
||||
:is-saving="isSaving"
|
||||
:on-set-free-delivery-threshold="setFreeDeliveryThreshold"
|
||||
:on-set-max-delivery-distance="setMaxDeliveryDistance"
|
||||
:on-set-hourly-capacity-limit="setHourlyCapacityLimit"
|
||||
:on-set-eta-adjustment-minutes="setEtaAdjustmentMinutes"
|
||||
@reset="resetFromSnapshot"
|
||||
@save="saveCurrentSettings"
|
||||
/>
|
||||
<DeliveryCommonSettingsCard
|
||||
:settings="generalSettings"
|
||||
:is-saving="isSaving"
|
||||
:on-set-free-delivery-threshold="setFreeDeliveryThreshold"
|
||||
:on-set-max-delivery-distance="setMaxDeliveryDistance"
|
||||
:on-set-hourly-capacity-limit="setHourlyCapacityLimit"
|
||||
:on-set-eta-adjustment-minutes="setEtaAdjustmentMinutes"
|
||||
@reset="resetFromSnapshot"
|
||||
@save="saveCurrentSettings"
|
||||
/>
|
||||
</div>
|
||||
</Spin>
|
||||
</template>
|
||||
|
||||
@@ -171,6 +185,11 @@ const {
|
||||
:on-set-eta-minutes="setZoneEtaMinutes"
|
||||
:on-set-priority="setZonePriority"
|
||||
:on-set-color="setZoneColor"
|
||||
:on-set-polygon-geo-json="setZonePolygonGeoJson"
|
||||
:initial-center-latitude="mapCenterLatitude"
|
||||
:initial-center-longitude="mapCenterLongitude"
|
||||
:initial-center-address="mapCenterAddress"
|
||||
:fallback-city-text="mapFallbackCityText"
|
||||
@update:open="setZoneDrawerOpen"
|
||||
@submit="handleZoneSubmit"
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
.page-store-delivery {
|
||||
max-width: 980px;
|
||||
|
||||
.delivery-cards-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -91,3 +91,97 @@
|
||||
font-size: 15px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.zone-map-summary {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.zone-geojson-preview {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.zone-geojson-preview .ant-input {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||||
'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: #334155;
|
||||
background: #f8fafc;
|
||||
border-color: #dbe5f3;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.zone-geojson-preview .ant-input::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.zone-map-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.delivery-map-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.delivery-map-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.delivery-map-status {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 10px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.delivery-map-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 9px;
|
||||
line-height: 22px;
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #dbe5f3;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.delivery-map-status-pill.drawing {
|
||||
color: #1d4ed8;
|
||||
background: #eff6ff;
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
|
||||
.delivery-map-status-text {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.delivery-map-alert {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.delivery-map-canvas {
|
||||
width: 100%;
|
||||
height: 520px;
|
||||
overflow: hidden;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #dbe5f3;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.delivery-map-canvas.loading {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* 文件职责:配送模式切换与地图占位样式。 */
|
||||
/* 文件职责:配送模式切换与半径中心点输入样式。 */
|
||||
.page-store-delivery {
|
||||
.delivery-active-mode {
|
||||
padding: 14px 16px;
|
||||
@@ -70,140 +70,61 @@
|
||||
box-shadow: 0 2px 6px rgb(15 23 42 / 8%);
|
||||
}
|
||||
|
||||
.delivery-map-area {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, #f0f5ff 0%, #f7faff 100%);
|
||||
border: 1px dashed #adc6ff;
|
||||
.radius-center-panel {
|
||||
padding: 14px 16px;
|
||||
background: #fbfcfe;
|
||||
border: 1px solid #e5eaf5;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.map-grid {
|
||||
position: absolute;
|
||||
background: #d6e4ff;
|
||||
}
|
||||
|
||||
.grid-h {
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.grid-v {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.map-grid-h-1 {
|
||||
top: 25%;
|
||||
}
|
||||
|
||||
.map-grid-h-2 {
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.map-grid-h-3 {
|
||||
top: 75%;
|
||||
}
|
||||
|
||||
.map-grid-v-1 {
|
||||
left: 25%;
|
||||
}
|
||||
|
||||
.map-grid-v-2 {
|
||||
left: 50%;
|
||||
}
|
||||
|
||||
.map-grid-v-3 {
|
||||
left: 75%;
|
||||
}
|
||||
|
||||
.map-pin {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
z-index: 2;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
color: #1677ff;
|
||||
transform: translate(-50%, -100%);
|
||||
}
|
||||
|
||||
.radius-circle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
border-style: dashed;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.radius-label {
|
||||
position: absolute;
|
||||
bottom: -16px;
|
||||
left: 50%;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.radius-1 {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: rgb(82 196 26 / 8%);
|
||||
border-color: #52c41a;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.radius-1 .radius-label {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.radius-2 {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
background: rgb(250 173 20 / 5%);
|
||||
border-color: #faad14;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.radius-2 .radius-label {
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.radius-3 {
|
||||
width: 260px;
|
||||
height: 260px;
|
||||
background: rgb(255 77 79 / 4%);
|
||||
border-color: #ff4d4f;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.radius-3 .radius-label {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.polygon-hint {
|
||||
z-index: 3;
|
||||
color: #3f87ff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.polygon-hint-title {
|
||||
margin-bottom: 6px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.polygon-hint-desc {
|
||||
.radius-center-title {
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
opacity: 0.8;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.radius-center-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.radius-center-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.radius-center-field label {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.radius-center-field .field-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.radius-center-hint {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.polygon-mode-hint {
|
||||
padding: 12px 14px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
background: #fafafa;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-store-delivery {
|
||||
.radius-center-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,25 +17,6 @@
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.delivery-map-area {
|
||||
height: 260px;
|
||||
}
|
||||
|
||||
.radius-1 {
|
||||
width: 86px;
|
||||
height: 86px;
|
||||
}
|
||||
|
||||
.radius-2 {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.radius-3 {
|
||||
width: 214px;
|
||||
height: 214px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,4 +56,10 @@
|
||||
vertical-align: middle;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.zone-shape-count {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,12 +29,15 @@ export interface PolygonZoneFormState {
|
||||
id: string;
|
||||
minOrderAmount: number;
|
||||
name: string;
|
||||
polygonGeoJson: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface DeliverySettingsSnapshot {
|
||||
generalSettings: DeliveryGeneralSettingsDto;
|
||||
mode: DeliveryMode;
|
||||
radiusCenterLatitude: null | number;
|
||||
radiusCenterLongitude: null | number;
|
||||
polygonZones: PolygonZoneDto[];
|
||||
radiusTiers: RadiusTierDto[];
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import type {
|
||||
DineInTableFormState,
|
||||
} from '#/views/store/dine-in/types';
|
||||
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { createAreaActions } from './dinein-page/area-actions';
|
||||
import {
|
||||
@@ -320,6 +320,8 @@ export function useStoreDineInPage() {
|
||||
|
||||
// 10. 页面首屏初始化。
|
||||
onMounted(loadStores);
|
||||
// 11. 路由回到当前页时刷新门店列表,避免使用旧缓存。
|
||||
onActivated(loadStores);
|
||||
|
||||
return {
|
||||
DINE_IN_SEATS_OPTIONS,
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
StoreFeesSettingsSnapshot,
|
||||
} from '#/views/store/fees/types';
|
||||
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
@@ -284,6 +284,8 @@ export function useStoreFeesPage() {
|
||||
|
||||
// 8. 页面首屏初始化。
|
||||
onMounted(loadStores);
|
||||
// 9. 路由回到当前页时刷新门店列表,避免使用旧缓存。
|
||||
onActivated(loadStores);
|
||||
|
||||
return {
|
||||
PACKAGING_MODE_OPTIONS,
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
import type { StoreListItemDto } from '#/api/store';
|
||||
import type { DayHoursDto, HolidayDto } from '#/api/store-hours';
|
||||
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { HolidayType, SlotType } from '#/api/store-hours';
|
||||
|
||||
@@ -282,6 +282,8 @@ export function useStoreHoursPage() {
|
||||
|
||||
// 12. 页面首屏初始化。
|
||||
onMounted(loadStores);
|
||||
// 13. 路由回到当前页时刷新门店列表,避免使用旧缓存。
|
||||
onActivated(loadStores);
|
||||
|
||||
return {
|
||||
DAY_NAMES,
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { StoreListItemDto } from '#/api/store';
|
||||
import { Button, Card, Popconfirm, Table, Tag } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
GeoLocationStatus as GeoLocationStatusEnum,
|
||||
StoreAuditStatus as StoreAuditStatusEnum,
|
||||
StoreBusinessStatus as StoreBusinessStatusEnum,
|
||||
} from '#/api/store';
|
||||
@@ -23,6 +24,7 @@ interface Props {
|
||||
auditStatusMap: Record<number, StatusTagMeta>;
|
||||
businessStatusMap: Record<number, StatusTagMeta>;
|
||||
columns: Array<Record<string, unknown>>;
|
||||
geoStatusMap: Record<number, StatusTagMeta>;
|
||||
getAvatarColor: (index: number) => string;
|
||||
isLoading: boolean;
|
||||
pagination: StoreTablePagination;
|
||||
@@ -35,6 +37,7 @@ const props = defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
(event: 'delete', record: StoreListItemDto): void;
|
||||
(event: 'edit', record: StoreListItemDto): void;
|
||||
(event: 'retryGeocode', record: StoreListItemDto): void;
|
||||
(event: 'toggleBusinessStatus', record: StoreListItemDto): void;
|
||||
(event: 'tableChange', pagination: TablePagination): void;
|
||||
}>();
|
||||
@@ -91,6 +94,24 @@ function emitToggleBusinessStatus(record: unknown) {
|
||||
emit('toggleBusinessStatus', record);
|
||||
}
|
||||
|
||||
/** 当前门店是否允许手动重试定位。 */
|
||||
function canRetryGeocode(record: unknown) {
|
||||
if (!isStoreRecord(record)) return false;
|
||||
return record.geoStatus !== GeoLocationStatusEnum.Success;
|
||||
}
|
||||
|
||||
/** 安全触发重试定位事件。 */
|
||||
function emitRetryGeocode(record: unknown) {
|
||||
if (!isStoreRecord(record)) return;
|
||||
emit('retryGeocode', record);
|
||||
}
|
||||
|
||||
/** 解析定位状态枚举值。 */
|
||||
function resolveGeoStatus(record: unknown) {
|
||||
if (!isStoreRecord(record)) return GeoLocationStatusEnum.Pending;
|
||||
return record.geoStatus ?? GeoLocationStatusEnum.Pending;
|
||||
}
|
||||
|
||||
/** 创建时间格式化为 yyyy-MM-dd。 */
|
||||
function formatCreatedDate(value: string) {
|
||||
if (!value) return '--';
|
||||
@@ -180,6 +201,15 @@ function formatCreatedDate(value: string) {
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'geoStatus'">
|
||||
<Tag
|
||||
:color="props.geoStatusMap[resolveGeoStatus(record)]?.color"
|
||||
:title="record.geoFailReason || ''"
|
||||
>
|
||||
{{ props.geoStatusMap[resolveGeoStatus(record)]?.text }}
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'createdAt'">
|
||||
<span>{{ formatCreatedDate(record.createdAt) }}</span>
|
||||
</template>
|
||||
@@ -197,6 +227,14 @@ function formatCreatedDate(value: string) {
|
||||
>
|
||||
{{ getToggleBusinessStatusText(record) }}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
:disabled="!canRetryGeocode(record)"
|
||||
@click="emitRetryGeocode(record)"
|
||||
>
|
||||
重试定位
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除该门店吗?"
|
||||
ok-text="确定"
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
} from '#/api/store';
|
||||
|
||||
import {
|
||||
GeoLocationStatus as GeoLocationStatusEnum,
|
||||
ServiceType as ServiceTypeEnum,
|
||||
StoreAuditStatus as StoreAuditStatusEnum,
|
||||
StoreBusinessStatus as StoreBusinessStatusEnum,
|
||||
@@ -95,6 +96,12 @@ export const serviceTypeMap: Record<number, StatusTagMeta> = {
|
||||
[ServiceTypeEnum.Pickup]: { color: 'green', text: '自提' },
|
||||
};
|
||||
|
||||
export const geoStatusMap: Record<number, StatusTagMeta> = {
|
||||
[GeoLocationStatusEnum.Pending]: { color: 'orange', text: '待定位' },
|
||||
[GeoLocationStatusEnum.Success]: { color: 'green', text: '已定位' },
|
||||
[GeoLocationStatusEnum.Failed]: { color: 'red', text: '定位失败' },
|
||||
};
|
||||
|
||||
export const businessStatusOptions: SelectOptionItem<StoreBusinessStatus>[] = [
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user