feat: 完成门店配置拆分并新增配送与自提设置页面

This commit is contained in:
2026-02-16 14:39:11 +08:00
parent 07495f8c35
commit 8d1325edf0
63 changed files with 6827 additions and 368 deletions

View File

@@ -1,5 +1,6 @@
// Mock 数据入口,仅在开发环境下使用
import './store';
import './store-hours';
import './store-pickup';
console.warn('[Mock] Mock 数据已启用');

View File

@@ -0,0 +1,582 @@
import Mock from 'mockjs';
const Random = Mock.Random;
/** 文件职责:自提设置页面 Mock 接口。 */
interface MockRequestOptions {
body: null | string;
type: string;
url: string;
}
type PickupMode = 'big' | 'fine';
type PickupWeekDay = 0 | 1 | 2 | 3 | 4 | 5 | 6;
type PickupPreviewStatus = 'almost' | 'available' | 'expired' | 'full';
interface PickupBasicSettingsMock {
allowSameDayPickup: boolean;
bookingDays: number;
maxItemsPerOrder: null | number;
}
interface PickupSlotMock {
capacity: number;
cutoffMinutes: number;
dayOfWeeks: PickupWeekDay[];
enabled: boolean;
endTime: string;
id: string;
name: string;
reservedCount: number;
startTime: string;
}
interface PickupFineRuleMock {
dayEndTime: string;
dayOfWeeks: PickupWeekDay[];
dayStartTime: string;
intervalMinutes: number;
minAdvanceHours: number;
slotCapacity: number;
}
interface PickupPreviewSlotMock {
remainingCount: number;
status: PickupPreviewStatus;
time: string;
}
interface PickupPreviewDayMock {
date: string;
label: string;
slots: PickupPreviewSlotMock[];
subLabel: string;
}
interface StorePickupState {
basicSettings: PickupBasicSettingsMock;
bigSlots: PickupSlotMock[];
fineRule: PickupFineRuleMock;
mode: PickupMode;
previewDays: PickupPreviewDayMock[];
}
const ALL_WEEK_DAYS: PickupWeekDay[] = [0, 1, 2, 3, 4, 5, 6];
const WEEKDAY_ONLY: PickupWeekDay[] = [0, 1, 2, 3, 4];
const WEEKEND_ONLY: PickupWeekDay[] = [5, 6];
const WEEKDAY_LABEL_MAP: Record<PickupWeekDay, string> = {
0: '周一',
1: '周二',
2: '周三',
3: '周四',
4: '周五',
5: '周六',
6: '周日',
};
const storePickupMap = new Map<string, StorePickupState>();
/** 解析 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);
} catch (error) {
console.error('[mock-store-pickup] parseBody error:', error);
return {};
}
}
/** 确保门店状态存在。 */
function ensureStoreState(storeId = '') {
const key = storeId || 'default';
let state = storePickupMap.get(key);
if (!state) {
state = createDefaultState();
storePickupMap.set(key, state);
}
return state;
}
function createDefaultState(): StorePickupState {
const fineRule: PickupFineRuleMock = {
intervalMinutes: 30,
slotCapacity: 5,
dayStartTime: '09:00',
dayEndTime: '20:30',
minAdvanceHours: 2,
dayOfWeeks: [...ALL_WEEK_DAYS],
};
return {
mode: 'big',
basicSettings: {
allowSameDayPickup: true,
bookingDays: 3,
maxItemsPerOrder: 20,
},
bigSlots: sortSlots([
{
id: Random.guid(),
name: '上午时段',
startTime: '09:00',
endTime: '11:30',
cutoffMinutes: 30,
capacity: 20,
reservedCount: 5,
dayOfWeeks: [...WEEKDAY_ONLY],
enabled: true,
},
{
id: Random.guid(),
name: '午间时段',
startTime: '11:30',
endTime: '14:00',
cutoffMinutes: 20,
capacity: 30,
reservedCount: 12,
dayOfWeeks: [...ALL_WEEK_DAYS],
enabled: true,
},
{
id: Random.guid(),
name: '下午时段',
startTime: '14:00',
endTime: '17:00',
cutoffMinutes: 30,
capacity: 15,
reservedCount: 3,
dayOfWeeks: [...WEEKDAY_ONLY],
enabled: true,
},
{
id: Random.guid(),
name: '晚间时段',
startTime: '17:00',
endTime: '20:30',
cutoffMinutes: 30,
capacity: 25,
reservedCount: 8,
dayOfWeeks: [...ALL_WEEK_DAYS],
enabled: true,
},
{
id: Random.guid(),
name: '周末特惠',
startTime: '10:00',
endTime: '15:00',
cutoffMinutes: 45,
capacity: 40,
reservedCount: 18,
dayOfWeeks: [...WEEKEND_ONLY],
enabled: false,
},
]),
fineRule,
previewDays: generatePreviewDays(fineRule),
};
}
/** 深拷贝基础设置。 */
function cloneBasicSettings(source: PickupBasicSettingsMock) {
return { ...source };
}
/** 深拷贝大时段列表。 */
function cloneBigSlots(source: PickupSlotMock[]) {
return source.map((item) => ({
...item,
dayOfWeeks: [...item.dayOfWeeks],
}));
}
/** 深拷贝精细规则。 */
function cloneFineRule(source: PickupFineRuleMock) {
return {
...source,
dayOfWeeks: [...source.dayOfWeeks],
};
}
/** 深拷贝预览日列表。 */
function clonePreviewDays(source: PickupPreviewDayMock[]) {
return source.map((day) => ({
...day,
slots: day.slots.map((slot) => ({ ...slot })),
}));
}
/** 深拷贝门店配置。 */
function cloneStoreState(source: StorePickupState): StorePickupState {
return {
mode: source.mode,
basicSettings: cloneBasicSettings(source.basicSettings),
bigSlots: cloneBigSlots(source.bigSlots),
fineRule: cloneFineRule(source.fineRule),
previewDays: clonePreviewDays(source.previewDays),
};
}
/** 归一化基础设置提交数据。 */
function normalizeBasicSettings(source: any): PickupBasicSettingsMock {
return {
allowSameDayPickup: Boolean(source?.allowSameDayPickup),
bookingDays: clampInt(source?.bookingDays, 1, 30, 3),
maxItemsPerOrder:
source?.maxItemsPerOrder === null ||
source?.maxItemsPerOrder === undefined
? null
: clampInt(source?.maxItemsPerOrder, 0, 999, 20),
};
}
/** 归一化精细规则提交数据。 */
function normalizeFineRule(source: any): PickupFineRuleMock {
return {
intervalMinutes: clampInt(source?.intervalMinutes, 5, 180, 30),
slotCapacity: clampInt(source?.slotCapacity, 1, 999, 5),
dayStartTime: normalizeTime(source?.dayStartTime, '09:00'),
dayEndTime: normalizeTime(source?.dayEndTime, '20:30'),
minAdvanceHours: clampInt(source?.minAdvanceHours, 0, 72, 2),
dayOfWeeks: normalizeDayOfWeeks(source?.dayOfWeeks, [...ALL_WEEK_DAYS]),
};
}
/** 归一化大时段提交数据。 */
function normalizeSlots(source: any, previous: PickupSlotMock[]) {
const reservedMap = new Map(
previous.map((item) => [item.id, item.reservedCount]),
);
if (!Array.isArray(source) || source.length === 0) return [];
return sortSlots(
source.map((item, index) => {
const id = String(item?.id || Random.guid());
const capacity = clampInt(item?.capacity, 0, 9999, 0);
const existingReserved = reservedMap.get(id) ?? 0;
const incomingReserved = clampInt(
item?.reservedCount,
0,
capacity,
existingReserved,
);
return {
id,
name: String(item?.name || `时段${index + 1}`).trim(),
startTime: normalizeTime(item?.startTime, '09:00'),
endTime: normalizeTime(item?.endTime, '17:00'),
cutoffMinutes: clampInt(item?.cutoffMinutes, 0, 720, 30),
capacity,
reservedCount: Math.min(capacity, incomingReserved),
dayOfWeeks: normalizeDayOfWeeks(item?.dayOfWeeks, [...WEEKDAY_ONLY]),
enabled: Boolean(item?.enabled),
};
}),
);
}
/** 归一化模式字段。 */
function normalizeMode(mode: unknown, fallback: PickupMode): PickupMode {
return mode === 'fine' || mode === 'big' ? mode : fallback;
}
/** 排序大时段。 */
function sortSlots(source: PickupSlotMock[]) {
return source.toSorted((a, b) => {
const diff =
parseTimeToMinutes(a.startTime) - parseTimeToMinutes(b.startTime);
if (diff !== 0) return diff;
return a.name.localeCompare(b.name);
});
}
/** 归一化 HH:mm 时间。 */
function normalizeTime(time: unknown, fallback: string) {
const value = typeof time === 'string' ? time : '';
const matched = /(\d{2}):(\d{2})/.exec(value);
if (!matched) return fallback;
const hour = Number(matched[1]);
const minute = Number(matched[2]);
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return fallback;
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
/** 归一化星期数组。 */
function normalizeDayOfWeeks(source: unknown, fallback: PickupWeekDay[]) {
if (!Array.isArray(source)) return fallback;
const values = source
.map(Number)
.filter((item) => Number.isInteger(item) && item >= 0 && item <= 6)
.map((item) => item as PickupWeekDay);
const unique = [...new Set(values)].toSorted((a, b) => a - b);
return unique.length > 0 ? unique : fallback;
}
/** 数值裁剪为整数区间。 */
function clampInt(value: unknown, min: number, max: number, fallback: number) {
const numberValue = Number(value);
if (!Number.isFinite(numberValue)) return fallback;
const normalized = Math.floor(numberValue);
return Math.max(min, Math.min(max, normalized));
}
/** HH:mm 转分钟。 */
function parseTimeToMinutes(time: string) {
const matched = /^(\d{2}):(\d{2})$/.exec(time);
if (!matched) return Number.NaN;
return Number(matched[1]) * 60 + Number(matched[2]);
}
/** 生成三天预览数据。 */
function generatePreviewDays(
fineRule: PickupFineRuleMock,
baseDate = new Date(),
) {
const startMinutes = parseTimeToMinutes(fineRule.dayStartTime);
const endMinutes = parseTimeToMinutes(fineRule.dayEndTime);
if (!Number.isFinite(startMinutes) || !Number.isFinite(endMinutes)) return [];
if (endMinutes <= startMinutes || fineRule.intervalMinutes <= 0) return [];
return Array.from({ length: 3 }).map((_, index) => {
const date = addDays(baseDate, index);
const dateKey = toDateOnly(date);
const dayOfWeek = toPickupWeekDay(date);
const slots = fineRule.dayOfWeeks.includes(dayOfWeek)
? generateDaySlots({
date,
dateKey,
fineRule,
})
: [];
return {
date: dateKey,
label: `${date.getMonth() + 1}/${date.getDate()}`,
subLabel: resolvePreviewSubLabel(index, dayOfWeek),
slots,
};
});
}
/** 生成某天时段预览。 */
function generateDaySlots(payload: {
date: Date;
dateKey: string;
fineRule: PickupFineRuleMock;
}) {
const startMinutes = parseTimeToMinutes(payload.fineRule.dayStartTime);
const endMinutes = parseTimeToMinutes(payload.fineRule.dayEndTime);
const interval = payload.fineRule.intervalMinutes;
const total = Math.floor((endMinutes - startMinutes) / interval);
return Array.from({ length: total + 1 }).map((_, index) => {
const minutes = startMinutes + index * interval;
const time = `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(
minutes % 60,
).padStart(2, '0')}`;
const booked = calcMockBookedCount(
`${payload.dateKey}|${time}`,
payload.fineRule.slotCapacity,
);
const remainingCount = Math.max(0, payload.fineRule.slotCapacity - booked);
return {
time,
remainingCount,
status: resolvePreviewStatus({
date: payload.date,
fineRule: payload.fineRule,
remainingCount,
time,
}),
};
});
}
/** 计算时段预览状态。 */
function resolvePreviewStatus(payload: {
date: Date;
fineRule: PickupFineRuleMock;
remainingCount: number;
time: string;
}): PickupPreviewStatus {
const now = new Date();
const today = toDateOnly(now);
const dateKey = toDateOnly(payload.date);
const slotMinutes = parseTimeToMinutes(payload.time);
const nowMinutes = now.getHours() * 60 + now.getMinutes();
const minAdvanceMinutes = payload.fineRule.minAdvanceHours * 60;
if (dateKey < today) return 'expired';
if (dateKey === today && slotMinutes - nowMinutes <= minAdvanceMinutes) {
return 'expired';
}
if (payload.remainingCount <= 0) return 'full';
if (payload.remainingCount <= 1) return 'almost';
return 'available';
}
/** 计算稳定的伪随机已预约量。 */
function calcMockBookedCount(seed: string, capacity: number) {
if (capacity <= 0) return 0;
let hash = 0;
for (const char of seed) {
hash = (hash * 31 + (char.codePointAt(0) ?? 0)) >>> 0;
}
if (hash % 7 === 0) return capacity;
if (hash % 5 === 0) return Math.max(0, capacity - 1);
return hash % (capacity + 1);
}
function toDateOnly(date: Date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function addDays(baseDate: Date, days: number) {
const next = new Date(baseDate);
next.setDate(baseDate.getDate() + days);
return next;
}
function toPickupWeekDay(date: Date): PickupWeekDay {
const mapping: PickupWeekDay[] = [6, 0, 1, 2, 3, 4, 5];
return mapping[date.getDay()] ?? 0;
}
function resolvePreviewSubLabel(offset: number, dayOfWeek: PickupWeekDay) {
const dayText = WEEKDAY_LABEL_MAP[dayOfWeek];
if (offset === 0) return `${dayText} 今天`;
if (offset === 1) return `${dayText} 明天`;
if (offset === 2) return `${dayText} 后天`;
return dayText;
}
// 获取门店自提设置
Mock.mock(/\/store\/pickup(?:\?|$)/, 'get', (options: MockRequestOptions) => {
const params = parseUrlParams(options.url);
const storeId = String(params.storeId || '');
const state = ensureStoreState(storeId);
state.previewDays = generatePreviewDays(state.fineRule);
return {
code: 200,
data: {
storeId,
mode: state.mode,
basicSettings: cloneBasicSettings(state.basicSettings),
bigSlots: cloneBigSlots(state.bigSlots),
fineRule: cloneFineRule(state.fineRule),
previewDays: clonePreviewDays(state.previewDays),
},
};
});
// 保存自提基础设置
Mock.mock(
/\/store\/pickup\/basic\/save/,
'post',
(options: MockRequestOptions) => {
const body = parseBody(options);
const storeId = String(body.storeId || '');
if (!storeId) return { code: 200, data: null };
const state = ensureStoreState(storeId);
state.basicSettings = normalizeBasicSettings(body.basicSettings);
state.mode = normalizeMode(body.mode, state.mode);
return {
code: 200,
data: null,
};
},
);
// 保存自提大时段
Mock.mock(
/\/store\/pickup\/slots\/save/,
'post',
(options: MockRequestOptions) => {
const body = parseBody(options);
const storeId = String(body.storeId || '');
if (!storeId) return { code: 200, data: null };
const state = ensureStoreState(storeId);
state.bigSlots = normalizeSlots(body.slots, state.bigSlots);
state.mode = normalizeMode(body.mode, state.mode);
return {
code: 200,
data: null,
};
},
);
// 保存自提精细规则
Mock.mock(
/\/store\/pickup\/fine-rule\/save/,
'post',
(options: MockRequestOptions) => {
const body = parseBody(options);
const storeId = String(body.storeId || '');
if (!storeId) return { code: 200, data: null };
const state = ensureStoreState(storeId);
state.fineRule = normalizeFineRule(body.fineRule);
state.mode = normalizeMode(body.mode, state.mode);
state.previewDays = generatePreviewDays(state.fineRule);
return {
code: 200,
data: null,
};
},
);
// 复制门店自提设置
Mock.mock(/\/store\/pickup\/copy/, 'post', (options: MockRequestOptions) => {
const body = parseBody(options);
const sourceStoreId = String(body.sourceStoreId || '');
const targetStoreIds: string[] = Array.isArray(body.targetStoreIds)
? body.targetStoreIds.map(String).filter(Boolean)
: [];
if (!sourceStoreId || targetStoreIds.length === 0) {
return {
code: 200,
data: { copiedCount: 0 },
};
}
const sourceState = ensureStoreState(sourceStoreId);
const uniqueTargets = [...new Set(targetStoreIds)].filter(
(storeId) => storeId !== sourceStoreId,
);
for (const targetStoreId of uniqueTargets) {
storePickupMap.set(targetStoreId, cloneStoreState(sourceState));
}
return {
code: 200,
data: {
copiedCount: uniqueTargets.length,
},
};
});