feat: 完成门店配置拆分并新增配送与自提设置页面
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
// Mock 数据入口,仅在开发环境下使用
|
||||
import './store';
|
||||
import './store-hours';
|
||||
import './store-pickup';
|
||||
|
||||
console.warn('[Mock] Mock 数据已启用');
|
||||
|
||||
582
apps/web-antd/src/mock/store-pickup.ts
Normal file
582
apps/web-antd/src/mock/store-pickup.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user