diff --git a/apps/web-antd/src/api/store-staff/index.ts b/apps/web-antd/src/api/store-staff/index.ts new file mode 100644 index 0000000..c62dbed --- /dev/null +++ b/apps/web-antd/src/api/store-staff/index.ts @@ -0,0 +1,186 @@ +/** + * 文件职责:员工排班模块 API 与 DTO 定义。 + * 1. 维护员工、班次模板、排班表类型。 + * 2. 提供查询、保存、删除、复制接口。 + */ +import type { PaginatedResult } from '#/api/store'; + +import { requestClient } from '#/api/request'; + +/** 员工角色 */ +export type StaffRoleType = 'cashier' | 'chef' | 'courier' | 'manager'; + +/** 员工状态 */ +export type StaffStatus = 'active' | 'leave' | 'resigned'; + +/** 班次类型 */ +export type ShiftType = 'evening' | 'full' | 'morning' | 'off'; + +/** 员工档案 */ +export interface StoreStaffDto { + avatarColor: string; + email: string; + hiredAt: string; + id: string; + name: string; + permissions: string[]; + phone: string; + roleType: StaffRoleType; + status: StaffStatus; +} + +/** 班次模板条目 */ +export interface ShiftTemplateItemDto { + endTime: string; + startTime: string; +} + +/** 门店班次模板 */ +export interface StoreShiftTemplatesDto { + evening: ShiftTemplateItemDto; + full: ShiftTemplateItemDto; + morning: ShiftTemplateItemDto; +} + +/** 单日排班 */ +export interface StaffDayShiftDto { + dayOfWeek: number; + endTime: string; + shiftType: ShiftType; + startTime: string; +} + +/** 员工排班 */ +export interface StaffScheduleDto { + shifts: StaffDayShiftDto[]; + staffId: string; +} + +/** 门店排班聚合 */ +export interface StoreStaffScheduleDto { + schedules: StaffScheduleDto[]; + storeId: string; + templates: StoreShiftTemplatesDto; + weekStartDate: string; +} + +/** 员工列表查询参数 */ +export interface StoreStaffListQuery { + keyword?: string; + page: number; + pageSize: number; + roleType?: StaffRoleType; + status?: StaffStatus; + storeId: string; +} + +/** 保存员工参数 */ +export interface SaveStoreStaffParams { + email: string; + id?: string; + name: string; + permissions: string[]; + phone: string; + roleType: StaffRoleType; + status: StaffStatus; + storeId: string; +} + +/** 删除员工参数 */ +export interface DeleteStoreStaffParams { + staffId: string; + storeId: string; +} + +/** 保存班次模板参数 */ +export interface SaveStoreStaffTemplatesParams { + storeId: string; + templates: StoreShiftTemplatesDto; +} + +/** 保存个人排班参数 */ +export interface SaveStoreStaffPersonalScheduleParams { + shifts: StaffDayShiftDto[]; + staffId: string; + storeId: string; +} + +/** 保存周排班参数 */ +export interface SaveStoreStaffWeeklyScheduleParams { + schedules: StaffScheduleDto[]; + storeId: string; +} + +/** 复制排班参数 */ +export interface CopyStoreStaffScheduleParams { + copyScope: 'template_and_schedule'; + sourceStoreId: string; + targetStoreIds: string[]; +} + +/** 获取员工列表 */ +export async function getStoreStaffListApi(params: StoreStaffListQuery) { + return requestClient.get>('/store/staff', { + params, + }); +} + +/** 新增/编辑员工 */ +export async function saveStoreStaffApi(data: SaveStoreStaffParams) { + return requestClient.post('/store/staff/save', data); +} + +/** 删除员工 */ +export async function deleteStoreStaffApi(data: DeleteStoreStaffParams) { + return requestClient.post('/store/staff/delete', data); +} + +/** 获取门店排班配置 */ +export async function getStoreStaffScheduleApi( + storeId: string, + weekStartDate?: string, +) { + return requestClient.get('/store/staff/schedule', { + params: { + storeId, + weekStartDate, + }, + }); +} + +/** 保存班次模板 */ +export async function saveStoreStaffTemplatesApi( + data: SaveStoreStaffTemplatesParams, +) { + return requestClient.post( + '/store/staff/template/save', + data, + ); +} + +/** 保存单员工排班 */ +export async function saveStoreStaffPersonalScheduleApi( + data: SaveStoreStaffPersonalScheduleParams, +) { + return requestClient.post( + '/store/staff/schedule/personal/save', + data, + ); +} + +/** 保存周排班 */ +export async function saveStoreStaffWeeklyScheduleApi( + data: SaveStoreStaffWeeklyScheduleParams, +) { + return requestClient.post( + '/store/staff/schedule/weekly/save', + data, + ); +} + +/** 复制班次模板与排班 */ +export async function copyStoreStaffScheduleApi( + data: CopyStoreStaffScheduleParams, +) { + return requestClient.post('/store/staff/copy', data); +} diff --git a/apps/web-antd/src/mock/index.ts b/apps/web-antd/src/mock/index.ts index a1bfff0..de82b2d 100644 --- a/apps/web-antd/src/mock/index.ts +++ b/apps/web-antd/src/mock/index.ts @@ -4,5 +4,6 @@ import './store-dinein'; import './store-fees'; import './store-hours'; import './store-pickup'; +import './store-staff'; console.warn('[Mock] Mock 数据已启用'); diff --git a/apps/web-antd/src/mock/store-staff.ts b/apps/web-antd/src/mock/store-staff.ts new file mode 100644 index 0000000..b1fef3a --- /dev/null +++ b/apps/web-antd/src/mock/store-staff.ts @@ -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([ + 'cashier', + 'chef', + 'courier', + 'manager', +]); +const STATUS_VALUES = new Set(['active', 'leave', 'resigned']); +const SHIFT_VALUES = new Set(['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(); + +/** 解析 URL 查询参数。 */ +function parseUrlParams(url: string) { + const parsed = new URL(url, 'http://localhost'); + const params: Record = {}; + 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; + } 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 = { + 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(); + 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 }).morning as Record< + string, + unknown + >) + : {}; + const evening = + typeof (record as { evening?: unknown }).evening === 'object' && + (record as { evening?: unknown }).evening + ? ((record as { evening: Record }).evening as Record< + string, + unknown + >) + : {}; + const full = + typeof (record as { full?: unknown }).full === 'object' && + (record as { full?: unknown }).full + ? ((record as { full: Record }).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(); + + if (Array.isArray(input)) { + for (const rawShift of input) { + const shiftRecord = + typeof rawShift === 'object' && rawShift + ? (rawShift as Record) + : {}; + 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) + : {}; + 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', + }, + }; +}); diff --git a/apps/web-antd/src/router/routes/modules/store.ts b/apps/web-antd/src/router/routes/modules/store.ts index 73deef6..2bffad8 100644 --- a/apps/web-antd/src/router/routes/modules/store.ts +++ b/apps/web-antd/src/router/routes/modules/store.ts @@ -55,6 +55,15 @@ const routes: RouteRecordRaw[] = [ title: '费用设置', }, }, + { + name: 'StoreStaff', + path: '/store/staff', + component: () => import('#/views/store/staff/index.vue'), + meta: { + icon: 'lucide:users', + title: '员工排班', + }, + }, { name: 'StoreDineIn', path: '/store/dine-in', diff --git a/apps/web-antd/src/views/store/delivery/components/DeliveryTierDrawer.vue b/apps/web-antd/src/views/store/delivery/components/DeliveryTierDrawer.vue index 81a97a8..ff8e3cf 100644 --- a/apps/web-antd/src/views/store/delivery/components/DeliveryTierDrawer.vue +++ b/apps/web-antd/src/views/store/delivery/components/DeliveryTierDrawer.vue @@ -154,7 +154,9 @@ function toNumber(value: null | number | string, fallback = 0) { diff --git a/apps/web-antd/src/views/store/delivery/components/DeliveryZoneDrawer.vue b/apps/web-antd/src/views/store/delivery/components/DeliveryZoneDrawer.vue index 9ebd9a3..88d01ef 100644 --- a/apps/web-antd/src/views/store/delivery/components/DeliveryZoneDrawer.vue +++ b/apps/web-antd/src/views/store/delivery/components/DeliveryZoneDrawer.vue @@ -153,7 +153,9 @@ function readInputValue(event: Event) { diff --git a/apps/web-antd/src/views/store/delivery/styles/drawer.less b/apps/web-antd/src/views/store/delivery/styles/drawer.less index 67eb3df..724adfc 100644 --- a/apps/web-antd/src/views/store/delivery/styles/drawer.less +++ b/apps/web-antd/src/views/store/delivery/styles/drawer.less @@ -80,6 +80,14 @@ .drawer-footer { display: flex; - gap: 10px; + gap: 12px; justify-content: flex-end; } + +.drawer-footer .ant-btn { + min-width: 96px; + height: 40px; + padding: 0 24px; + font-size: 15px; + border-radius: 10px; +} diff --git a/apps/web-antd/src/views/store/dine-in/components/DineInAreaDrawer.vue b/apps/web-antd/src/views/store/dine-in/components/DineInAreaDrawer.vue index 570ec7f..ec11e0f 100644 --- a/apps/web-antd/src/views/store/dine-in/components/DineInAreaDrawer.vue +++ b/apps/web-antd/src/views/store/dine-in/components/DineInAreaDrawer.vue @@ -90,7 +90,7 @@ function toNumber(value: null | number | string, fallback = 1) { :loading="props.isSaving" @click="emit('submit')" > - {{ props.submitText }} + {{ props.form.id ? '保存修改' : '新增并保存' }} diff --git a/apps/web-antd/src/views/store/dine-in/components/DineInTableDrawer.vue b/apps/web-antd/src/views/store/dine-in/components/DineInTableDrawer.vue index 9c8fb0a..f686a35 100644 --- a/apps/web-antd/src/views/store/dine-in/components/DineInTableDrawer.vue +++ b/apps/web-antd/src/views/store/dine-in/components/DineInTableDrawer.vue @@ -161,7 +161,7 @@ function resolveBusinessStatusClass(status: DineInTableStatus) { :loading="props.isSaving" @click="emit('submit')" > - {{ props.submitText }} + {{ props.form.id ? '保存修改' : '新增并保存' }} diff --git a/apps/web-antd/src/views/store/dine-in/styles/drawer.less b/apps/web-antd/src/views/store/dine-in/styles/drawer.less index 7afd027..d5b27b3 100644 --- a/apps/web-antd/src/views/store/dine-in/styles/drawer.less +++ b/apps/web-antd/src/views/store/dine-in/styles/drawer.less @@ -120,10 +120,18 @@ .drawer-footer { display: flex; - gap: 10px; + gap: 12px; justify-content: flex-end; } +.drawer-footer .ant-btn { + min-width: 96px; + height: 40px; + padding: 0 24px; + font-size: 15px; + border-radius: 10px; +} + .dinein-batch-modal-wrap { .ant-modal-content { overflow: hidden; diff --git a/apps/web-antd/src/views/store/fees/components/FeesTierDrawer.vue b/apps/web-antd/src/views/store/fees/components/FeesTierDrawer.vue index e7beee4..13ae48e 100644 --- a/apps/web-antd/src/views/store/fees/components/FeesTierDrawer.vue +++ b/apps/web-antd/src/views/store/fees/components/FeesTierDrawer.vue @@ -110,7 +110,7 @@ function toNumber(value: null | number | string, fallback = 0) { :loading="props.isSaving" @click="emit('submit')" > - 确认 + {{ props.form.id ? '保存修改' : '新增并保存' }} diff --git a/apps/web-antd/src/views/store/fees/styles/drawer.less b/apps/web-antd/src/views/store/fees/styles/drawer.less index 1365910..b915c08 100644 --- a/apps/web-antd/src/views/store/fees/styles/drawer.less +++ b/apps/web-antd/src/views/store/fees/styles/drawer.less @@ -50,6 +50,14 @@ .drawer-footer { display: flex; - gap: 10px; + gap: 12px; justify-content: flex-end; } + +.drawer-footer .ant-btn { + min-width: 96px; + height: 40px; + padding: 0 24px; + font-size: 15px; + border-radius: 10px; +} diff --git a/apps/web-antd/src/views/store/hours/components/AddSlotDrawer.vue b/apps/web-antd/src/views/store/hours/components/AddSlotDrawer.vue index 585ea77..6c9eb49 100644 --- a/apps/web-antd/src/views/store/hours/components/AddSlotDrawer.vue +++ b/apps/web-antd/src/views/store/hours/components/AddSlotDrawer.vue @@ -195,7 +195,7 @@ function readTimeValue(value: unknown) { :loading="props.isWeeklySubmitting" @click="emit('submit')" > - 确认添加 + 新增并保存 diff --git a/apps/web-antd/src/views/store/hours/components/HolidayDrawer.vue b/apps/web-antd/src/views/store/hours/components/HolidayDrawer.vue index 795705e..ad38644 100644 --- a/apps/web-antd/src/views/store/hours/components/HolidayDrawer.vue +++ b/apps/web-antd/src/views/store/hours/components/HolidayDrawer.vue @@ -232,7 +232,7 @@ function readTimeValue(value: unknown) { :loading="props.isHolidaySubmitting" @click="emit('submit')" > - {{ props.submitText }} + {{ props.holidayForm.id ? '保存修改' : '新增并保存' }} diff --git a/apps/web-antd/src/views/store/hours/styles/drawer.less b/apps/web-antd/src/views/store/hours/styles/drawer.less index 614ff12..94bbc0a 100644 --- a/apps/web-antd/src/views/store/hours/styles/drawer.less +++ b/apps/web-antd/src/views/store/hours/styles/drawer.less @@ -42,17 +42,22 @@ } } -.add-slot-drawer-wrap .drawer-footer { +.add-slot-drawer-wrap .drawer-footer, +.day-edit-drawer-wrap .drawer-footer, +.holiday-drawer-wrap .drawer-footer { display: flex; - gap: 10px; + gap: 12px; justify-content: flex-end; } -.add-slot-drawer-wrap .drawer-footer .ant-btn { - height: 32px; - padding: 0 16px; - font-size: 13px; - border-radius: 6px; +.add-slot-drawer-wrap .drawer-footer .ant-btn, +.day-edit-drawer-wrap .drawer-footer .ant-btn, +.holiday-drawer-wrap .drawer-footer .ant-btn { + min-width: 96px; + height: 40px; + padding: 0 24px; + font-size: 15px; + border-radius: 10px; } .add-slot-drawer-wrap .form-block { @@ -257,19 +262,6 @@ color: #999; } -.day-edit-drawer-wrap .drawer-footer { - display: flex; - gap: 10px; - justify-content: flex-end; -} - -.day-edit-drawer-wrap .drawer-footer .ant-btn { - height: 32px; - padding: 0 16px; - font-size: 13px; - border-radius: 6px; -} - .day-edit-drawer-wrap .day-open-row { display: flex; gap: 10px; @@ -499,19 +491,6 @@ border-color: #1677ff; } -.holiday-drawer-wrap .drawer-footer { - display: flex; - gap: 10px; - justify-content: flex-end; -} - -.holiday-drawer-wrap .drawer-footer .ant-btn { - height: 32px; - padding: 0 16px; - font-size: 13px; - border-radius: 6px; -} - .holiday-drawer-wrap .form-block { margin-bottom: 16px; } @@ -758,7 +737,7 @@ .drawer-footer { display: flex; - gap: 8px; + gap: 12px; justify-content: flex-end; } diff --git a/apps/web-antd/src/views/store/list/components/StoreEditorDrawer.vue b/apps/web-antd/src/views/store/list/components/StoreEditorDrawer.vue index c9e096b..9f40a6d 100644 --- a/apps/web-antd/src/views/store/list/components/StoreEditorDrawer.vue +++ b/apps/web-antd/src/views/store/list/components/StoreEditorDrawer.vue @@ -152,7 +152,7 @@ function handleServiceTypesChange(value: unknown) { :loading="props.isSubmitting" @click="emit('submit')" > - 确认 + {{ props.title.includes('编辑') ? '保存修改' : '新增并保存' }} diff --git a/apps/web-antd/src/views/store/list/styles/drawer.less b/apps/web-antd/src/views/store/list/styles/drawer.less index d09cf6d..6a43cbe 100644 --- a/apps/web-antd/src/views/store/list/styles/drawer.less +++ b/apps/web-antd/src/views/store/list/styles/drawer.less @@ -26,6 +26,20 @@ padding: 14px 24px; border-top: 1px solid #f0f0f0; } + + .store-drawer-footer { + display: flex; + gap: 12px; + justify-content: flex-end; + } + + .store-drawer-footer .ant-btn { + min-width: 96px; + height: 40px; + padding: 0 24px; + font-size: 15px; + border-radius: 10px; + } } .page-store-list { @@ -41,10 +55,4 @@ .store-drawer-form .store-drawer-section-title + .ant-form-item { margin-top: 2px; } - - .store-drawer-footer { - display: flex; - gap: 8px; - justify-content: flex-end; - } } diff --git a/apps/web-antd/src/views/store/pickup/components/PickupSlotDrawer.vue b/apps/web-antd/src/views/store/pickup/components/PickupSlotDrawer.vue index 17f077e..f1a7639 100644 --- a/apps/web-antd/src/views/store/pickup/components/PickupSlotDrawer.vue +++ b/apps/web-antd/src/views/store/pickup/components/PickupSlotDrawer.vue @@ -196,7 +196,7 @@ function readTimeValue(value: unknown) { :loading="props.isSaving" @click="emit('submit')" > - {{ props.form.id ? '保存修改' : '确认添加' }} + {{ props.form.id ? '保存修改' : '新增并保存' }} diff --git a/apps/web-antd/src/views/store/pickup/styles/drawer.less b/apps/web-antd/src/views/store/pickup/styles/drawer.less index 2d45193..41c7988 100644 --- a/apps/web-antd/src/views/store/pickup/styles/drawer.less +++ b/apps/web-antd/src/views/store/pickup/styles/drawer.less @@ -104,6 +104,14 @@ .pickup-drawer-footer { display: flex; - gap: 10px; + gap: 12px; justify-content: flex-end; } + +.pickup-drawer-footer .ant-btn { + min-width: 96px; + height: 40px; + padding: 0 24px; + font-size: 15px; + border-radius: 10px; +} diff --git a/apps/web-antd/src/views/store/staff/components/ShiftTemplateCard.vue b/apps/web-antd/src/views/store/staff/components/ShiftTemplateCard.vue new file mode 100644 index 0000000..531760e --- /dev/null +++ b/apps/web-antd/src/views/store/staff/components/ShiftTemplateCard.vue @@ -0,0 +1,124 @@ + + + diff --git a/apps/web-antd/src/views/store/staff/components/StaffEditorDrawer.vue b/apps/web-antd/src/views/store/staff/components/StaffEditorDrawer.vue new file mode 100644 index 0000000..3b653df --- /dev/null +++ b/apps/web-antd/src/views/store/staff/components/StaffEditorDrawer.vue @@ -0,0 +1,221 @@ + + + diff --git a/apps/web-antd/src/views/store/staff/components/StaffFilterBar.vue b/apps/web-antd/src/views/store/staff/components/StaffFilterBar.vue new file mode 100644 index 0000000..b478505 --- /dev/null +++ b/apps/web-antd/src/views/store/staff/components/StaffFilterBar.vue @@ -0,0 +1,99 @@ + + + diff --git a/apps/web-antd/src/views/store/staff/components/StaffScheduleDrawer.vue b/apps/web-antd/src/views/store/staff/components/StaffScheduleDrawer.vue new file mode 100644 index 0000000..2b6ad45 --- /dev/null +++ b/apps/web-antd/src/views/store/staff/components/StaffScheduleDrawer.vue @@ -0,0 +1,133 @@ + + + diff --git a/apps/web-antd/src/views/store/staff/components/StaffTableSection.vue b/apps/web-antd/src/views/store/staff/components/StaffTableSection.vue new file mode 100644 index 0000000..0fa0bbf --- /dev/null +++ b/apps/web-antd/src/views/store/staff/components/StaffTableSection.vue @@ -0,0 +1,197 @@ + + + diff --git a/apps/web-antd/src/views/store/staff/components/WeekScheduleDrawer.vue b/apps/web-antd/src/views/store/staff/components/WeekScheduleDrawer.vue new file mode 100644 index 0000000..299b91e --- /dev/null +++ b/apps/web-antd/src/views/store/staff/components/WeekScheduleDrawer.vue @@ -0,0 +1,152 @@ + + + diff --git a/apps/web-antd/src/views/store/staff/components/WeeklyScheduleBoard.vue b/apps/web-antd/src/views/store/staff/components/WeeklyScheduleBoard.vue new file mode 100644 index 0000000..b1e234c --- /dev/null +++ b/apps/web-antd/src/views/store/staff/components/WeeklyScheduleBoard.vue @@ -0,0 +1,106 @@ + + + diff --git a/apps/web-antd/src/views/store/staff/composables/staff-page/constants.ts b/apps/web-antd/src/views/store/staff/composables/staff-page/constants.ts new file mode 100644 index 0000000..ddc8318 --- /dev/null +++ b/apps/web-antd/src/views/store/staff/composables/staff-page/constants.ts @@ -0,0 +1,135 @@ +/** + * 文件职责:员工排班页面常量定义。 + * 1. 提供角色/状态/班次等选项源。 + * 2. 提供页面默认值与分页配置。 + */ +import type { StoreShiftTemplatesDto } from '#/api/store-staff'; +import type { + DayOption, + ShiftOption, + StaffEditorFormState, + StaffFilterState, + StaffPermissionOption, + StaffRoleOption, + StaffStatusOption, +} from '#/views/store/staff/types'; + +export const DAY_OPTIONS: DayOption[] = [ + { dayOfWeek: 0, label: '周一', shortLabel: '一' }, + { dayOfWeek: 1, label: '周二', shortLabel: '二' }, + { dayOfWeek: 2, label: '周三', shortLabel: '三' }, + { dayOfWeek: 3, label: '周四', shortLabel: '四' }, + { dayOfWeek: 4, label: '周五', shortLabel: '五' }, + { dayOfWeek: 5, label: '周六', shortLabel: '六' }, + { dayOfWeek: 6, label: '周日', shortLabel: '日' }, +]; + +export const SHIFT_OPTIONS: ShiftOption[] = [ + { + value: 'morning', + label: '早班', + className: 'shift-morning', + }, + { + value: 'evening', + label: '晚班', + className: 'shift-evening', + }, + { + value: 'full', + label: '全天', + className: 'shift-full', + }, + { + value: 'off', + label: '休息', + className: 'shift-off', + }, +]; + +export const SHIFT_CYCLE = SHIFT_OPTIONS.map((item) => item.value); + +export const STAFF_ROLE_OPTIONS: StaffRoleOption[] = [ + { + value: 'manager', + label: '店长', + tagClassName: 'role-manager', + }, + { + value: 'cashier', + label: '收银员', + tagClassName: 'role-cashier', + }, + { + value: 'courier', + label: '配送员', + tagClassName: 'role-courier', + }, + { + value: 'chef', + label: '厨师', + tagClassName: 'role-chef', + }, +]; + +export const STAFF_STATUS_OPTIONS: StaffStatusOption[] = [ + { + value: 'active', + label: '在职', + dotClassName: 'status-dot-active', + }, + { + value: 'leave', + label: '休假', + dotClassName: 'status-dot-leave', + }, + { + value: 'resigned', + label: '离职', + dotClassName: 'status-dot-resigned', + }, +]; + +export const STAFF_PERMISSION_OPTIONS: StaffPermissionOption[] = [ + { value: '收银', label: '收银' }, + { value: '退款', label: '退款' }, + { value: '配送管理', label: '配送管理' }, + { value: '订单查看', label: '订单查看' }, + { value: '库存管理', label: '库存管理' }, + { value: '数据统计', label: '数据统计' }, +]; + +export const DEFAULT_SHIFT_TEMPLATES: StoreShiftTemplatesDto = { + morning: { + startTime: '09:00', + endTime: '14:00', + }, + evening: { + startTime: '14:00', + endTime: '21:00', + }, + full: { + startTime: '09:00', + endTime: '21:00', + }, +}; + +export const DEFAULT_FILTER_STATE: StaffFilterState = { + keyword: '', + roleType: '', + status: '', + page: 1, + pageSize: 10, +}; + +export const DEFAULT_STAFF_FORM: StaffEditorFormState = { + id: '', + name: '', + phone: '', + email: '', + roleType: 'cashier', + status: 'active', + permissions: [], +}; + +export const PAGE_SIZE_OPTIONS = [10, 20, 50]; diff --git a/apps/web-antd/src/views/store/staff/composables/staff-page/copy-actions.ts b/apps/web-antd/src/views/store/staff/composables/staff-page/copy-actions.ts new file mode 100644 index 0000000..fc805eb --- /dev/null +++ b/apps/web-antd/src/views/store/staff/composables/staff-page/copy-actions.ts @@ -0,0 +1,78 @@ +import type { ComputedRef, Ref } from 'vue'; + +/** + * 文件职责:员工排班复制动作。 + * 1. 管理复制弹窗选择状态。 + * 2. 提交“模板+排班”复制请求。 + */ +import type { StoreListItemDto } from '#/api/store'; + +import { message } from 'ant-design-vue'; + +import { copyStoreStaffScheduleApi } from '#/api/store-staff'; + +interface CreateCopyActionsOptions { + copyCandidates: ComputedRef; + copyTargetStoreIds: Ref; + isCopyModalOpen: Ref; + isCopySubmitting: Ref; + selectedStoreId: Ref; +} + +export function createCopyActions(options: CreateCopyActionsOptions) { + /** 打开复制弹窗。 */ + function openCopyModal() { + if (!options.selectedStoreId.value) return; + options.copyTargetStoreIds.value = []; + options.isCopyModalOpen.value = true; + } + + /** 切换单个门店选中态。 */ + function toggleCopyStore(storeId: string, checked: boolean) { + options.copyTargetStoreIds.value = checked + ? [...new Set([storeId, ...options.copyTargetStoreIds.value])] + : options.copyTargetStoreIds.value.filter((id) => id !== storeId); + } + + /** 全选/取消全选。 */ + function handleCopyCheckAll(checked: boolean) { + options.copyTargetStoreIds.value = checked + ? options.copyCandidates.value.map((item) => item.id) + : []; + } + + /** 提交复制。 */ + async function handleCopySubmit() { + if (!options.selectedStoreId.value) return; + if (options.copyTargetStoreIds.value.length === 0) { + message.error('请至少选择一个目标门店'); + return; + } + + options.isCopySubmitting.value = true; + try { + await copyStoreStaffScheduleApi({ + sourceStoreId: options.selectedStoreId.value, + targetStoreIds: options.copyTargetStoreIds.value, + copyScope: 'template_and_schedule', + }); + + message.success( + `已复制班次模板与排班到 ${options.copyTargetStoreIds.value.length} 家门店`, + ); + options.isCopyModalOpen.value = false; + options.copyTargetStoreIds.value = []; + } catch (error) { + console.error(error); + } finally { + options.isCopySubmitting.value = false; + } + } + + return { + handleCopyCheckAll, + handleCopySubmit, + openCopyModal, + toggleCopyStore, + }; +} diff --git a/apps/web-antd/src/views/store/staff/composables/staff-page/data-actions.ts b/apps/web-antd/src/views/store/staff/composables/staff-page/data-actions.ts new file mode 100644 index 0000000..8d2bed4 --- /dev/null +++ b/apps/web-antd/src/views/store/staff/composables/staff-page/data-actions.ts @@ -0,0 +1,233 @@ +import type { Ref } from 'vue'; + +/** + * 文件职责:员工排班数据动作。 + * 1. 加载门店列表、员工列表、班次模板与排班数据。 + * 2. 维护页面级数据快照。 + */ +import type { StoreListItemDto } from '#/api/store'; +import type { + StaffDayShiftDto, + StoreShiftTemplatesDto, + StoreStaffDto, +} from '#/api/store-staff'; +import type { + StaffFilterState, + StaffScheduleSnapshot, +} from '#/views/store/staff/types'; + +import { getStoreListApi } from '#/api/store'; +import { + getStoreStaffListApi, + getStoreStaffScheduleApi, +} from '#/api/store-staff'; + +import { + cloneScheduleMap, + cloneShifts, + cloneTemplates, + createEmptyWeekShifts, + normalizeWeekShifts, +} from './helpers'; + +interface CreateDataActionsOptions { + filters: StaffFilterState; + isPageLoading: Ref; + isStoreLoading: Ref; + scheduleMap: Ref>; + scheduleSnapshot: Ref; + selectedStoreId: Ref; + staffDirectory: Ref; + staffRows: Ref; + staffTotal: Ref; + stores: Ref; + templates: Ref; +} + +export function createDataActions(options: CreateDataActionsOptions) { + /** 清空当前门店相关数据。 */ + function clearStoreData() { + options.staffRows.value = []; + options.staffDirectory.value = []; + options.staffTotal.value = 0; + options.scheduleMap.value = {}; + options.scheduleSnapshot.value = null; + } + + /** 加载员工分页列表(受筛选条件影响)。 */ + async function loadStaffPage() { + if (!options.selectedStoreId.value) { + options.staffRows.value = []; + options.staffTotal.value = 0; + return; + } + + const result = await getStoreStaffListApi({ + storeId: options.selectedStoreId.value, + keyword: options.filters.keyword || undefined, + roleType: options.filters.roleType || undefined, + status: options.filters.status || undefined, + page: options.filters.page, + pageSize: options.filters.pageSize, + }); + + options.staffRows.value = result.items ?? []; + options.staffTotal.value = result.total ?? 0; + } + + /** 加载员工全量字典(用于周排班看板)。 */ + async function loadStaffDirectory() { + if (!options.selectedStoreId.value) { + options.staffDirectory.value = []; + return; + } + + const result = await getStoreStaffListApi({ + storeId: options.selectedStoreId.value, + page: 1, + pageSize: 200, + keyword: undefined, + roleType: undefined, + status: undefined, + }); + + options.staffDirectory.value = result.items ?? []; + } + + /** 加载班次模板与排班配置。 */ + async function loadScheduleSettings() { + if (!options.selectedStoreId.value) { + options.scheduleMap.value = {}; + options.scheduleSnapshot.value = null; + return; + } + + const result = await getStoreStaffScheduleApi( + options.selectedStoreId.value, + ); + const templates = cloneTemplates(result.templates); + + const scheduleMap: Record = {}; + for (const schedule of result.schedules ?? []) { + scheduleMap[schedule.staffId] = normalizeWeekShifts({ + shifts: schedule.shifts, + fallback: createEmptyWeekShifts(templates), + templates, + }); + } + + options.templates.value = templates; + options.scheduleMap.value = scheduleMap; + options.scheduleSnapshot.value = { + templates: cloneTemplates(templates), + scheduleMap: cloneScheduleMap(scheduleMap), + }; + } + + /** 一次性刷新当前门店全部数据。 */ + async function reloadStoreData() { + if (!options.selectedStoreId.value) { + clearStoreData(); + return; + } + + options.isPageLoading.value = true; + try { + await Promise.all([ + loadStaffPage(), + loadStaffDirectory(), + loadScheduleSettings(), + ]); + } catch (error) { + console.error(error); + } finally { + options.isPageLoading.value = false; + } + } + + /** 加载门店列表并初始化默认门店。 */ + 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 = ''; + clearStoreData(); + return; + } + + const hasSelected = options.stores.value.some( + (item) => item.id === options.selectedStoreId.value, + ); + if (!hasSelected) { + const firstStore = options.stores.value[0]; + if (firstStore) { + options.selectedStoreId.value = firstStore.id; + return; + } + } + + await reloadStoreData(); + } catch (error) { + console.error(error); + options.stores.value = []; + options.selectedStoreId.value = ''; + clearStoreData(); + } finally { + options.isStoreLoading.value = false; + } + } + + /** 局部更新单员工排班映射。 */ + function patchStaffSchedule(staffId: string, shifts: StaffDayShiftDto[]) { + const nextMap = { + ...options.scheduleMap.value, + [staffId]: cloneShifts(shifts), + }; + options.scheduleMap.value = nextMap; + options.scheduleSnapshot.value = { + templates: cloneTemplates(options.templates.value), + scheduleMap: cloneScheduleMap(nextMap), + }; + } + + /** 批量更新排班映射。 */ + function patchScheduleMap(nextMap: Record) { + options.scheduleMap.value = cloneScheduleMap(nextMap); + options.scheduleSnapshot.value = { + templates: cloneTemplates(options.templates.value), + scheduleMap: cloneScheduleMap(nextMap), + }; + } + + /** 更新模板并同步快照。 */ + function patchTemplates(nextTemplates: StoreShiftTemplatesDto) { + options.templates.value = cloneTemplates(nextTemplates); + options.scheduleSnapshot.value = { + templates: cloneTemplates(nextTemplates), + scheduleMap: cloneScheduleMap(options.scheduleMap.value), + }; + } + + return { + clearStoreData, + loadScheduleSettings, + loadStaffDirectory, + loadStaffPage, + loadStores, + patchScheduleMap, + patchStaffSchedule, + patchTemplates, + reloadStoreData, + }; +} diff --git a/apps/web-antd/src/views/store/staff/composables/staff-page/helpers.ts b/apps/web-antd/src/views/store/staff/composables/staff-page/helpers.ts new file mode 100644 index 0000000..54ae06f --- /dev/null +++ b/apps/web-antd/src/views/store/staff/composables/staff-page/helpers.ts @@ -0,0 +1,303 @@ +/** + * 文件职责:员工排班页面纯函数工具。 + * 1. 负责克隆、归一化、格式化等无副作用逻辑。 + * 2. 负责排班矩阵与周视图数据转换。 + */ +import type { + ShiftType, + StaffDayShiftDto, + StaffScheduleDto, + StoreShiftTemplatesDto, +} from '#/api/store-staff'; +import type { + StaffEditorFormState, + StaffFilterState, + WeekEditorRow, +} from '#/views/store/staff/types'; + +import { DAY_OPTIONS } from './constants'; + +/** 深拷贝模板。 */ +export function cloneTemplates( + source: StoreShiftTemplatesDto, +): StoreShiftTemplatesDto { + return { + morning: { ...source.morning }, + evening: { ...source.evening }, + full: { ...source.full }, + }; +} + +/** 深拷贝单员工周排班。 */ +export function cloneShifts(source: StaffDayShiftDto[]) { + return source.map((item) => ({ ...item })); +} + +/** 深拷贝排班映射。 */ +export function cloneScheduleMap(source: Record) { + const next: Record = {}; + for (const [staffId, shifts] of Object.entries(source)) { + next[staffId] = cloneShifts(shifts); + } + return next; +} + +/** 深拷贝筛选表单。 */ +export function cloneFilterState(source: StaffFilterState): StaffFilterState { + return { + keyword: source.keyword, + roleType: source.roleType, + status: source.status, + page: source.page, + pageSize: source.pageSize, + }; +} + +/** 深拷贝员工编辑表单。 */ +export function cloneStaffForm( + source: StaffEditorFormState, +): StaffEditorFormState { + return { + id: source.id, + name: source.name, + phone: source.phone, + email: source.email, + roleType: source.roleType, + status: source.status, + permissions: [...source.permissions], + }; +} + +/** 按模板创建空白一周(默认休息)。 */ +export function createEmptyWeekShifts( + templates: StoreShiftTemplatesDto, +): StaffDayShiftDto[] { + return DAY_OPTIONS.map((day) => { + const fallbackShift = templates.morning; + return { + dayOfWeek: day.dayOfWeek, + shiftType: 'off', + startTime: '', + endTime: '', + ...fallbackShift, + }; + }).map((item) => ({ + dayOfWeek: item.dayOfWeek, + shiftType: item.shiftType, + startTime: item.shiftType === 'off' ? '' : item.startTime, + endTime: item.shiftType === 'off' ? '' : item.endTime, + })); +} + +/** 生成指定班次默认时间。 */ +export function resolveShiftTimeByType( + shiftType: ShiftType, + templates: StoreShiftTemplatesDto, +) { + if (shiftType === 'off') { + return { + startTime: '', + endTime: '', + }; + } + const template = templates[shiftType]; + return { + startTime: template.startTime, + endTime: template.endTime, + }; +} + +/** 归一化单日排班。 */ +export function normalizeDayShift(payload: { + dayOfWeek: number; + fallback?: StaffDayShiftDto; + shift: Partial; + templates: StoreShiftTemplatesDto; +}) { + const fallbackShift = payload.fallback; + const shiftType = normalizeShiftType( + payload.shift.shiftType, + fallbackShift?.shiftType ?? 'off', + ); + + if (shiftType === 'off') { + return { + dayOfWeek: payload.dayOfWeek, + shiftType, + startTime: '', + endTime: '', + } satisfies StaffDayShiftDto; + } + + const templateTime = resolveShiftTimeByType(shiftType, payload.templates); + return { + dayOfWeek: payload.dayOfWeek, + shiftType, + startTime: normalizeTime(payload.shift.startTime, templateTime.startTime), + endTime: normalizeTime(payload.shift.endTime, templateTime.endTime), + } satisfies StaffDayShiftDto; +} + +/** 归一化周排班数组,确保包含 0-6 共七天。 */ +export function normalizeWeekShifts(payload: { + fallback: StaffDayShiftDto[]; + shifts: Partial[]; + templates: StoreShiftTemplatesDto; +}) { + const shiftMap = new Map>(); + for (const shift of payload.shifts) { + const dayOfWeek = Number(shift.dayOfWeek); + if (!Number.isInteger(dayOfWeek) || dayOfWeek < 0 || dayOfWeek > 6) { + continue; + } + shiftMap.set(dayOfWeek, shift); + } + + return DAY_OPTIONS.map((day) => { + const fallbackShift = payload.fallback.find( + (item) => item.dayOfWeek === day.dayOfWeek, + ); + return normalizeDayShift({ + dayOfWeek: day.dayOfWeek, + shift: shiftMap.get(day.dayOfWeek) ?? fallbackShift ?? {}, + fallback: fallbackShift, + templates: payload.templates, + }); + }); +} + +/** 将接口排班数组转换为映射。 */ +export function createScheduleMap(schedules: StaffScheduleDto[]) { + const scheduleMap: Record = {}; + for (const schedule of schedules) { + scheduleMap[schedule.staffId] = normalizeWeekShifts({ + shifts: schedule.shifts, + fallback: createEmptyWeekShifts({ + morning: { startTime: '09:00', endTime: '14:00' }, + evening: { startTime: '14:00', endTime: '21:00' }, + full: { startTime: '09:00', endTime: '21:00' }, + }), + templates: { + morning: { startTime: '09:00', endTime: '14:00' }, + evening: { startTime: '14:00', endTime: '21:00' }, + full: { startTime: '09:00', endTime: '21:00' }, + }, + }); + } + return scheduleMap; +} + +/** 将员工排班矩阵转为可编辑周视图行。 */ +export function buildWeekEditorRows(payload: { + scheduleMap: Record; + staffs: Array<{ + id: string; + name: string; + roleType: WeekEditorRow['roleType']; + status: WeekEditorRow['status']; + }>; + templates: StoreShiftTemplatesDto; +}) { + return payload.staffs.map((staff) => ({ + staffId: staff.id, + staffName: staff.name, + roleType: staff.roleType, + status: staff.status, + shifts: cloneShifts( + payload.scheduleMap[staff.id] ?? createEmptyWeekShifts(payload.templates), + ), + })); +} + +/** 更新周视图某员工某天班次。 */ +export function updateWeekRowShift(payload: { + dayOfWeek: number; + nextShiftType: ShiftType; + row: WeekEditorRow; + templates: StoreShiftTemplatesDto; +}) { + return { + ...payload.row, + shifts: payload.row.shifts.map((item) => { + if (item.dayOfWeek !== payload.dayOfWeek) return { ...item }; + const normalized = normalizeDayShift({ + dayOfWeek: payload.dayOfWeek, + shift: { + dayOfWeek: payload.dayOfWeek, + shiftType: payload.nextShiftType, + }, + fallback: item, + templates: payload.templates, + }); + return normalized; + }), + }; +} + +/** 归一化班次类型。 */ +export function normalizeShiftType( + value: unknown, + fallback: ShiftType, +): ShiftType { + if ( + value === 'morning' || + value === 'evening' || + value === 'full' || + value === 'off' + ) { + return value; + } + return fallback; +} + +/** 归一化时间字符串(HH:mm)。 */ +export 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')}`; +} + +/** 获取当前周一日期。 */ +export 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。 */ +export 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}`; +} + +/** 手机号脱敏展示。 */ +export function maskPhone(phone: string) { + const normalized = String(phone || '').replaceAll(/\D/g, ''); + if (normalized.length < 7) return phone; + return `${normalized.slice(0, 3)}****${normalized.slice(-4)}`; +} + +/** 根据姓名提取头像字符。 */ +export function resolveAvatarText(name: string) { + const normalized = String(name || '').trim(); + if (!normalized) return '?'; + return normalized.slice(0, 1).toUpperCase(); +} + +/** 获取班次展示时间。 */ +export function formatShiftTimeText(shift: StaffDayShiftDto) { + if (shift.shiftType === 'off') return '休息'; + return `${shift.startTime}-${shift.endTime}`; +} diff --git a/apps/web-antd/src/views/store/staff/composables/staff-page/schedule-actions.ts b/apps/web-antd/src/views/store/staff/composables/staff-page/schedule-actions.ts new file mode 100644 index 0000000..789a87c --- /dev/null +++ b/apps/web-antd/src/views/store/staff/composables/staff-page/schedule-actions.ts @@ -0,0 +1,207 @@ +import type { Ref } from 'vue'; + +/** + * 文件职责:排班编辑动作。 + * 1. 管理个人排班抽屉与周排班抽屉。 + * 2. 提交个人排班与周排班保存请求。 + */ +import type { + ShiftType, + StaffDayShiftDto, + StoreShiftTemplatesDto, + StoreStaffDto, +} from '#/api/store-staff'; +import type { + StaffPersonalScheduleFormState, + WeekEditorRow, +} from '#/views/store/staff/types'; + +import { message } from 'ant-design-vue'; + +import { + saveStoreStaffPersonalScheduleApi, + saveStoreStaffWeeklyScheduleApi, +} from '#/api/store-staff'; + +import { SHIFT_CYCLE } from './constants'; +import { + buildWeekEditorRows, + cloneScheduleMap, + cloneShifts, + createEmptyWeekShifts, + normalizeDayShift, + normalizeTime, + updateWeekRowShift, +} from './helpers'; + +interface CreateScheduleActionsOptions { + isPersonalDrawerOpen: Ref; + isPersonalSaving: Ref; + isWeekDrawerOpen: Ref; + isWeekSaving: Ref; + loadScheduleSettings: () => Promise; + patchScheduleMap: (nextMap: Record) => void; + patchStaffSchedule: (staffId: string, shifts: StaffDayShiftDto[]) => void; + personalForm: StaffPersonalScheduleFormState; + scheduleMap: Ref>; + selectedStoreId: Ref; + staffDirectory: Ref; + templates: Ref; + weekRows: Ref; +} + +export function createScheduleActions(options: CreateScheduleActionsOptions) { + /** 打开个人排班抽屉。 */ + function openPersonalScheduleDrawer(staff: StoreStaffDto) { + options.personalForm.staffId = staff.id; + options.personalForm.staffName = staff.name; + options.personalForm.roleType = staff.roleType; + options.personalForm.shifts = cloneShifts( + options.scheduleMap.value[staff.id] ?? + createEmptyWeekShifts(options.templates.value), + ); + options.isPersonalDrawerOpen.value = true; + } + + /** 更新个人排班班次类型。 */ + function setPersonalShiftType(dayOfWeek: number, shiftType: ShiftType) { + options.personalForm.shifts = options.personalForm.shifts.map((item) => { + if (item.dayOfWeek !== dayOfWeek) return { ...item }; + return normalizeDayShift({ + dayOfWeek, + shift: { + ...item, + shiftType, + }, + fallback: item, + templates: options.templates.value, + }); + }); + } + + /** 更新个人排班开始时间。 */ + function setPersonalShiftStart(dayOfWeek: number, startTime: string) { + options.personalForm.shifts = options.personalForm.shifts.map((item) => { + if (item.dayOfWeek !== dayOfWeek) return { ...item }; + if (item.shiftType === 'off') return { ...item }; + return { + ...item, + startTime: normalizeTime(startTime, item.startTime), + }; + }); + } + + /** 更新个人排班结束时间。 */ + function setPersonalShiftEnd(dayOfWeek: number, endTime: string) { + options.personalForm.shifts = options.personalForm.shifts.map((item) => { + if (item.dayOfWeek !== dayOfWeek) return { ...item }; + if (item.shiftType === 'off') return { ...item }; + return { + ...item, + endTime: normalizeTime(endTime, item.endTime), + }; + }); + } + + /** 提交个人排班。 */ + async function handleSavePersonalSchedule() { + if (!options.selectedStoreId.value || !options.personalForm.staffId) return; + + options.isPersonalSaving.value = true; + try { + const result = await saveStoreStaffPersonalScheduleApi({ + storeId: options.selectedStoreId.value, + staffId: options.personalForm.staffId, + shifts: cloneShifts(options.personalForm.shifts), + }); + + options.patchStaffSchedule(result.staffId, result.shifts); + options.isPersonalDrawerOpen.value = false; + message.success('员工排班已保存'); + } catch (error) { + console.error(error); + } finally { + options.isPersonalSaving.value = false; + } + } + + /** 打开周排班抽屉。 */ + function openWeekScheduleDrawer() { + const editableStaffs = options.staffDirectory.value.filter( + (staff) => staff.status !== 'resigned', + ); + options.weekRows.value = buildWeekEditorRows({ + staffs: editableStaffs, + scheduleMap: options.scheduleMap.value, + templates: options.templates.value, + }); + options.isWeekDrawerOpen.value = true; + } + + /** 按循环顺序切换周视图单元格班次。 */ + function cycleWeekShift(payload: { dayOfWeek: number; staffId: string }) { + options.weekRows.value = options.weekRows.value.map((row) => { + if (row.staffId !== payload.staffId) return row; + + const currentShift = row.shifts.find( + (item) => item.dayOfWeek === payload.dayOfWeek, + ); + const currentType = currentShift?.shiftType ?? 'off'; + const currentIndex = SHIFT_CYCLE.indexOf(currentType); + const nextType = + SHIFT_CYCLE[(currentIndex + 1) % SHIFT_CYCLE.length] ?? 'off'; + + return updateWeekRowShift({ + row, + dayOfWeek: payload.dayOfWeek, + nextShiftType: nextType, + templates: options.templates.value, + }); + }); + } + + /** 提交周排班。 */ + async function handleSaveWeekSchedule() { + if (!options.selectedStoreId.value) return; + + options.isWeekSaving.value = true; + try { + const schedules = options.weekRows.value.map((row) => ({ + staffId: row.staffId, + shifts: cloneShifts(row.shifts), + })); + + const result = await saveStoreStaffWeeklyScheduleApi({ + storeId: options.selectedStoreId.value, + schedules, + }); + + const nextMap = cloneScheduleMap(options.scheduleMap.value); + for (const schedule of result.schedules ?? []) { + nextMap[schedule.staffId] = cloneShifts(schedule.shifts); + } + options.patchScheduleMap(nextMap); + + options.isWeekDrawerOpen.value = false; + message.success('本周排班已保存'); + + // 1. 保守刷新一次后端快照,避免本地与服务端算法差异。 + await options.loadScheduleSettings(); + } catch (error) { + console.error(error); + } finally { + options.isWeekSaving.value = false; + } + } + + return { + cycleWeekShift, + handleSavePersonalSchedule, + handleSaveWeekSchedule, + openPersonalScheduleDrawer, + openWeekScheduleDrawer, + setPersonalShiftEnd, + setPersonalShiftStart, + setPersonalShiftType, + }; +} diff --git a/apps/web-antd/src/views/store/staff/composables/staff-page/staff-actions.ts b/apps/web-antd/src/views/store/staff/composables/staff-page/staff-actions.ts new file mode 100644 index 0000000..d2212e6 --- /dev/null +++ b/apps/web-antd/src/views/store/staff/composables/staff-page/staff-actions.ts @@ -0,0 +1,169 @@ +import type { Ref } from 'vue'; + +/** + * 文件职责:员工管理动作。 + * 1. 管理员工抽屉开关与表单字段。 + * 2. 执行新增、编辑、删除员工请求。 + */ +import type { StoreStaffDto } from '#/api/store-staff'; +import type { + StaffDrawerMode, + StaffEditorFormState, +} from '#/views/store/staff/types'; + +import { message } from 'ant-design-vue'; + +import { deleteStoreStaffApi, saveStoreStaffApi } from '#/api/store-staff'; + +import { DEFAULT_STAFF_FORM } from './constants'; +import { cloneStaffForm } from './helpers'; + +interface CreateStaffActionsOptions { + deletingStaffId: Ref; + isStaffSaving: Ref; + isStaffDrawerOpen: Ref; + reloadStoreData: () => Promise; + selectedStoreId: Ref; + staffDrawerMode: Ref; + staffForm: StaffEditorFormState; +} + +export function createStaffActions(options: CreateStaffActionsOptions) { + /** 重置员工表单。 */ + function resetStaffForm() { + Object.assign(options.staffForm, cloneStaffForm(DEFAULT_STAFF_FORM)); + } + + /** 打开员工抽屉。 */ + function openStaffDrawer(mode: StaffDrawerMode, staff?: StoreStaffDto) { + options.staffDrawerMode.value = mode; + if (mode === 'edit' && staff) { + Object.assign(options.staffForm, { + id: staff.id, + name: staff.name, + phone: staff.phone, + email: staff.email, + roleType: staff.roleType, + status: staff.status, + permissions: [...staff.permissions], + } satisfies StaffEditorFormState); + } else { + resetStaffForm(); + } + options.isStaffDrawerOpen.value = true; + } + + /** 设置员工姓名。 */ + function setStaffName(value: string) { + options.staffForm.name = value; + } + + /** 设置员工手机号。 */ + function setStaffPhone(value: string) { + options.staffForm.phone = value; + } + + /** 设置员工邮箱。 */ + function setStaffEmail(value: string) { + options.staffForm.email = value; + } + + /** 设置员工角色。 */ + function setStaffRoleType(value: StaffEditorFormState['roleType']) { + options.staffForm.roleType = value; + + // 1. 店长默认拥有“全部权限”。 + if (value === 'manager' && options.staffForm.permissions.length === 0) { + options.staffForm.permissions = ['全部权限']; + } + } + + /** 设置员工状态。 */ + function setStaffStatus(value: StaffEditorFormState['status']) { + options.staffForm.status = value; + } + + /** 设置员工权限。 */ + function setStaffPermissions(value: string[]) { + options.staffForm.permissions = [ + ...new Set(value.map((item) => item.trim()).filter(Boolean)), + ]; + } + + /** 提交新增/编辑员工。 */ + async function handleSubmitStaff() { + if (!options.selectedStoreId.value) return; + + const name = options.staffForm.name.trim(); + const phone = options.staffForm.phone.replaceAll(/\D/g, ''); + + if (!name) { + message.error('请输入员工姓名'); + return; + } + + if (phone.length !== 11) { + message.error('请输入 11 位手机号'); + return; + } + + options.isStaffSaving.value = true; + try { + await saveStoreStaffApi({ + storeId: options.selectedStoreId.value, + id: options.staffForm.id || undefined, + name, + phone, + email: options.staffForm.email.trim(), + roleType: options.staffForm.roleType, + status: options.staffForm.status, + permissions: [...options.staffForm.permissions], + }); + + message.success( + options.staffDrawerMode.value === 'edit' + ? '员工信息已更新' + : '员工已添加', + ); + options.isStaffDrawerOpen.value = false; + resetStaffForm(); + await options.reloadStoreData(); + } catch (error) { + console.error(error); + } finally { + options.isStaffSaving.value = false; + } + } + + /** 删除员工。 */ + async function handleDeleteStaff(staff: StoreStaffDto) { + if (!options.selectedStoreId.value) return; + + options.deletingStaffId.value = staff.id; + try { + await deleteStoreStaffApi({ + storeId: options.selectedStoreId.value, + staffId: staff.id, + }); + message.success('员工已删除'); + await options.reloadStoreData(); + } catch (error) { + console.error(error); + } finally { + options.deletingStaffId.value = ''; + } + } + + return { + handleDeleteStaff, + handleSubmitStaff, + openStaffDrawer, + resetStaffForm, + setStaffEmail, + setStaffName, + setStaffPermissions, + setStaffPhone, + setStaffRoleType, + setStaffStatus, + }; +} diff --git a/apps/web-antd/src/views/store/staff/composables/staff-page/template-actions.ts b/apps/web-antd/src/views/store/staff/composables/staff-page/template-actions.ts new file mode 100644 index 0000000..c707936 --- /dev/null +++ b/apps/web-antd/src/views/store/staff/composables/staff-page/template-actions.ts @@ -0,0 +1,81 @@ +import type { Ref } from 'vue'; + +/** + * 文件职责:班次模板动作。 + * 1. 管理模板时间输入与重置。 + * 2. 提交模板保存请求并刷新排班。 + */ +import type { ShiftType, StoreShiftTemplatesDto } from '#/api/store-staff'; +import type { StaffScheduleSnapshot } from '#/views/store/staff/types'; + +import { message } from 'ant-design-vue'; + +import { saveStoreStaffTemplatesApi } from '#/api/store-staff'; + +import { cloneTemplates, normalizeTime } from './helpers'; + +interface CreateTemplateActionsOptions { + isTemplateSaving: Ref; + loadScheduleSettings: () => Promise; + patchTemplates: (nextTemplates: StoreShiftTemplatesDto) => void; + scheduleSnapshot: Ref; + selectedStoreId: Ref; + templates: Ref; +} + +export function createTemplateActions(options: CreateTemplateActionsOptions) { + /** 更新单个模板时间字段。 */ + function setTemplateTime(payload: { + field: 'endTime' | 'startTime'; + shiftType: Exclude; + value: string; + }) { + const currentTemplate = options.templates.value[payload.shiftType]; + options.templates.value = { + ...options.templates.value, + [payload.shiftType]: { + ...currentTemplate, + [payload.field]: normalizeTime( + payload.value, + currentTemplate[payload.field], + ), + }, + }; + } + + /** 重置模板到最近快照。 */ + function resetTemplates() { + if (!options.scheduleSnapshot.value) return; + options.templates.value = cloneTemplates( + options.scheduleSnapshot.value.templates, + ); + message.success('班次模板已重置'); + } + + /** 保存模板。 */ + async function handleSaveTemplates() { + if (!options.selectedStoreId.value) return; + + options.isTemplateSaving.value = true; + try { + const nextTemplates = await saveStoreStaffTemplatesApi({ + storeId: options.selectedStoreId.value, + templates: cloneTemplates(options.templates.value), + }); + + options.patchTemplates(nextTemplates); + await options.loadScheduleSettings(); + message.success('班次模板已保存'); + } catch (error) { + console.error(error); + } finally { + options.isTemplateSaving.value = false; + } + } + + return { + handleSaveTemplates, + resetTemplates, + setTemplateTime, + }; +} diff --git a/apps/web-antd/src/views/store/staff/composables/useStoreStaffPage.ts b/apps/web-antd/src/views/store/staff/composables/useStoreStaffPage.ts new file mode 100644 index 0000000..b83b26e --- /dev/null +++ b/apps/web-antd/src/views/store/staff/composables/useStoreStaffPage.ts @@ -0,0 +1,511 @@ +/** + * 文件职责:员工排班页面主编排。 + * 1. 维护门店、员工、模板、排班、抽屉与复制弹窗状态。 + * 2. 组装数据加载、员工管理、模板编辑、排班编辑、复制动作。 + * 3. 对视图层暴露可直接绑定的状态与方法。 + */ +import type { StoreListItemDto } from '#/api/store'; +import type { + ShiftType, + StaffDayShiftDto, + StaffRoleType, + StaffStatus, + StoreShiftTemplatesDto, + StoreStaffDto, +} from '#/api/store-staff'; +import type { + ShiftLegendItem, + StaffDrawerMode, + StaffEditorFormState, + StaffFilterState, + StaffPersonalScheduleFormState, + WeekEditorRow, +} from '#/views/store/staff/types'; + +import { computed, onMounted, reactive, ref, watch } from 'vue'; + +import { + DAY_OPTIONS, + DEFAULT_FILTER_STATE, + DEFAULT_SHIFT_TEMPLATES, + DEFAULT_STAFF_FORM, + PAGE_SIZE_OPTIONS, + SHIFT_OPTIONS, + STAFF_PERMISSION_OPTIONS, + STAFF_ROLE_OPTIONS, + STAFF_STATUS_OPTIONS, +} from './staff-page/constants'; +import { createCopyActions } from './staff-page/copy-actions'; +import { createDataActions } from './staff-page/data-actions'; +import { + cloneFilterState, + cloneShifts, + cloneStaffForm, + cloneTemplates, + createEmptyWeekShifts, + formatShiftTimeText, + getCurrentWeekStartDate, + maskPhone, + normalizeWeekShifts, + resolveAvatarText, +} from './staff-page/helpers'; +import { createScheduleActions } from './staff-page/schedule-actions'; +import { createStaffActions } from './staff-page/staff-actions'; +import { createTemplateActions } from './staff-page/template-actions'; + +export function useStoreStaffPage() { + // 1. 页面 loading / submitting 状态。 + const isStoreLoading = ref(false); + const isPageLoading = ref(false); + const isStaffSaving = ref(false); + const isTemplateSaving = ref(false); + const isPersonalSaving = ref(false); + const isWeekSaving = ref(false); + const isCopySubmitting = ref(false); + const deletingStaffId = ref(''); + + // 2. 页面核心业务数据。 + const stores = ref([]); + const selectedStoreId = ref(''); + + const filters = reactive( + cloneFilterState(DEFAULT_FILTER_STATE), + ); + + const staffRows = ref([]); + const staffTotal = ref(0); + const staffDirectory = ref([]); + + const templates = ref( + cloneTemplates(DEFAULT_SHIFT_TEMPLATES), + ); + const scheduleMap = ref>({}); + const scheduleSnapshot = ref; + templates: StoreShiftTemplatesDto; + }>(null); + + // 3. 抽屉与弹窗状态。 + const isStaffDrawerOpen = ref(false); + const staffDrawerMode = ref('create'); + const staffForm = reactive( + cloneStaffForm(DEFAULT_STAFF_FORM), + ); + + const isPersonalDrawerOpen = ref(false); + const personalScheduleForm = reactive({ + staffId: '', + staffName: '', + roleType: 'cashier', + shifts: createEmptyWeekShifts(DEFAULT_SHIFT_TEMPLATES), + }); + + const isWeekDrawerOpen = ref(false); + const weekRows = ref([]); + + const isCopyModalOpen = ref(false); + const copyTargetStoreIds = ref([]); + + // 4. 页面衍生数据。 + const storeOptions = computed(() => + stores.value.map((item) => ({ label: item.name, value: item.id })), + ); + + const selectedStoreName = computed( + () => + stores.value.find((item) => item.id === selectedStoreId.value)?.name ?? + '', + ); + + const copyCandidates = computed(() => + stores.value.filter((item) => item.id !== selectedStoreId.value), + ); + + const isCopyAllChecked = computed( + () => + copyCandidates.value.length > 0 && + copyTargetStoreIds.value.length === copyCandidates.value.length, + ); + + const isCopyIndeterminate = computed( + () => + copyTargetStoreIds.value.length > 0 && + copyTargetStoreIds.value.length < copyCandidates.value.length, + ); + + const roleOptionMap = computed(() => { + const map = new Map(); + for (const option of STAFF_ROLE_OPTIONS) { + map.set(option.value, option); + } + return map; + }); + + const statusOptionMap = computed(() => { + const map = new Map(); + for (const option of STAFF_STATUS_OPTIONS) { + map.set(option.value, option); + } + return map; + }); + + const shiftOptionMap = computed(() => { + const map = new Map(); + for (const option of SHIFT_OPTIONS) { + map.set(option.value, option); + } + return map; + }); + + const staffDrawerTitle = computed(() => + staffDrawerMode.value === 'edit' + ? `编辑员工${staffForm.name ? ` - ${staffForm.name}` : ''}` + : '添加员工', + ); + + const staffSubmitText = computed(() => + staffDrawerMode.value === 'edit' ? '保存修改' : '确认添加', + ); + + const personalDrawerTitle = computed(() => { + if (!personalScheduleForm.staffName) return '编辑排班'; + return `编辑排班 - ${personalScheduleForm.staffName}`; + }); + + const boardRows = computed(() => { + const visibleStaffs = staffDirectory.value.filter( + (item) => item.status !== 'resigned', + ); + + return visibleStaffs.map((staff) => ({ + staffId: staff.id, + staffName: staff.name, + roleType: staff.roleType, + status: staff.status, + shifts: cloneShifts( + scheduleMap.value[staff.id] ?? createEmptyWeekShifts(templates.value), + ), + })); + }); + + const shiftLegendItems = computed(() => { + return SHIFT_OPTIONS.filter((item) => item.value !== 'off').map((item) => { + const template = templates.value[item.value as Exclude]; + return { + type: item.value as Exclude, + label: item.label, + className: item.className, + timeText: `${template.startTime}-${template.endTime}`, + }; + }); + }); + + // 5. 数据动作装配。 + const { + clearStoreData, + loadScheduleSettings, + loadStaffPage, + loadStores, + patchScheduleMap, + patchStaffSchedule, + patchTemplates, + reloadStoreData, + } = createDataActions({ + filters, + isPageLoading, + isStoreLoading, + scheduleMap, + scheduleSnapshot, + selectedStoreId, + staffDirectory, + staffRows, + staffTotal, + stores, + templates, + }); + + const { + handleDeleteStaff, + handleSubmitStaff, + openStaffDrawer, + setStaffEmail, + setStaffName, + setStaffPermissions, + setStaffPhone, + setStaffRoleType, + setStaffStatus, + } = createStaffActions({ + deletingStaffId, + isStaffSaving, + isStaffDrawerOpen, + reloadStoreData, + selectedStoreId, + staffDrawerMode, + staffForm, + }); + + const { handleSaveTemplates, resetTemplates, setTemplateTime } = + createTemplateActions({ + isTemplateSaving, + loadScheduleSettings, + patchTemplates, + scheduleSnapshot, + selectedStoreId, + templates, + }); + + const { + cycleWeekShift, + handleSavePersonalSchedule, + handleSaveWeekSchedule, + openPersonalScheduleDrawer, + openWeekScheduleDrawer, + setPersonalShiftEnd, + setPersonalShiftStart, + setPersonalShiftType, + } = createScheduleActions({ + isPersonalDrawerOpen, + isPersonalSaving, + isWeekDrawerOpen, + isWeekSaving, + loadScheduleSettings, + patchScheduleMap, + patchStaffSchedule, + personalForm: personalScheduleForm, + scheduleMap, + selectedStoreId, + staffDirectory, + templates, + weekRows, + }); + + const { + handleCopyCheckAll, + handleCopySubmit, + openCopyModal, + toggleCopyStore, + } = createCopyActions({ + copyCandidates, + copyTargetStoreIds, + isCopyModalOpen, + isCopySubmitting, + selectedStoreId, + }); + + // 6. 页面交互方法。 + function setSelectedStoreId(value: string) { + selectedStoreId.value = value; + } + + function setFilterKeyword(value: string) { + filters.keyword = value; + } + + function setFilterRoleType(value: '' | StaffRoleType) { + filters.roleType = value; + } + + function setFilterStatus(value: '' | StaffStatus) { + filters.status = value; + } + + /** 应用筛选条件后重新查询列表。 */ + async function applyFilters() { + filters.page = 1; + await loadStaffPage(); + } + + /** 重置筛选并重新查询列表。 */ + async function resetFilters() { + filters.keyword = ''; + filters.roleType = ''; + filters.status = ''; + filters.page = 1; + await loadStaffPage(); + } + + /** 切换分页。 */ + async function handlePageChange(payload: { page: number; pageSize: number }) { + filters.page = payload.page; + filters.pageSize = payload.pageSize; + await loadStaffPage(); + } + + function setStaffDrawerOpen(value: boolean) { + isStaffDrawerOpen.value = value; + } + + function setPersonalDrawerOpen(value: boolean) { + isPersonalDrawerOpen.value = value; + } + + function setWeekDrawerOpen(value: boolean) { + isWeekDrawerOpen.value = value; + } + + function setCopyModalOpen(value: boolean) { + isCopyModalOpen.value = value; + } + + /** 判断员工是否可进入排班。 */ + function isScheduleDisabled(staff: StoreStaffDto) { + return staff.status === 'resigned'; + } + + /** 打开个人排班抽屉。 */ + function handleOpenPersonalSchedule(staff: StoreStaffDto) { + if (isScheduleDisabled(staff)) return; + openPersonalScheduleDrawer(staff); + } + + /** 获取角色展示文案。 */ + function getRoleLabel(roleType: StaffRoleType) { + return roleOptionMap.value.get(roleType)?.label ?? roleType; + } + + /** 获取角色标签样式类。 */ + function getRoleTagClass(roleType: StaffRoleType) { + return roleOptionMap.value.get(roleType)?.tagClassName ?? 'role-cashier'; + } + + /** 获取状态展示文案。 */ + function getStatusLabel(status: StaffStatus) { + return statusOptionMap.value.get(status)?.label ?? status; + } + + /** 获取状态圆点样式类。 */ + function getStatusDotClass(status: StaffStatus) { + return ( + statusOptionMap.value.get(status)?.dotClassName ?? 'status-dot-active' + ); + } + + /** 获取班次文案。 */ + function getShiftLabel(shiftType: ShiftType) { + return shiftOptionMap.value.get(shiftType)?.label ?? shiftType; + } + + /** 获取班次样式类。 */ + function getShiftClass(shiftType: ShiftType) { + return shiftOptionMap.value.get(shiftType)?.className ?? 'shift-off'; + } + + /** 获取员工某天排班。 */ + function getStaffShift(row: WeekEditorRow, dayOfWeek: number) { + return ( + row.shifts.find((item) => item.dayOfWeek === dayOfWeek) ?? + normalizeWeekShifts({ + shifts: [], + fallback: createEmptyWeekShifts(templates.value), + templates: templates.value, + }).find((item) => item.dayOfWeek === dayOfWeek) ?? { + dayOfWeek, + shiftType: 'off', + startTime: '', + endTime: '', + } + ); + } + + // 7. 监听门店切换。 + watch(selectedStoreId, async (storeId) => { + if (!storeId) { + clearStoreData(); + return; + } + + filters.page = 1; + await reloadStoreData(); + }); + + // 8. 页面首屏加载。 + onMounted(loadStores); + + return { + DAY_OPTIONS, + PAGE_SIZE_OPTIONS, + SHIFT_OPTIONS, + STAFF_PERMISSION_OPTIONS, + STAFF_ROLE_OPTIONS, + STAFF_STATUS_OPTIONS, + applyFilters, + boardRows, + copyCandidates, + copyTargetStoreIds, + deletingStaffId, + filters, + getCurrentWeekStartDate, + getRoleLabel, + getRoleTagClass, + getShiftClass, + getShiftLabel, + getStaffShift, + getStatusDotClass, + getStatusLabel, + handleCopyCheckAll, + handleCopySubmit, + handleDeleteStaff, + handleOpenPersonalSchedule, + handlePageChange, + handleSavePersonalSchedule, + handleSaveTemplates, + handleSaveWeekSchedule, + handleSubmitStaff, + isCopyAllChecked, + isCopyIndeterminate, + isCopyModalOpen, + isCopySubmitting, + isPageLoading, + isPersonalDrawerOpen, + isPersonalSaving, + isScheduleDisabled, + isStaffDrawerOpen, + isStaffSaving, + isStoreLoading, + isTemplateSaving, + isWeekDrawerOpen, + isWeekSaving, + maskPhone, + openCopyModal, + openStaffDrawer, + openWeekScheduleDrawer, + personalDrawerTitle, + personalScheduleForm, + resetFilters, + resetTemplates, + resolveAvatarText, + selectedStoreId, + selectedStoreName, + setCopyModalOpen, + setFilterKeyword, + setFilterRoleType, + setFilterStatus, + setPersonalDrawerOpen, + setPersonalShiftEnd, + setPersonalShiftStart, + setPersonalShiftType, + setSelectedStoreId, + setStaffDrawerOpen, + setStaffEmail, + setStaffName, + setStaffPermissions, + setStaffPhone, + setStaffRoleType, + setStaffStatus, + setTemplateTime, + setWeekDrawerOpen, + shiftLegendItems, + staffDrawerTitle, + staffDrawerMode, + staffForm, + staffRows, + staffSubmitText, + staffTotal, + storeOptions, + templates, + toggleCopyStore, + weekRows, + cycleWeekShift, + formatShiftTimeText, + }; +} diff --git a/apps/web-antd/src/views/store/staff/index.vue b/apps/web-antd/src/views/store/staff/index.vue new file mode 100644 index 0000000..b72e743 --- /dev/null +++ b/apps/web-antd/src/views/store/staff/index.vue @@ -0,0 +1,255 @@ + + + + + diff --git a/apps/web-antd/src/views/store/staff/styles/base.less b/apps/web-antd/src/views/store/staff/styles/base.less new file mode 100644 index 0000000..a704d01 --- /dev/null +++ b/apps/web-antd/src/views/store/staff/styles/base.less @@ -0,0 +1,94 @@ +/* 文件职责:员工排班页面基础骨架与通用样式。 */ +.page-store-staff { + width: 100%; + max-width: none; + + .staff-card { + margin-bottom: 16px; + overflow: hidden; + border-radius: 12px; + box-shadow: 0 1px 3px rgb(15 23 42 / 8%); + + .ant-card-head { + min-height: 52px; + padding: 0 18px; + background: #f8f9fb; + border-bottom: 1px solid #f3f4f6; + } + + .ant-card-head-title { + padding: 12px 0; + } + + .ant-card-extra { + padding: 12px 0; + } + + .ant-card-body { + padding: 16px 18px; + } + } + + .section-title { + font-size: 14px; + font-weight: 600; + color: #1a1a2e; + } + + .staff-card-actions { + display: flex; + gap: 10px; + justify-content: flex-end; + margin-top: 14px; + } + + .staff-drawer-footer { + display: flex; + gap: 12px; + justify-content: flex-end; + } + + .staff-filter-bar { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + margin-bottom: 14px; + } + + .staff-filter-input { + width: 260px; + } + + .staff-filter-select { + width: 132px; + } + + .staff-empty { + color: #999; + } + + .shift-morning { + color: #096dd9; + background: #e6f7ff; + border-color: #91d5ff; + } + + .shift-evening { + color: #d46b08; + background: #fff7e6; + border-color: #ffd591; + } + + .shift-full { + color: #389e0d; + background: #f6ffed; + border-color: #b7eb8f; + } + + .shift-off { + color: #8c8c8c; + background: #f5f5f5; + border-color: #d9d9d9; + } +} diff --git a/apps/web-antd/src/views/store/staff/styles/drawer.less b/apps/web-antd/src/views/store/staff/styles/drawer.less new file mode 100644 index 0000000..dbc810b --- /dev/null +++ b/apps/web-antd/src/views/store/staff/styles/drawer.less @@ -0,0 +1,300 @@ +/* 文件职责:员工排班抽屉与表单样式。 */ +.staff-editor-drawer { + .ant-drawer-header { + min-height: 56px; + padding: 0 24px; + border-bottom: 1px solid #e5e7eb; + } + + .ant-drawer-title { + font-size: 16px; + font-weight: 600; + color: #1a1a2e; + } + + .ant-drawer-body { + padding: 20px 24px; + } + + .ant-drawer-footer { + padding: 14px 24px; + border-top: 1px solid #e5e7eb; + } + + .staff-drawer-form { + display: block; + } + + .g-form-group { + margin-bottom: 16px; + } + + .g-form-label { + display: block; + margin-bottom: 6px; + font-size: 13px; + font-weight: 500; + color: #1a1a2e; + } + + .g-form-label.required::before { + margin-right: 3px; + color: #ff4d4f; + content: '*'; + } + + .g-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + } + + .g-input { + box-sizing: border-box; + width: 100%; + height: 34px; + padding: 0 11px; + font-size: 13px; + color: #1a1a2e; + outline: none; + background: #fff; + border: 1px solid #d9d9d9; + border-radius: 6px; + transition: all 0.2s ease; + } + + .g-input:focus { + border-color: #1677ff; + box-shadow: 0 0 0 3px rgb(22 119 255 / 10%); + } + + .g-input::placeholder { + color: #bfbfbf; + } + + .staff-form-select { + width: 100%; + font-size: 13px; + } + + .staff-form-select .ant-select-selector { + height: 34px !important; + padding: 0 11px !important; + background: #fff !important; + border: 1px solid #d9d9d9 !important; + border-radius: 6px !important; + box-shadow: none !important; + transition: all 0.2s ease; + } + + .staff-form-select .ant-select-selection-item, + .staff-form-select .ant-select-selection-placeholder { + line-height: 32px !important; + } + + .staff-form-select .ant-select-selection-item { + font-size: 13px; + color: #1a1a2e; + } + + .staff-form-select .ant-select-selection-placeholder { + font-size: 13px; + color: #bfbfbf; + } + + .staff-form-select .ant-select-arrow { + color: #8c8c8c; + } + + .staff-form-select:not(.ant-select-disabled):hover .ant-select-selector, + .staff-form-select.ant-select-focused .ant-select-selector { + border-color: #1677ff !important; + } + + .staff-form-select.ant-select-focused .ant-select-selector { + box-shadow: 0 0 0 3px rgb(22 119 255 / 10%) !important; + } + + .g-hint { + margin-top: 4px; + font-size: 11px; + color: #8c8c8c; + } + + .staff-permission-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 10px; + } + + .staff-permission-item { + display: flex; + gap: 6px; + align-items: center; + padding: 6px 10px; + font-size: 13px; + color: #1a1a2e; + cursor: pointer; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 8px; + transition: all 0.2s ease; + } + + .staff-permission-item:hover { + border-color: #1677ff; + } + + .staff-permission-item input[type='checkbox'] { + margin: 0; + accent-color: #1677ff; + cursor: pointer; + } + + .staff-permission-item-all { + grid-column: 1 / -1; + font-weight: 600; + background: #f0f5ff; + border-color: #d6e4ff; + } +} + +.staff-editor-drawer .staff-drawer-footer, +.staff-schedule-drawer .staff-drawer-footer, +.staff-week-drawer .staff-drawer-footer { + display: flex; + gap: 12px; + justify-content: flex-end; +} + +.staff-editor-drawer .staff-drawer-footer .ant-btn, +.staff-schedule-drawer .staff-drawer-footer .ant-btn, +.staff-week-drawer .staff-drawer-footer .ant-btn { + min-width: 96px; + height: 40px; + padding: 0 24px; + font-size: 15px; + border-radius: 10px; +} + +.staff-schedule-drawer, +.staff-week-drawer { + .ant-drawer-header { + padding: 18px 20px 14px; + border-bottom: 1px solid #f0f0f0; + } + + .ant-drawer-title { + font-size: 16px; + font-weight: 600; + color: #1f2329; + } + + .ant-drawer-body { + padding: 18px 20px; + } + + .ant-drawer-footer { + padding: 12px 20px; + border-top: 1px solid #f0f0f0; + } +} + +.staff-schedule-drawer { + .staff-schedule-hint { + padding: 10px 12px; + margin-bottom: 12px; + font-size: 12px; + color: #1d39c4; + background: #f0f5ff; + border: 1px solid #adc6ff; + border-radius: 8px; + } + + .staff-schedule-list { + display: flex; + flex-direction: column; + gap: 10px; + } + + .staff-schedule-row { + display: flex; + gap: 10px; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid #f3f4f6; + } + + .staff-schedule-row:last-child { + border-bottom: 0; + } + + .staff-schedule-day { + width: 44px; + font-size: 13px; + font-weight: 600; + color: #1f2329; + } + + .staff-schedule-shifts { + display: flex; + gap: 6px; + } + + .staff-shift-pill { + min-width: 46px; + padding: 4px 10px; + font-size: 12px; + line-height: 1.4; + color: #667085; + cursor: pointer; + background: #fff; + border: 1px solid #d9d9d9; + border-radius: 999px; + transition: all 0.2s ease; + } + + .staff-shift-pill:hover { + color: #1677ff; + border-color: #1677ff; + } + + .staff-shift-pill.active.shift-morning { + color: #096dd9; + background: #e6f7ff; + border-color: #91d5ff; + } + + .staff-shift-pill.active.shift-evening { + color: #d46b08; + background: #fff7e6; + border-color: #ffd591; + } + + .staff-shift-pill.active.shift-full { + color: #389e0d; + background: #f6ffed; + border-color: #b7eb8f; + } + + .staff-shift-pill.active.shift-off { + color: #8c8c8c; + background: #f5f5f5; + border-color: #d9d9d9; + } + + .staff-schedule-time { + display: flex; + gap: 8px; + align-items: center; + margin-left: auto; + } + + .staff-schedule-time.hidden { + visibility: hidden; + } + + .staff-schedule-separator { + color: #8c8c8c; + } +} diff --git a/apps/web-antd/src/views/store/staff/styles/index.less b/apps/web-antd/src/views/store/staff/styles/index.less new file mode 100644 index 0000000..732af30 --- /dev/null +++ b/apps/web-antd/src/views/store/staff/styles/index.less @@ -0,0 +1,7 @@ +/* 文件职责:员工排班页面样式聚合入口(仅负责分片导入)。 */ +@import './base.less'; +@import './list.less'; +@import './template.less'; +@import './schedule.less'; +@import './drawer.less'; +@import './responsive.less'; diff --git a/apps/web-antd/src/views/store/staff/styles/list.less b/apps/web-antd/src/views/store/staff/styles/list.less new file mode 100644 index 0000000..4d41a38 --- /dev/null +++ b/apps/web-antd/src/views/store/staff/styles/list.less @@ -0,0 +1,196 @@ +/* 文件职责:员工列表区样式。 */ +.page-store-staff { + .staff-table-wrap { + overflow-x: auto; + border: 1px solid #f0f2f5; + border-radius: 10px; + } + + .staff-table { + width: 100%; + min-width: 980px; + border-collapse: collapse; + + th, + td { + padding: 12px 10px; + text-align: left; + border-bottom: 1px solid #f5f5f5; + } + + th { + font-size: 12px; + font-weight: 600; + color: #667085; + background: #fafafa; + } + + td { + font-size: 13px; + vertical-align: middle; + color: #1f2329; + } + + tbody tr:last-child td { + border-bottom: 0; + } + } + + .staff-op-column { + width: 166px; + } + + .staff-row-resigned { + opacity: 0.55; + } + + .staff-info { + display: flex; + gap: 10px; + align-items: center; + min-width: 160px; + } + + .staff-avatar { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + font-size: 14px; + font-weight: 600; + color: #fff; + border-radius: 50%; + } + + .staff-main { + min-width: 0; + } + + .staff-name { + margin-bottom: 2px; + font-size: 13px; + font-weight: 600; + color: #1a1a2e; + } + + .staff-phone { + font-size: 12px; + color: #667085; + } + + .staff-role-tag { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 1px 8px; + font-size: 12px; + font-weight: 600; + border: 1px solid transparent; + border-radius: 999px; + } + + .role-manager { + color: #c41d7f; + background: #fff0f6; + border-color: #ffadd2; + } + + .role-cashier { + color: #1677ff; + background: #e6f4ff; + border-color: #91caff; + } + + .role-courier { + color: #389e0d; + background: #f6ffed; + border-color: #b7eb8f; + } + + .role-chef { + color: #d46b08; + background: #fff7e6; + border-color: #ffd591; + } + + .staff-status { + display: inline-flex; + gap: 6px; + align-items: center; + font-size: 13px; + } + + .staff-status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + } + + .status-dot-active { + background: #22c55e; + } + + .status-dot-leave { + background: #f59e0b; + } + + .status-dot-resigned { + background: #9ca3af; + } + + .staff-permissions { + display: flex; + flex-wrap: wrap; + gap: 4px; + } + + .staff-permission-pill { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 1px 7px; + font-size: 11px; + font-weight: 600; + color: #2f54eb; + background: #f0f5ff; + border-radius: 6px; + } + + .staff-actions { + display: flex; + gap: 10px; + align-items: center; + } + + .staff-link { + font-size: 13px; + color: #1677ff; + cursor: pointer; + user-select: none; + } + + .staff-link:hover { + text-decoration: underline; + } + + .staff-link-disabled { + color: #bfbfbf; + text-decoration: none !important; + pointer-events: none; + cursor: not-allowed; + } + + .staff-delete-btn { + height: auto !important; + padding: 0 !important; + font-size: 13px; + } + + .staff-pagination { + display: flex; + justify-content: flex-end; + margin-top: 14px; + } +} diff --git a/apps/web-antd/src/views/store/staff/styles/responsive.less b/apps/web-antd/src/views/store/staff/styles/responsive.less new file mode 100644 index 0000000..ab8d714 --- /dev/null +++ b/apps/web-antd/src/views/store/staff/styles/responsive.less @@ -0,0 +1,47 @@ +/* 文件职责:员工排班页面响应式样式。 */ +@media (max-width: 992px) { + .page-store-staff { + .staff-filter-input { + width: 100%; + } + + .staff-filter-select { + width: 140px; + } + + .staff-form-grid, + .staff-permission-grid { + grid-template-columns: 1fr; + } + + .staff-schedule-row { + flex-wrap: wrap; + align-items: flex-start; + } + + .staff-schedule-time { + width: 100%; + margin-left: 0; + } + } +} + +@media (max-width: 768px) { + .page-store-staff { + .staff-filter-select { + width: 100%; + } + + .staff-card { + .ant-card-head, + .ant-card-body { + padding-right: 14px; + padding-left: 14px; + } + } + + .staff-actions { + gap: 6px; + } + } +} diff --git a/apps/web-antd/src/views/store/staff/styles/schedule.less b/apps/web-antd/src/views/store/staff/styles/schedule.less new file mode 100644 index 0000000..9e11771 --- /dev/null +++ b/apps/web-antd/src/views/store/staff/styles/schedule.less @@ -0,0 +1,135 @@ +/* 文件职责:排班看板与周排班抽屉样式。 */ +.page-store-staff { + .schedule-empty, + .staff-week-empty { + padding: 18px 0; + } + + .schedule-board-wrap, + .staff-week-table-wrap { + overflow-x: auto; + border: 1px solid #f0f2f5; + border-radius: 10px; + } + + .schedule-board-table, + .staff-week-table { + width: 100%; + min-width: 900px; + border-collapse: collapse; + + th, + td { + padding: 10px 8px; + text-align: center; + border-bottom: 1px solid #f3f4f6; + } + + th { + font-size: 12px; + font-weight: 600; + color: #667085; + background: #fafafa; + } + } + + .schedule-staff-cell, + .staff-week-name-cell { + min-width: 96px; + text-align: left !important; + background: #fff; + } + + .schedule-staff-name, + .staff-week-name { + font-size: 13px; + font-weight: 600; + color: #1f2329; + } + + .schedule-staff-role, + .staff-week-role { + margin-top: 2px; + font-size: 11px; + color: #86909c; + } + + .schedule-cell { + display: flex; + flex-direction: column; + gap: 2px; + align-items: center; + justify-content: center; + width: 100%; + min-height: 50px; + padding: 4px 5px; + border: 1px solid; + border-radius: 8px; + } + + .schedule-cell-time { + font-size: 11px; + line-height: 1.3; + } + + .schedule-cell-label { + font-size: 11px; + opacity: 0.82; + } + + .staff-week-legend { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 12px; + font-size: 12px; + color: #667085; + } + + .staff-week-legend-item { + display: inline-flex; + gap: 6px; + align-items: center; + } + + .staff-week-legend-dot { + display: inline-block; + width: 12px; + height: 12px; + border: 1px solid transparent; + border-radius: 3px; + } + + .staff-week-cell-wrap { + padding: 6px; + } + + .staff-week-cell { + display: flex; + flex-direction: column; + gap: 2px; + align-items: center; + justify-content: center; + width: 100%; + min-height: 58px; + padding: 6px; + cursor: pointer; + border: 1px solid; + border-radius: 8px; + transition: filter 0.2s ease; + } + + .staff-week-cell:hover { + filter: brightness(0.98); + } + + .staff-week-cell-label { + font-size: 12px; + font-weight: 600; + } + + .staff-week-cell-time { + font-size: 11px; + opacity: 0.85; + } +} diff --git a/apps/web-antd/src/views/store/staff/styles/template.less b/apps/web-antd/src/views/store/staff/styles/template.less new file mode 100644 index 0000000..6600a2c --- /dev/null +++ b/apps/web-antd/src/views/store/staff/styles/template.less @@ -0,0 +1,65 @@ +/* 文件职责:班次模板区样式。 */ +.page-store-staff { + .template-list { + display: flex; + flex-direction: column; + gap: 10px; + } + + .template-row { + display: flex; + gap: 12px; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid #f3f4f6; + } + + .template-row:last-child { + border-bottom: 0; + } + + .template-dot { + width: 10px; + height: 10px; + border-radius: 3px; + } + + .template-dot-morning { + background: #1890ff; + } + + .template-dot-evening { + background: #fa8c16; + } + + .template-dot-full { + background: #52c41a; + } + + .template-label { + width: 48px; + font-size: 14px; + font-weight: 600; + color: #1f2329; + } + + .template-time-group { + display: flex; + gap: 8px; + align-items: center; + } + + .template-time-separator { + color: #8c8c8c; + } + + .template-tip { + padding: 10px 12px; + margin-top: 8px; + font-size: 12px; + color: #667085; + background: #fafafa; + border: 1px solid #f0f2f5; + border-radius: 8px; + } +} diff --git a/apps/web-antd/src/views/store/staff/types.ts b/apps/web-antd/src/views/store/staff/types.ts new file mode 100644 index 0000000..079a631 --- /dev/null +++ b/apps/web-antd/src/views/store/staff/types.ts @@ -0,0 +1,88 @@ +/** + * 文件职责:员工排班页面类型定义。 + * 1. 声明筛选、抽屉、周排班编辑等页面状态类型。 + * 2. 约束组件之间的数据结构与事件载荷。 + */ +import type { + ShiftType, + StaffDayShiftDto, + StaffRoleType, + StaffStatus, + StoreShiftTemplatesDto, +} from '#/api/store-staff'; + +export type StaffDrawerMode = 'create' | 'edit'; + +export interface StaffFilterState { + keyword: string; + page: number; + pageSize: number; + roleType: '' | StaffRoleType; + status: '' | StaffStatus; +} + +export interface StaffEditorFormState { + email: string; + id: string; + name: string; + permissions: string[]; + phone: string; + roleType: StaffRoleType; + status: StaffStatus; +} + +export interface StaffPersonalScheduleFormState { + roleType: StaffRoleType; + shifts: StaffDayShiftDto[]; + staffId: string; + staffName: string; +} + +export interface WeekEditorRow { + roleType: StaffRoleType; + shifts: StaffDayShiftDto[]; + staffId: string; + staffName: string; + status: StaffStatus; +} + +export interface ShiftOption { + className: string; + label: string; + value: ShiftType; +} + +export interface DayOption { + dayOfWeek: number; + label: string; + shortLabel: string; +} + +export interface StaffRoleOption { + label: string; + tagClassName: string; + value: StaffRoleType; +} + +export interface StaffStatusOption { + dotClassName: string; + label: string; + value: StaffStatus; +} + +export interface StaffPermissionOption { + label: string; + value: string; +} + +export interface ShiftLegendItem { + className: string; + label: string; + timeText: string; + type: Exclude; +} + +export interface StaffScheduleSnapshot { + scheduleMap: Record; + templates: StoreShiftTemplatesDto; +}