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

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