feat(project): align store delivery pages with live APIs and geocode status

This commit is contained in:
2026-02-19 17:15:19 +08:00
parent 435626ca55
commit 3b96b3f92d
62 changed files with 6813 additions and 250 deletions

View File

@@ -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

View File

@@ -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');
}

View 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,
);
}

View File

@@ -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`);
}

View File

@@ -60,3 +60,9 @@ export enum MerchantAuditAction {
Unknown = 0,
Update = 2,
}
export enum GeoLocationStatus {
Failed = 2,
Pending = 0,
Success = 1,
}

View File

@@ -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,
}

View 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,
},
};
});

View 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;

View 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>

View 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;
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,
};
}

View File

@@ -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' },
];

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View 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>

View 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;
}
}

View 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%;
}

View 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;
}

View 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';

View 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;
}

View 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;
}
}
}

View 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;
}
}

View 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;
}
}

View 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;
}

View File

@@ -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;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return labels;
});
</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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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],
]),
},
];

View File

@@ -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),

View File

@@ -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;
}

View File

@@ -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),

View File

@@ -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,
};
}

View File

@@ -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,

View File

@@ -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('未配置腾讯地图 KeyVITE_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;
}

View File

@@ -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,13 +111,17 @@ const {
<template v-else>
<Spin :spinning="isPageLoading">
<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"
:radius-tiers="radiusTiers"
@change-active-mode="setDeliveryMode"
@change-config-mode="setEditingMode"
@change-radius-center-latitude="setRadiusCenterLatitude"
@change-radius-center-longitude="setRadiusCenterLongitude"
/>
<RadiusTierSection
@@ -142,6 +155,7 @@ const {
@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"
/>

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -56,4 +56,10 @@
vertical-align: middle;
border-radius: 3px;
}
.zone-shape-count {
margin-left: 4px;
font-size: 12px;
color: #64748b;
}
}

View File

@@ -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[];
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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="确定"

View File

@@ -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 },
];

View File

@@ -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,

View File

@@ -33,6 +33,7 @@ import {
DEFAULT_FORM_STATE,
DEFAULT_PAGINATION,
DEFAULT_STATS,
geoStatusMap,
serviceTypeMap,
serviceTypeOptions,
THEME_COLORS,
@@ -108,8 +109,13 @@ export function useStoreListPage() {
});
// 6. 组装抽屉与删除动作。
const { handleDelete, handleSubmit, handleToggleBusinessStatus, openDrawer } =
createDrawerActions({
const {
handleDelete,
handleRetryGeocode,
handleSubmit,
handleToggleBusinessStatus,
openDrawer,
} = createDrawerActions({
drawerMode,
formState,
isDrawerVisible,
@@ -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,

View File

@@ -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"
/>

View File

@@ -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,

View File

@@ -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,