feat: 完成员工排班模块并统一门店抽屉底部操作样式

This commit is contained in:
2026-02-16 22:43:45 +08:00
parent aebd0c285b
commit becef7e6cb
43 changed files with 5127 additions and 53 deletions

View File

@@ -0,0 +1,919 @@
import Mock from 'mockjs';
/** 文件职责:员工排班页面 Mock 接口。 */
interface MockRequestOptions {
body: null | string;
type: string;
url: string;
}
type StaffRoleType = 'cashier' | 'chef' | 'courier' | 'manager';
type StaffStatus = 'active' | 'leave' | 'resigned';
type ShiftType = 'evening' | 'full' | 'morning' | 'off';
interface StoreStaffMock {
avatarColor: string;
email: string;
hiredAt: string;
id: string;
name: string;
permissions: string[];
phone: string;
roleType: StaffRoleType;
status: StaffStatus;
}
interface ShiftTemplateItemMock {
endTime: string;
startTime: string;
}
interface StoreShiftTemplatesMock {
evening: ShiftTemplateItemMock;
full: ShiftTemplateItemMock;
morning: ShiftTemplateItemMock;
}
interface StaffDayShiftMock {
dayOfWeek: number;
endTime: string;
shiftType: ShiftType;
startTime: string;
}
interface StaffScheduleMock {
shifts: StaffDayShiftMock[];
staffId: string;
}
interface StoreStaffState {
schedules: StaffScheduleMock[];
staffs: StoreStaffMock[];
templates: StoreShiftTemplatesMock;
weekStartDate: string;
}
const ROLE_VALUES = new Set<StaffRoleType>([
'cashier',
'chef',
'courier',
'manager',
]);
const STATUS_VALUES = new Set<StaffStatus>(['active', 'leave', 'resigned']);
const SHIFT_VALUES = new Set<ShiftType>(['evening', 'full', 'morning', 'off']);
const AVATAR_COLORS = [
'#f56a00',
'#7265e6',
'#52c41a',
'#fa8c16',
'#1890ff',
'#bfbfbf',
'#13c2c2',
'#eb2f96',
];
const DEFAULT_TEMPLATES: StoreShiftTemplatesMock = {
morning: {
startTime: '09:00',
endTime: '14:00',
},
evening: {
startTime: '14:00',
endTime: '21:00',
},
full: {
startTime: '09:00',
endTime: '21:00',
},
};
const DEFAULT_STAFFS: StoreStaffMock[] = [
{
id: 'staff-001',
name: '张伟',
phone: '13800008001',
email: 'zhangwei@example.com',
roleType: 'manager',
status: 'active',
permissions: ['全部权限'],
hiredAt: '2024-01-15',
avatarColor: '#f56a00',
},
{
id: 'staff-002',
name: '李娜',
phone: '13800008002',
email: 'lina@example.com',
roleType: 'cashier',
status: 'active',
permissions: ['收银', '退款'],
hiredAt: '2024-03-20',
avatarColor: '#7265e6',
},
{
id: 'staff-003',
name: '王磊',
phone: '13800008003',
email: '',
roleType: 'courier',
status: 'active',
permissions: ['配送管理'],
hiredAt: '2024-06-01',
avatarColor: '#52c41a',
},
{
id: 'staff-004',
name: '赵敏',
phone: '13800008004',
email: '',
roleType: 'chef',
status: 'active',
permissions: ['订单查看'],
hiredAt: '2024-08-10',
avatarColor: '#fa8c16',
},
{
id: 'staff-005',
name: '刘洋',
phone: '13800008005',
email: 'liuyang@example.com',
roleType: 'courier',
status: 'leave',
permissions: ['配送管理'],
hiredAt: '2025-01-05',
avatarColor: '#1890ff',
},
{
id: 'staff-006',
name: '陈静',
phone: '13800008006',
email: '',
roleType: 'cashier',
status: 'resigned',
permissions: [],
hiredAt: '2024-11-20',
avatarColor: '#bfbfbf',
},
];
const storeStaffMap = new Map<string, StoreStaffState>();
/** 解析 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-store-staff] parseBody error:', error);
return {};
}
}
/** 获取当前周一日期。 */
function getCurrentWeekStartDate(baseDate = new Date()) {
const date = new Date(baseDate);
const weekDay = date.getDay();
const diff = weekDay === 0 ? -6 : 1 - weekDay;
date.setDate(date.getDate() + diff);
return toDateOnly(date);
}
/** 日期转 yyyy-MM-dd。 */
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}`;
}
/** 归一化 HH:mm 时间。 */
function normalizeTime(value: unknown, fallback: string) {
const input = typeof value === 'string' ? value : '';
const matched = /^(\d{2}):(\d{2})$/.exec(input);
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 normalizeRoleType(value: unknown, fallback: StaffRoleType) {
return ROLE_VALUES.has(value as StaffRoleType)
? (value as StaffRoleType)
: fallback;
}
/** 归一化状态。 */
function normalizeStatus(value: unknown, fallback: StaffStatus) {
return STATUS_VALUES.has(value as StaffStatus)
? (value as StaffStatus)
: fallback;
}
/** 归一化班次类型。 */
function normalizeShiftType(value: unknown, fallback: ShiftType) {
return SHIFT_VALUES.has(value as ShiftType) ? (value as ShiftType) : fallback;
}
/** 深拷贝员工列表。 */
function cloneStaffs(source: StoreStaffMock[]) {
return source.map((item) => ({
...item,
permissions: [...item.permissions],
}));
}
/** 深拷贝模板。 */
function cloneTemplates(
source: StoreShiftTemplatesMock,
): StoreShiftTemplatesMock {
return {
morning: { ...source.morning },
evening: { ...source.evening },
full: { ...source.full },
};
}
/** 深拷贝排班。 */
function cloneSchedules(source: StaffScheduleMock[]) {
return source.map((item) => ({
staffId: item.staffId,
shifts: item.shifts.map((shift) => ({ ...shift })),
}));
}
/** 按入职时间稳定排序员工。 */
function sortStaffs(source: StoreStaffMock[]) {
return cloneStaffs(source).toSorted((a, b) => {
const dateDiff = a.hiredAt.localeCompare(b.hiredAt);
if (dateDiff !== 0) return dateDiff;
return a.name.localeCompare(b.name);
});
}
/** 通过班次类型生成单日排班。 */
function createDayShift(
dayOfWeek: number,
shiftType: ShiftType,
templates: StoreShiftTemplatesMock,
): StaffDayShiftMock {
if (shiftType === 'off') {
return {
dayOfWeek,
shiftType,
startTime: '',
endTime: '',
};
}
const template = templates[shiftType];
return {
dayOfWeek,
shiftType,
startTime: template.startTime,
endTime: template.endTime,
};
}
/** 生成默认 7 天排班。 */
function createDefaultWeekByRole(
roleType: StaffRoleType,
templates: StoreShiftTemplatesMock,
): StaffDayShiftMock[] {
const rolePatternMap: Record<StaffRoleType, ShiftType[]> = {
manager: ['full', 'full', 'full', 'full', 'full', 'morning', 'off'],
cashier: [
'morning',
'morning',
'off',
'morning',
'evening',
'full',
'full',
],
courier: [
'morning',
'evening',
'morning',
'evening',
'morning',
'evening',
'off',
],
chef: ['full', 'full', 'evening', 'off', 'full', 'full', 'morning'],
};
const pattern = rolePatternMap[roleType] ?? rolePatternMap.cashier;
return Array.from({ length: 7 }).map((_, dayOfWeek) =>
createDayShift(dayOfWeek, pattern[dayOfWeek] ?? 'off', templates),
);
}
/** 创建默认门店状态。 */
function createDefaultState(): StoreStaffState {
const templates = cloneTemplates(DEFAULT_TEMPLATES);
const staffs = sortStaffs(cloneStaffs(DEFAULT_STAFFS));
const schedules = staffs.map((staff) => ({
staffId: staff.id,
shifts:
staff.status === 'resigned'
? Array.from({ length: 7 }).map((_, dayOfWeek) =>
createDayShift(dayOfWeek, 'off', templates),
)
: createDefaultWeekByRole(staff.roleType, templates),
}));
return {
staffs,
templates,
schedules,
weekStartDate: getCurrentWeekStartDate(),
};
}
/** 确保门店状态存在。 */
function ensureStoreState(storeId = '') {
const key = storeId || 'default';
let state = storeStaffMap.get(key);
if (!state) {
state = createDefaultState();
storeStaffMap.set(key, state);
}
return state;
}
/** 构建员工排班索引。 */
function createScheduleMap(schedules: StaffScheduleMock[]) {
const scheduleMap = new Map<string, StaffDayShiftMock[]>();
for (const schedule of schedules) {
scheduleMap.set(
schedule.staffId,
schedule.shifts.map((shift) => ({ ...shift })),
);
}
return scheduleMap;
}
/** 同步模板后刷新已有排班的时间段。 */
function syncScheduleTimesWithTemplates(
schedules: StaffScheduleMock[],
templates: StoreShiftTemplatesMock,
) {
for (const schedule of schedules) {
schedule.shifts = schedule.shifts
.map((shift) => {
const normalizedType = normalizeShiftType(shift.shiftType, 'off');
if (normalizedType === 'off') {
return {
dayOfWeek: shift.dayOfWeek,
shiftType: 'off' as const,
startTime: '',
endTime: '',
};
}
return {
dayOfWeek: shift.dayOfWeek,
shiftType: normalizedType,
startTime: templates[normalizedType].startTime,
endTime: templates[normalizedType].endTime,
};
})
.toSorted((a, b) => a.dayOfWeek - b.dayOfWeek);
}
}
/** 归一化模板。 */
function normalizeTemplates(
input: unknown,
fallback: StoreShiftTemplatesMock,
): StoreShiftTemplatesMock {
const record = typeof input === 'object' && input ? input : {};
const morning =
typeof (record as { morning?: unknown }).morning === 'object' &&
(record as { morning?: unknown }).morning
? ((record as { morning: Record<string, unknown> }).morning as Record<
string,
unknown
>)
: {};
const evening =
typeof (record as { evening?: unknown }).evening === 'object' &&
(record as { evening?: unknown }).evening
? ((record as { evening: Record<string, unknown> }).evening as Record<
string,
unknown
>)
: {};
const full =
typeof (record as { full?: unknown }).full === 'object' &&
(record as { full?: unknown }).full
? ((record as { full: Record<string, unknown> }).full as Record<
string,
unknown
>)
: {};
return {
morning: {
startTime: normalizeTime(morning.startTime, fallback.morning.startTime),
endTime: normalizeTime(morning.endTime, fallback.morning.endTime),
},
evening: {
startTime: normalizeTime(evening.startTime, fallback.evening.startTime),
endTime: normalizeTime(evening.endTime, fallback.evening.endTime),
},
full: {
startTime: normalizeTime(full.startTime, fallback.full.startTime),
endTime: normalizeTime(full.endTime, fallback.full.endTime),
},
};
}
/** 归一化单员工 7 天排班。 */
function normalizeShifts(
input: unknown,
templates: StoreShiftTemplatesMock,
fallback: StaffDayShiftMock[],
): StaffDayShiftMock[] {
const byDay = new Map<number, StaffDayShiftMock>();
if (Array.isArray(input)) {
for (const rawShift of input) {
const shiftRecord =
typeof rawShift === 'object' && rawShift
? (rawShift as Record<string, unknown>)
: {};
const dayOfWeek = Number(shiftRecord.dayOfWeek);
if (!Number.isInteger(dayOfWeek) || dayOfWeek < 0 || dayOfWeek > 6) {
continue;
}
const shiftType = normalizeShiftType(shiftRecord.shiftType, 'off');
if (shiftType === 'off') {
byDay.set(dayOfWeek, {
dayOfWeek,
shiftType,
startTime: '',
endTime: '',
});
continue;
}
byDay.set(dayOfWeek, {
dayOfWeek,
shiftType,
startTime: normalizeTime(
shiftRecord.startTime,
templates[shiftType].startTime,
),
endTime: normalizeTime(
shiftRecord.endTime,
templates[shiftType].endTime,
),
});
}
}
return Array.from({ length: 7 }).map((_, dayOfWeek) => {
const normalized = byDay.get(dayOfWeek);
if (normalized) {
return normalized;
}
const fallbackShift = fallback.find((item) => item.dayOfWeek === dayOfWeek);
if (fallbackShift) {
return { ...fallbackShift };
}
return createDayShift(dayOfWeek, 'off', templates);
});
}
/** 规范手机号格式。 */
function normalizePhone(value: unknown) {
return String(value || '')
.replaceAll(/\D/g, '')
.slice(0, 11);
}
/** 规范权限列表。 */
function normalizePermissions(value: unknown, roleType: StaffRoleType) {
if (!Array.isArray(value)) {
return roleType === 'manager' ? ['全部权限'] : [];
}
const unique = [
...new Set(value.map((item) => String(item || '').trim()).filter(Boolean)),
];
if (roleType === 'manager' && unique.length === 0) {
return ['全部权限'];
}
return unique;
}
/** 生成员工 ID。 */
function createStaffId() {
return `staff-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
}
/** 生成头像颜色。 */
function resolveAvatarColor(seed: string) {
let hash = 0;
for (const char of seed) {
hash = (hash * 31 + (char.codePointAt(0) ?? 0)) >>> 0;
}
return AVATAR_COLORS[hash % AVATAR_COLORS.length] ?? '#1677ff';
}
/** 获取员工列表。 */
Mock.mock(/\/store\/staff(?:\?|$)/, 'get', (options: MockRequestOptions) => {
const params = parseUrlParams(options.url);
const storeId = String(params.storeId || '');
const state = ensureStoreState(storeId);
const keyword = String(params.keyword || '')
.trim()
.toLowerCase();
const roleType = params.roleType
? normalizeRoleType(params.roleType, 'cashier')
: '';
const status = params.status ? normalizeStatus(params.status, 'active') : '';
const page = Math.max(1, Number(params.page) || 1);
const pageSize = Math.max(1, Math.min(200, Number(params.pageSize) || 10));
const filtered = state.staffs.filter((staff) => {
if (keyword) {
const hitKeyword =
staff.name.toLowerCase().includes(keyword) ||
staff.phone.includes(keyword) ||
staff.email.toLowerCase().includes(keyword);
if (!hitKeyword) return false;
}
if (roleType && staff.roleType !== roleType) {
return false;
}
if (status && staff.status !== status) {
return false;
}
return true;
});
const start = (page - 1) * pageSize;
const items = filtered.slice(start, start + pageSize);
return {
code: 200,
data: {
items: cloneStaffs(items),
total: filtered.length,
page,
pageSize,
},
};
});
/** 新增 / 编辑员工。 */
Mock.mock(/\/store\/staff\/save/, 'post', (options: MockRequestOptions) => {
const body = parseBody(options);
const storeId = String(body.storeId || '');
if (!storeId) {
return { code: 200, data: null };
}
const state = ensureStoreState(storeId);
const id = String(body.id || '').trim();
const existingIndex = state.staffs.findIndex((item) => item.id === id);
const roleType = normalizeRoleType(body.roleType, 'cashier');
const status = normalizeStatus(body.status, 'active');
const nextStaff: StoreStaffMock = {
id: id || createStaffId(),
name: String(body.name || '').trim(),
phone: normalizePhone(body.phone),
email: String(body.email || '').trim(),
roleType,
status,
permissions: normalizePermissions(body.permissions, roleType),
hiredAt:
existingIndex === -1
? toDateOnly(new Date())
: state.staffs[existingIndex]?.hiredAt || toDateOnly(new Date()),
avatarColor:
existingIndex === -1
? resolveAvatarColor(String(body.name || Date.now()))
: state.staffs[existingIndex]?.avatarColor || '#1677ff',
};
if (!nextStaff.name) {
return {
code: 400,
data: null,
message: '员工姓名不能为空',
};
}
if (!nextStaff.phone || nextStaff.phone.length < 6) {
return {
code: 400,
data: null,
message: '手机号格式不正确',
};
}
if (existingIndex === -1) {
state.staffs.push(nextStaff);
state.staffs = sortStaffs(state.staffs);
state.schedules.push({
staffId: nextStaff.id,
shifts:
status === 'resigned'
? Array.from({ length: 7 }).map((_, dayOfWeek) =>
createDayShift(dayOfWeek, 'off', state.templates),
)
: createDefaultWeekByRole(roleType, state.templates),
});
} else {
state.staffs[existingIndex] = nextStaff;
state.staffs = sortStaffs(state.staffs);
const schedule = state.schedules.find(
(item) => item.staffId === nextStaff.id,
);
if (schedule && nextStaff.status === 'resigned') {
schedule.shifts = Array.from({ length: 7 }).map((_, dayOfWeek) =>
createDayShift(dayOfWeek, 'off', state.templates),
);
}
if (!schedule) {
state.schedules.push({
staffId: nextStaff.id,
shifts: createDefaultWeekByRole(nextStaff.roleType, state.templates),
});
}
}
return {
code: 200,
data: {
...nextStaff,
permissions: [...nextStaff.permissions],
},
};
});
/** 删除员工。 */
Mock.mock(/\/store\/staff\/delete/, 'post', (options: MockRequestOptions) => {
const body = parseBody(options);
const storeId = String(body.storeId || '');
const staffId = String(body.staffId || '');
if (!storeId || !staffId) {
return { code: 200, data: null };
}
const state = ensureStoreState(storeId);
state.staffs = state.staffs.filter((item) => item.id !== staffId);
state.schedules = state.schedules.filter((item) => item.staffId !== staffId);
return {
code: 200,
data: null,
};
});
/** 获取门店排班配置。 */
Mock.mock(
/\/store\/staff\/schedule(?:\?|$)/,
'get',
(options: MockRequestOptions) => {
const params = parseUrlParams(options.url);
const storeId = String(params.storeId || '');
const state = ensureStoreState(storeId);
return {
code: 200,
data: {
storeId,
templates: cloneTemplates(state.templates),
schedules: cloneSchedules(state.schedules),
weekStartDate: String(params.weekStartDate || state.weekStartDate),
},
};
},
);
/** 保存班次模板。 */
Mock.mock(
/\/store\/staff\/template\/save/,
'post',
(options: MockRequestOptions) => {
const body = parseBody(options);
const storeId = String(body.storeId || '');
if (!storeId) {
return { code: 200, data: cloneTemplates(DEFAULT_TEMPLATES) };
}
const state = ensureStoreState(storeId);
state.templates = normalizeTemplates(body.templates, state.templates);
syncScheduleTimesWithTemplates(state.schedules, state.templates);
return {
code: 200,
data: cloneTemplates(state.templates),
};
},
);
/** 保存员工个人排班。 */
Mock.mock(
/\/store\/staff\/schedule\/personal\/save/,
'post',
(options: MockRequestOptions) => {
const body = parseBody(options);
const storeId = String(body.storeId || '');
const staffId = String(body.staffId || '');
if (!storeId || !staffId) {
return { code: 200, data: null };
}
const state = ensureStoreState(storeId);
const staff = state.staffs.find((item) => item.id === staffId);
if (!staff) {
return {
code: 400,
data: null,
message: '员工不存在',
};
}
const schedule = state.schedules.find((item) => item.staffId === staffId);
const fallbackShifts =
schedule?.shifts ??
createDefaultWeekByRole(staff.roleType, state.templates);
const nextShifts =
staff.status === 'resigned'
? Array.from({ length: 7 }).map((_, dayOfWeek) =>
createDayShift(dayOfWeek, 'off', state.templates),
)
: normalizeShifts(body.shifts, state.templates, fallbackShifts);
if (schedule) {
schedule.shifts = nextShifts;
} else {
state.schedules.push({
staffId,
shifts: nextShifts,
});
}
return {
code: 200,
data: {
staffId,
shifts: nextShifts.map((item) => ({ ...item })),
},
};
},
);
/** 保存周排班。 */
Mock.mock(
/\/store\/staff\/schedule\/weekly\/save/,
'post',
(options: MockRequestOptions) => {
const body = parseBody(options);
const storeId = String(body.storeId || '');
if (!storeId) {
return { code: 200, data: null };
}
const state = ensureStoreState(storeId);
const incomingList = Array.isArray(body.schedules) ? body.schedules : [];
const scheduleMap = createScheduleMap(state.schedules);
for (const incoming of incomingList) {
const record =
typeof incoming === 'object' && incoming
? (incoming as Record<string, unknown>)
: {};
const staffId = String(record.staffId || '');
if (!staffId) continue;
const staff = state.staffs.find((item) => item.id === staffId);
if (!staff || staff.status === 'resigned') continue;
const fallbackShifts =
scheduleMap.get(staffId) ??
createDefaultWeekByRole(staff.roleType, state.templates);
const nextShifts = normalizeShifts(
record.shifts,
state.templates,
fallbackShifts,
);
scheduleMap.set(staffId, nextShifts);
}
state.schedules = state.staffs.map((staff) => {
const baseShifts =
scheduleMap.get(staff.id) ??
createDefaultWeekByRole(staff.roleType, state.templates);
return {
staffId: staff.id,
shifts:
staff.status === 'resigned'
? Array.from({ length: 7 }).map((_, dayOfWeek) =>
createDayShift(dayOfWeek, 'off', state.templates),
)
: baseShifts.map((shift) => ({ ...shift })),
};
});
return {
code: 200,
data: {
storeId,
templates: cloneTemplates(state.templates),
schedules: cloneSchedules(state.schedules),
weekStartDate: state.weekStartDate,
},
};
},
);
/** 复制门店模板与排班。 */
Mock.mock(/\/store\/staff\/copy/, 'post', (options: MockRequestOptions) => {
const body = parseBody(options);
const sourceStoreId = String(body.sourceStoreId || '');
const copyScope = String(body.copyScope || '');
const targetStoreIds = Array.isArray(body.targetStoreIds)
? body.targetStoreIds.map(String).filter(Boolean)
: [];
if (
!sourceStoreId ||
copyScope !== 'template_and_schedule' ||
targetStoreIds.length === 0
) {
return {
code: 200,
data: { copiedCount: 0 },
};
}
const sourceState = ensureStoreState(sourceStoreId);
const sourceScheduleMap = createScheduleMap(sourceState.schedules);
const uniqueTargets = [...new Set(targetStoreIds)].filter(
(id) => id !== sourceStoreId,
);
for (const targetStoreId of uniqueTargets) {
const targetState = ensureStoreState(targetStoreId);
targetState.templates = cloneTemplates(sourceState.templates);
targetState.schedules = targetState.staffs.map((staff) => {
const targetShiftFallback =
sourceScheduleMap.get(staff.id) ??
createDefaultWeekByRole(staff.roleType, targetState.templates);
return {
staffId: staff.id,
shifts:
staff.status === 'resigned'
? Array.from({ length: 7 }).map((_, dayOfWeek) =>
createDayShift(dayOfWeek, 'off', targetState.templates),
)
: normalizeShifts(
targetShiftFallback,
targetState.templates,
targetShiftFallback,
),
};
});
}
return {
code: 200,
data: {
copiedCount: uniqueTargets.length,
copyScope: 'template_and_schedule',
},
};
});