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

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

View File

@@ -0,0 +1,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<PaginatedResult<StoreStaffDto>>('/store/staff', {
params,
});
}
/** 新增/编辑员工 */
export async function saveStoreStaffApi(data: SaveStoreStaffParams) {
return requestClient.post<StoreStaffDto>('/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<StoreStaffScheduleDto>('/store/staff/schedule', {
params: {
storeId,
weekStartDate,
},
});
}
/** 保存班次模板 */
export async function saveStoreStaffTemplatesApi(
data: SaveStoreStaffTemplatesParams,
) {
return requestClient.post<StoreShiftTemplatesDto>(
'/store/staff/template/save',
data,
);
}
/** 保存单员工排班 */
export async function saveStoreStaffPersonalScheduleApi(
data: SaveStoreStaffPersonalScheduleParams,
) {
return requestClient.post<StaffScheduleDto>(
'/store/staff/schedule/personal/save',
data,
);
}
/** 保存周排班 */
export async function saveStoreStaffWeeklyScheduleApi(
data: SaveStoreStaffWeeklyScheduleParams,
) {
return requestClient.post<StoreStaffScheduleDto>(
'/store/staff/schedule/weekly/save',
data,
);
}
/** 复制班次模板与排班 */
export async function copyStoreStaffScheduleApi(
data: CopyStoreStaffScheduleParams,
) {
return requestClient.post('/store/staff/copy', data);
}

View File

@@ -4,5 +4,6 @@ import './store-dinein';
import './store-fees';
import './store-hours';
import './store-pickup';
import './store-staff';
console.warn('[Mock] Mock 数据已启用');

View File

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

View File

@@ -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',

View File

@@ -154,7 +154,9 @@ function toNumber(value: null | number | string, fallback = 0) {
<template #footer>
<div class="drawer-footer">
<Button @click="emit('update:open', false)">取消</Button>
<Button type="primary" @click="emit('submit')">确认</Button>
<Button type="primary" @click="emit('submit')">
{{ props.form.id ? '保存修改' : '新增并保存' }}
</Button>
</div>
</template>
</Drawer>

View File

@@ -153,7 +153,9 @@ function readInputValue(event: Event) {
<template #footer>
<div class="drawer-footer">
<Button @click="emit('update:open', false)">取消</Button>
<Button type="primary" @click="emit('submit')">确认</Button>
<Button type="primary" @click="emit('submit')">
{{ props.form.id ? '保存修改' : '新增并保存' }}
</Button>
</div>
</template>
</Drawer>

View File

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

View File

@@ -90,7 +90,7 @@ function toNumber(value: null | number | string, fallback = 1) {
:loading="props.isSaving"
@click="emit('submit')"
>
{{ props.submitText }}
{{ props.form.id ? '保存修改' : '新增并保存' }}
</Button>
</div>
</template>

View File

@@ -161,7 +161,7 @@ function resolveBusinessStatusClass(status: DineInTableStatus) {
:loading="props.isSaving"
@click="emit('submit')"
>
{{ props.submitText }}
{{ props.form.id ? '保存修改' : '新增并保存' }}
</Button>
</div>
</template>

View File

@@ -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;

View File

@@ -110,7 +110,7 @@ function toNumber(value: null | number | string, fallback = 0) {
:loading="props.isSaving"
@click="emit('submit')"
>
确认
{{ props.form.id ? '保存修改' : '新增并保存' }}
</Button>
</div>
</template>

View File

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

View File

@@ -195,7 +195,7 @@ function readTimeValue(value: unknown) {
:loading="props.isWeeklySubmitting"
@click="emit('submit')"
>
确认添加
新增并保存
</Button>
</div>
</template>

View File

@@ -232,7 +232,7 @@ function readTimeValue(value: unknown) {
:loading="props.isHolidaySubmitting"
@click="emit('submit')"
>
{{ props.submitText }}
{{ props.holidayForm.id ? '保存修改' : '新增并保存' }}
</Button>
</div>
</template>

View File

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

View File

@@ -152,7 +152,7 @@ function handleServiceTypesChange(value: unknown) {
:loading="props.isSubmitting"
@click="emit('submit')"
>
确认
{{ props.title.includes('编辑') ? '保存修改' : '新增并保存' }}
</Button>
</div>
</template>

View File

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

View File

@@ -196,7 +196,7 @@ function readTimeValue(value: unknown) {
:loading="props.isSaving"
@click="emit('submit')"
>
{{ props.form.id ? '保存修改' : '确认添加' }}
{{ props.form.id ? '保存修改' : '新增并保存' }}
</Button>
</div>
</template>

View File

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

View File

@@ -0,0 +1,124 @@
<script setup lang="ts">
/**
* 文件职责:班次模板卡片。
* 1. 提供早班/晚班/全天模板时间编辑。
* 2. 透出保存与重置事件。
*/
import type { Dayjs } from 'dayjs';
import type { ShiftType, StoreShiftTemplatesDto } from '#/api/store-staff';
import { Button, Card, TimePicker } from 'ant-design-vue';
import dayjs from 'dayjs';
interface Props {
isSaving: boolean;
onSetTemplateTime: (payload: {
field: 'endTime' | 'startTime';
shiftType: Exclude<ShiftType, 'off'>;
value: string;
}) => void;
templates: StoreShiftTemplatesDto;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'reset'): void;
(event: 'save'): void;
}>();
const templateRows: Array<{
colorClass: string;
label: string;
shiftType: Exclude<ShiftType, 'off'>;
}> = [
{ shiftType: 'morning', label: '早班', colorClass: 'template-dot-morning' },
{ shiftType: 'evening', label: '晚班', colorClass: 'template-dot-evening' },
{ shiftType: 'full', label: '全天', colorClass: 'template-dot-full' },
];
/** 将 HH:mm 字符串转换为时间组件值。 */
function toPickerValue(time: string) {
if (!time) return null;
return dayjs(`2000-01-01 ${time}`);
}
/** 将时间组件值转换为 HH:mm 字符串。 */
function toTimeText(value: Dayjs | null) {
return value ? value.format('HH:mm') : '';
}
/** 处理模板时间变更。 */
function handleTemplateTimeChange(payload: {
field: 'endTime' | 'startTime';
shiftType: Exclude<ShiftType, 'off'>;
value: Dayjs | null;
}) {
props.onSetTemplateTime({
shiftType: payload.shiftType,
field: payload.field,
value: toTimeText(payload.value),
});
}
</script>
<template>
<Card :bordered="false" class="staff-card">
<template #title>
<span class="section-title">班次模板</span>
</template>
<div class="template-list">
<div
v-for="row in templateRows"
:key="row.shiftType"
class="template-row"
>
<span class="template-dot" :class="row.colorClass"></span>
<span class="template-label">{{ row.label }}</span>
<div class="template-time-group">
<TimePicker
:value="toPickerValue(props.templates[row.shiftType].startTime)"
format="HH:mm"
:allow-clear="false"
@update:value="
(value) =>
handleTemplateTimeChange({
shiftType: row.shiftType,
field: 'startTime',
value,
})
"
/>
<span class="template-time-separator">~</span>
<TimePicker
:value="toPickerValue(props.templates[row.shiftType].endTime)"
format="HH:mm"
:allow-clear="false"
@update:value="
(value) =>
handleTemplateTimeChange({
shiftType: row.shiftType,
field: 'endTime',
value,
})
"
/>
</div>
</div>
</div>
<div class="template-tip">
调整模板后个人排班和周排班中的同类型班次会同步到新的时间段
</div>
<div class="staff-card-actions">
<Button :disabled="props.isSaving" @click="emit('reset')">重置</Button>
<Button type="primary" :loading="props.isSaving" @click="emit('save')">
保存模板
</Button>
</div>
</Card>
</template>

View File

@@ -0,0 +1,221 @@
<script setup lang="ts">
/**
* 文件职责:员工新增/编辑抽屉。
* 1. 展示员工基础信息与权限表单。
* 2. 透出保存与关闭事件。
*/
import type { StaffRoleType, StaffStatus } from '#/api/store-staff';
import type {
StaffEditorFormState,
StaffPermissionOption,
StaffRoleOption,
StaffStatusOption,
} from '#/views/store/staff/types';
import { Button, Drawer, Select } from 'ant-design-vue';
interface Props {
form: StaffEditorFormState;
isSaving: boolean;
onSetEmail: (value: string) => void;
onSetName: (value: string) => void;
onSetPermissions: (value: string[]) => void;
onSetPhone: (value: string) => void;
onSetRoleType: (value: StaffRoleType) => void;
onSetStatus: (value: StaffStatus) => void;
open: boolean;
permissionOptions: StaffPermissionOption[];
roleOptions: StaffRoleOption[];
statusOptions: StaffStatusOption[];
submitText: string;
title: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'submit'): void;
(event: 'update:open', value: boolean): void;
}>();
/** 判断是否已全选权限。 */
function isAllPermissionChecked() {
if (props.form.permissions.includes('全部权限')) return true;
if (props.permissionOptions.length === 0) return false;
return props.permissionOptions.every((item) =>
props.form.permissions.includes(item.value),
);
}
/** 切换单个权限。 */
function togglePermission(value: string, checked: boolean) {
const basePermissions = props.form.permissions.filter(
(item) => item !== '全部权限',
);
const next = checked
? [...new Set([value, ...basePermissions])]
: basePermissions.filter((item) => item !== value);
props.onSetPermissions(next);
}
/** 全选/取消全选权限。 */
function toggleAllPermissions(checked: boolean) {
props.onSetPermissions(
checked
? ['全部权限', ...props.permissionOptions.map((item) => item.value)]
: [],
);
}
/** 统一处理角色更新。 */
function handleRoleChange(value: unknown) {
if (typeof value !== 'string') return;
props.onSetRoleType(value as StaffRoleType);
}
/** 统一处理状态更新。 */
function handleStatusChange(value: unknown) {
if (typeof value !== 'string') return;
props.onSetStatus(value as StaffStatus);
}
/** 处理姓名输入。 */
function handleNameInput(event: Event) {
props.onSetName((event.target as HTMLInputElement | null)?.value ?? '');
}
/** 处理手机号输入。 */
function handlePhoneInput(event: Event) {
props.onSetPhone((event.target as HTMLInputElement | null)?.value ?? '');
}
/** 处理邮箱输入。 */
function handleEmailInput(event: Event) {
props.onSetEmail((event.target as HTMLInputElement | null)?.value ?? '');
}
/** 处理全选权限切换。 */
function handlePermissionAllChange(event: Event) {
toggleAllPermissions(
Boolean((event.target as HTMLInputElement | null)?.checked),
);
}
/** 处理单项权限切换。 */
function handlePermissionItemChange(event: Event, value: string) {
togglePermission(
value,
Boolean((event.target as HTMLInputElement | null)?.checked),
);
}
</script>
<template>
<Drawer
:open="props.open"
:title="props.title"
:width="520"
:mask-closable="true"
:destroy-on-close="false"
class="staff-editor-drawer"
@update:open="(value) => emit('update:open', value)"
>
<div class="staff-drawer-form">
<div class="g-form-group">
<label class="g-form-label required">姓名</label>
<input
:value="props.form.name"
class="g-input"
maxlength="20"
placeholder="请输入员工姓名"
@input="handleNameInput"
/>
</div>
<div class="g-form-group">
<label class="g-form-label required">手机号</label>
<input
:value="props.form.phone"
class="g-input"
maxlength="11"
placeholder="请输入手机号"
@input="handlePhoneInput"
/>
</div>
<div class="g-form-group">
<label class="g-form-label">邮箱</label>
<input
:value="props.form.email"
class="g-input"
maxlength="64"
placeholder="可选"
@input="handleEmailInput"
/>
<div class="g-hint">用于接收系统通知</div>
</div>
<div class="g-form-grid">
<div class="g-form-group">
<label class="g-form-label required">角色</label>
<Select
:value="props.form.roleType"
class="staff-form-select"
:options="props.roleOptions"
@update:value="(value) => handleRoleChange(value)"
/>
</div>
<div class="g-form-group">
<label class="g-form-label">状态</label>
<Select
:value="props.form.status"
class="staff-form-select"
:options="props.statusOptions"
@update:value="(value) => handleStatusChange(value)"
/>
</div>
</div>
<div class="g-form-group">
<label class="g-form-label">权限</label>
<div class="staff-permission-grid">
<label class="staff-permission-item staff-permission-item-all">
<input
:checked="isAllPermissionChecked()"
type="checkbox"
@change="handlePermissionAllChange"
/>
<span>全部权限</span>
</label>
<label
v-for="item in props.permissionOptions"
:key="item.value"
class="staff-permission-item"
>
<input
:checked="props.form.permissions.includes(item.value)"
type="checkbox"
@change="(event) => handlePermissionItemChange(event, item.value)"
/>
<span>{{ item.label }}</span>
</label>
</div>
</div>
</div>
<template #footer>
<div class="staff-drawer-footer">
<Button @click="emit('update:open', false)"> 取消 </Button>
<Button
type="primary"
:loading="props.isSaving"
@click="emit('submit')"
>
{{ props.form.id ? '保存修改' : '新增并保存' }}
</Button>
</div>
</template>
</Drawer>
</template>

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
/**
* 文件职责:员工列表筛选条。
* 1. 提供关键词、角色、状态筛选。
* 2. 仅透出筛选事件,不处理请求。
*/
import type { StaffRoleType, StaffStatus } from '#/api/store-staff';
import { Button, Input, Select } from 'ant-design-vue';
interface RoleOption {
label: string;
value: StaffRoleType;
}
interface StatusOption {
label: string;
value: StaffStatus;
}
interface Props {
isLoading: boolean;
keyword: string;
onSetKeyword: (value: string) => void;
onSetRoleType: (value: '' | StaffRoleType) => void;
onSetStatus: (value: '' | StaffStatus) => void;
roleOptions: RoleOption[];
roleType: '' | StaffRoleType;
status: '' | StaffStatus;
statusOptions: StatusOption[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'reset'): void;
(event: 'search'): void;
}>();
const roleSelectOptions = [
{ label: '全部角色', value: '' },
...props.roleOptions,
];
const statusSelectOptions = [
{ label: '全部状态', value: '' },
...props.statusOptions,
];
/** 统一处理角色筛选值。 */
function handleRoleChange(value: unknown) {
if (typeof value !== 'string') {
props.onSetRoleType('');
return;
}
props.onSetRoleType(value as '' | StaffRoleType);
}
/** 统一处理状态筛选值。 */
function handleStatusChange(value: unknown) {
if (typeof value !== 'string') {
props.onSetStatus('');
return;
}
props.onSetStatus(value as '' | StaffStatus);
}
</script>
<template>
<div class="staff-filter-bar">
<Input
:value="props.keyword"
class="staff-filter-input"
allow-clear
placeholder="搜索姓名/手机号/邮箱"
@press-enter="emit('search')"
@update:value="(value) => props.onSetKeyword(String(value || ''))"
/>
<Select
:value="props.roleType"
class="staff-filter-select"
:options="roleSelectOptions"
@update:value="(value) => handleRoleChange(value)"
/>
<Select
:value="props.status"
class="staff-filter-select"
:options="statusSelectOptions"
@update:value="(value) => handleStatusChange(value)"
/>
<Button type="primary" :loading="props.isLoading" @click="emit('search')">
查询
</Button>
<Button :disabled="props.isLoading" @click="emit('reset')">重置</Button>
</div>
</template>

View File

@@ -0,0 +1,133 @@
<script setup lang="ts">
/**
* 文件职责:员工个人排班抽屉。
* 1. 按周编辑单个员工的每日班次。
* 2. 支持班次切换与时间微调。
*/
import type { Dayjs } from 'dayjs';
import type { ShiftType } from '#/api/store-staff';
import type {
DayOption,
ShiftOption,
StaffPersonalScheduleFormState,
} from '#/views/store/staff/types';
import { Button, Drawer, TimePicker } from 'ant-design-vue';
import dayjs from 'dayjs';
interface Props {
dayOptions: DayOption[];
form: StaffPersonalScheduleFormState;
isSaving: boolean;
onSetShiftEnd: (dayOfWeek: number, endTime: string) => void;
onSetShiftStart: (dayOfWeek: number, startTime: string) => void;
onSetShiftType: (dayOfWeek: number, shiftType: ShiftType) => void;
open: boolean;
shiftOptions: ShiftOption[];
title: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'submit'): void;
(event: 'update:open', value: boolean): void;
}>();
/** 获取某日排班。 */
function getDayShift(dayOfWeek: number) {
return props.form.shifts.find((item) => item.dayOfWeek === dayOfWeek);
}
/** 转换时间组件值。 */
function toPickerValue(time: string) {
if (!time) return null;
return dayjs(`2000-01-01 ${time}`);
}
/** 时间组件值转字符串。 */
function toTimeText(value: Dayjs | null) {
return value ? value.format('HH:mm') : '';
}
</script>
<template>
<Drawer
:open="props.open"
:title="props.title"
:width="620"
:mask-closable="true"
:destroy-on-close="false"
class="staff-schedule-drawer"
@update:open="(value) => emit('update:open', value)"
>
<div class="staff-schedule-hint">
点击班次胶囊可自动填充时间选择休息后该日不排班
</div>
<div class="staff-schedule-list">
<div
v-for="day in props.dayOptions"
:key="day.dayOfWeek"
class="staff-schedule-row"
>
<span class="staff-schedule-day">{{ day.label }}</span>
<div class="staff-schedule-shifts">
<button
v-for="shift in props.shiftOptions"
:key="shift.value"
type="button"
class="staff-shift-pill"
:class="[
shift.className,
{
active: getDayShift(day.dayOfWeek)?.shiftType === shift.value,
},
]"
@click="props.onSetShiftType(day.dayOfWeek, shift.value)"
>
{{ shift.label }}
</button>
</div>
<div
class="staff-schedule-time"
:class="{ hidden: getDayShift(day.dayOfWeek)?.shiftType === 'off' }"
>
<TimePicker
:value="toPickerValue(getDayShift(day.dayOfWeek)?.startTime || '')"
format="HH:mm"
:allow-clear="false"
@update:value="
(value) => props.onSetShiftStart(day.dayOfWeek, toTimeText(value))
"
/>
<span class="staff-schedule-separator">~</span>
<TimePicker
:value="toPickerValue(getDayShift(day.dayOfWeek)?.endTime || '')"
format="HH:mm"
:allow-clear="false"
@update:value="
(value) => props.onSetShiftEnd(day.dayOfWeek, toTimeText(value))
"
/>
</div>
</div>
</div>
<template #footer>
<div class="staff-drawer-footer">
<Button @click="emit('update:open', false)">取消</Button>
<Button
type="primary"
:loading="props.isSaving"
@click="emit('submit')"
>
保存排班
</Button>
</div>
</template>
</Drawer>
</template>

View File

@@ -0,0 +1,197 @@
<script setup lang="ts">
/**
* 文件职责:员工列表区块。
* 1. 展示员工信息表格与分页。
* 2. 透出添加、编辑、排班、删除动作。
*/
import type {
StaffRoleType,
StaffStatus,
StoreStaffDto,
} from '#/api/store-staff';
import {
Button,
Card,
Empty,
Pagination,
Popconfirm,
Spin,
} from 'ant-design-vue';
interface Props {
deletingStaffId: string;
formatRoleLabel: (value: StaffRoleType) => string;
formatStatusLabel: (value: StaffStatus) => string;
getRoleTagClass: (value: StaffRoleType) => string;
getStatusDotClass: (value: StaffStatus) => string;
isLoading: boolean;
isScheduleDisabled: (staff: StoreStaffDto) => boolean;
maskPhone: (phone: string) => string;
page: number;
pageSize: number;
pageSizeOptions: number[];
rows: StoreStaffDto[];
total: number;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'add'): void;
(event: 'delete', staff: StoreStaffDto): void;
(event: 'edit', staff: StoreStaffDto): void;
(event: 'pageChange', payload: { page: number; pageSize: number }): void;
(event: 'schedule', staff: StoreStaffDto): void;
}>();
/** 分页切换时同步页码和页长。 */
function handlePageChange(page: number, pageSize: number) {
emit('pageChange', { page, pageSize });
}
/** 点击排班按钮时拦截禁用态。 */
function handleScheduleClick(staff: StoreStaffDto) {
if (props.isScheduleDisabled(staff)) return;
emit('schedule', staff);
}
</script>
<template>
<Card :bordered="false" class="staff-card">
<template #title>
<span class="section-title">员工列表</span>
</template>
<template #extra>
<Button type="primary" @click="emit('add')">+ 添加员工</Button>
</template>
<Spin :spinning="props.isLoading">
<slot name="filters"></slot>
<div class="staff-table-wrap">
<table class="staff-table">
<thead>
<tr>
<th>员工信息</th>
<th>角色</th>
<th>邮箱</th>
<th>状态</th>
<th>权限</th>
<th>入职时间</th>
<th class="staff-op-column">操作</th>
</tr>
</thead>
<tbody>
<tr v-if="props.rows.length === 0">
<td colspan="7">
<Empty description="暂无员工" />
</td>
</tr>
<tr
v-for="staff in props.rows"
v-else
:key="staff.id"
:class="{ 'staff-row-resigned': staff.status === 'resigned' }"
>
<td>
<div class="staff-info">
<div
class="staff-avatar"
:style="{ background: staff.avatarColor || '#1677ff' }"
>
{{ staff.name.slice(0, 1) }}
</div>
<div class="staff-main">
<div class="staff-name">{{ staff.name }}</div>
<div class="staff-phone">
{{ props.maskPhone(staff.phone) }}
</div>
</div>
</div>
</td>
<td>
<span
class="staff-role-tag"
:class="props.getRoleTagClass(staff.roleType)"
>
{{ props.formatRoleLabel(staff.roleType) }}
</span>
</td>
<td>{{ staff.email || '--' }}</td>
<td>
<span class="staff-status">
<span
class="staff-status-dot"
:class="props.getStatusDotClass(staff.status)"
></span>
{{ props.formatStatusLabel(staff.status) }}
</span>
</td>
<td>
<div
v-if="staff.permissions.length > 0"
class="staff-permissions"
>
<span
v-for="item in staff.permissions"
:key="item"
class="staff-permission-pill"
>
{{ item }}
</span>
</div>
<span v-else class="staff-empty">--</span>
</td>
<td>{{ staff.hiredAt }}</td>
<td>
<div class="staff-actions">
<a class="staff-link" @click="emit('edit', staff)">编辑</a>
<a
class="staff-link"
:class="{
'staff-link-disabled': props.isScheduleDisabled(staff),
}"
@click="handleScheduleClick(staff)"
>
排班
</a>
<Popconfirm
title="确认删除该员工吗?"
ok-text="确认"
cancel-text="取消"
@confirm="emit('delete', staff)"
>
<Button
type="link"
danger
size="small"
class="staff-delete-btn"
:loading="props.deletingStaffId === staff.id"
>
删除
</Button>
</Popconfirm>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="staff-pagination" v-if="props.total > 0">
<Pagination
:current="props.page"
:page-size="props.pageSize"
:total="props.total"
:page-size-options="props.pageSizeOptions"
show-size-changer
:show-total="(total) => `${total}`"
@change="handlePageChange"
@show-size-change="handlePageChange"
/>
</div>
</Spin>
</Card>
</template>

View File

@@ -0,0 +1,152 @@
<script setup lang="ts">
/**
* 文件职责:全员周排班抽屉。
* 1. 以矩阵方式展示全员排班。
* 2. 点击单元格循环切换班次。
*/
import type { ShiftType, StaffDayShiftDto } from '#/api/store-staff';
import type {
DayOption,
ShiftLegendItem,
WeekEditorRow,
} from '#/views/store/staff/types';
import { Button, Drawer, Empty } from 'ant-design-vue';
interface Props {
dayOptions: DayOption[];
getShiftClass: (shiftType: ShiftType) => string;
getShiftLabel: (shiftType: ShiftType) => string;
getShiftTimeText: (shift: StaffDayShiftDto) => string;
isSaving: boolean;
legendItems: ShiftLegendItem[];
open: boolean;
roleLabelResolver: (roleType: WeekEditorRow['roleType']) => string;
rows: WeekEditorRow[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'cycleShift', payload: { dayOfWeek: number; staffId: string }): void;
(event: 'submit'): void;
(event: 'update:open', value: boolean): void;
}>();
/** 获取某行某天班次,缺失时兜底为休息。 */
function resolveDayShift(
row: WeekEditorRow,
dayOfWeek: number,
): StaffDayShiftDto {
return (
row.shifts.find((item) => item.dayOfWeek === dayOfWeek) ?? {
dayOfWeek,
shiftType: 'off',
startTime: '',
endTime: '',
}
);
}
</script>
<template>
<Drawer
:open="props.open"
title="编辑本周排班表"
:width="760"
:mask-closable="true"
:destroy-on-close="false"
class="staff-week-drawer"
@update:open="(value) => emit('update:open', value)"
>
<div class="staff-week-legend">
<span
v-for="item in props.legendItems"
:key="item.type"
class="staff-week-legend-item"
>
<i class="staff-week-legend-dot" :class="item.className"></i>
{{ item.label }} {{ item.timeText }}
</span>
<span class="staff-week-legend-item">
<i class="staff-week-legend-dot shift-off"></i>
休息
</span>
</div>
<div v-if="props.rows.length === 0" class="staff-week-empty">
<Empty description="暂无可编辑排班" />
</div>
<div v-else class="staff-week-table-wrap">
<table class="staff-week-table">
<thead>
<tr>
<th>员工</th>
<th v-for="day in props.dayOptions" :key="day.dayOfWeek">
{{ day.label }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in props.rows" :key="row.staffId">
<td class="staff-week-name-cell">
<div class="staff-week-name">{{ row.staffName }}</div>
<div class="staff-week-role">
{{ props.roleLabelResolver(row.roleType) }}
</div>
</td>
<td
v-for="day in props.dayOptions"
:key="`${row.staffId}-${day.dayOfWeek}`"
class="staff-week-cell-wrap"
>
<button
type="button"
class="staff-week-cell"
:class="
props.getShiftClass(
resolveDayShift(row, day.dayOfWeek).shiftType,
)
"
@click="
emit('cycleShift', {
staffId: row.staffId,
dayOfWeek: day.dayOfWeek,
})
"
>
<span class="staff-week-cell-label">
{{
props.getShiftLabel(
resolveDayShift(row, day.dayOfWeek).shiftType,
)
}}
</span>
<span class="staff-week-cell-time">
{{
props.getShiftTimeText(resolveDayShift(row, day.dayOfWeek))
}}
</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<template #footer>
<div class="staff-drawer-footer">
<Button @click="emit('update:open', false)">取消</Button>
<Button
type="primary"
:loading="props.isSaving"
@click="emit('submit')"
>
保存排班
</Button>
</div>
</template>
</Drawer>
</template>

View File

@@ -0,0 +1,106 @@
<script setup lang="ts">
/**
* 文件职责:周排班看板。
* 1. 展示各员工周排班只读视图。
* 2. 提供“编辑排班”入口。
*/
import type { ShiftType, StaffDayShiftDto } from '#/api/store-staff';
import type { DayOption, WeekEditorRow } from '#/views/store/staff/types';
import { Button, Card, Empty } from 'ant-design-vue';
interface Props {
dayOptions: DayOption[];
getShiftClass: (shiftType: ShiftType) => string;
getShiftLabel: (shiftType: ShiftType) => string;
getShiftTimeText: (shift: StaffDayShiftDto) => string;
roleLabelResolver: (roleType: WeekEditorRow['roleType']) => string;
rows: WeekEditorRow[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: 'editWeek'): void;
}>();
/** 读取员工某日排班,缺失时兜底为休息。 */
function resolveDayShift(
row: WeekEditorRow,
dayOfWeek: number,
): StaffDayShiftDto {
return (
row.shifts.find((item) => item.dayOfWeek === dayOfWeek) ?? {
dayOfWeek,
shiftType: 'off',
startTime: '',
endTime: '',
}
);
}
</script>
<template>
<Card :bordered="false" class="staff-card">
<template #title>
<span class="section-title">本周排班</span>
</template>
<template #extra>
<Button @click="emit('editWeek')">编辑排班</Button>
</template>
<div v-if="props.rows.length === 0" class="schedule-empty">
<Empty description="暂无可排班员工" />
</div>
<div v-else class="schedule-board-wrap">
<table class="schedule-board-table">
<thead>
<tr>
<th>员工</th>
<th v-for="day in props.dayOptions" :key="day.dayOfWeek">
{{ day.label }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in props.rows" :key="row.staffId">
<td class="schedule-staff-cell">
<div class="schedule-staff-name">{{ row.staffName }}</div>
<div class="schedule-staff-role">
{{ props.roleLabelResolver(row.roleType) }}
</div>
</td>
<td
v-for="day in props.dayOptions"
:key="`${row.staffId}-${day.dayOfWeek}`"
>
<div
class="schedule-cell"
:class="
props.getShiftClass(
resolveDayShift(row, day.dayOfWeek).shiftType,
)
"
>
<span class="schedule-cell-time">
{{
props.getShiftTimeText(resolveDayShift(row, day.dayOfWeek))
}}
</span>
<span class="schedule-cell-label">
{{
props.getShiftLabel(
resolveDayShift(row, day.dayOfWeek).shiftType,
)
}}
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</Card>
</template>

View File

@@ -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];

View File

@@ -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<StoreListItemDto[]>;
copyTargetStoreIds: Ref<string[]>;
isCopyModalOpen: Ref<boolean>;
isCopySubmitting: Ref<boolean>;
selectedStoreId: Ref<string>;
}
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,
};
}

View File

@@ -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<boolean>;
isStoreLoading: Ref<boolean>;
scheduleMap: Ref<Record<string, StaffDayShiftDto[]>>;
scheduleSnapshot: Ref<null | StaffScheduleSnapshot>;
selectedStoreId: Ref<string>;
staffDirectory: Ref<StoreStaffDto[]>;
staffRows: Ref<StoreStaffDto[]>;
staffTotal: Ref<number>;
stores: Ref<StoreListItemDto[]>;
templates: Ref<StoreShiftTemplatesDto>;
}
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<string, StaffDayShiftDto[]> = {};
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<string, StaffDayShiftDto[]>) {
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,
};
}

View File

@@ -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<string, StaffDayShiftDto[]>) {
const next: Record<string, StaffDayShiftDto[]> = {};
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<StaffDayShiftDto>;
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<StaffDayShiftDto>[];
templates: StoreShiftTemplatesDto;
}) {
const shiftMap = new Map<number, Partial<StaffDayShiftDto>>();
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<string, StaffDayShiftDto[]> = {};
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<string, StaffDayShiftDto[]>;
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}`;
}

View File

@@ -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<boolean>;
isPersonalSaving: Ref<boolean>;
isWeekDrawerOpen: Ref<boolean>;
isWeekSaving: Ref<boolean>;
loadScheduleSettings: () => Promise<void>;
patchScheduleMap: (nextMap: Record<string, StaffDayShiftDto[]>) => void;
patchStaffSchedule: (staffId: string, shifts: StaffDayShiftDto[]) => void;
personalForm: StaffPersonalScheduleFormState;
scheduleMap: Ref<Record<string, StaffDayShiftDto[]>>;
selectedStoreId: Ref<string>;
staffDirectory: Ref<StoreStaffDto[]>;
templates: Ref<StoreShiftTemplatesDto>;
weekRows: Ref<WeekEditorRow[]>;
}
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,
};
}

View File

@@ -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<string>;
isStaffSaving: Ref<boolean>;
isStaffDrawerOpen: Ref<boolean>;
reloadStoreData: () => Promise<void>;
selectedStoreId: Ref<string>;
staffDrawerMode: Ref<StaffDrawerMode>;
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,
};
}

View File

@@ -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<boolean>;
loadScheduleSettings: () => Promise<void>;
patchTemplates: (nextTemplates: StoreShiftTemplatesDto) => void;
scheduleSnapshot: Ref<null | StaffScheduleSnapshot>;
selectedStoreId: Ref<string>;
templates: Ref<StoreShiftTemplatesDto>;
}
export function createTemplateActions(options: CreateTemplateActionsOptions) {
/** 更新单个模板时间字段。 */
function setTemplateTime(payload: {
field: 'endTime' | 'startTime';
shiftType: Exclude<ShiftType, 'off'>;
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,
};
}

View File

@@ -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<StoreListItemDto[]>([]);
const selectedStoreId = ref('');
const filters = reactive<StaffFilterState>(
cloneFilterState(DEFAULT_FILTER_STATE),
);
const staffRows = ref<StoreStaffDto[]>([]);
const staffTotal = ref(0);
const staffDirectory = ref<StoreStaffDto[]>([]);
const templates = ref<StoreShiftTemplatesDto>(
cloneTemplates(DEFAULT_SHIFT_TEMPLATES),
);
const scheduleMap = ref<Record<string, StaffDayShiftDto[]>>({});
const scheduleSnapshot = ref<null | {
scheduleMap: Record<string, StaffDayShiftDto[]>;
templates: StoreShiftTemplatesDto;
}>(null);
// 3. 抽屉与弹窗状态。
const isStaffDrawerOpen = ref(false);
const staffDrawerMode = ref<StaffDrawerMode>('create');
const staffForm = reactive<StaffEditorFormState>(
cloneStaffForm(DEFAULT_STAFF_FORM),
);
const isPersonalDrawerOpen = ref(false);
const personalScheduleForm = reactive<StaffPersonalScheduleFormState>({
staffId: '',
staffName: '',
roleType: 'cashier',
shifts: createEmptyWeekShifts(DEFAULT_SHIFT_TEMPLATES),
});
const isWeekDrawerOpen = ref(false);
const weekRows = ref<WeekEditorRow[]>([]);
const isCopyModalOpen = ref(false);
const copyTargetStoreIds = ref<string[]>([]);
// 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<StaffRoleType, (typeof STAFF_ROLE_OPTIONS)[number]>();
for (const option of STAFF_ROLE_OPTIONS) {
map.set(option.value, option);
}
return map;
});
const statusOptionMap = computed(() => {
const map = new Map<StaffStatus, (typeof STAFF_STATUS_OPTIONS)[number]>();
for (const option of STAFF_STATUS_OPTIONS) {
map.set(option.value, option);
}
return map;
});
const shiftOptionMap = computed(() => {
const map = new Map<ShiftType, (typeof SHIFT_OPTIONS)[number]>();
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<ShiftLegendItem[]>(() => {
return SHIFT_OPTIONS.filter((item) => item.value !== 'off').map((item) => {
const template = templates.value[item.value as Exclude<ShiftType, 'off'>];
return {
type: item.value as Exclude<ShiftType, 'off'>,
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,
};
}

View File

@@ -0,0 +1,255 @@
<script setup lang="ts">
/**
* 文件职责:员工排班页面主视图。
* 1. 组装员工列表、班次模板、周排班看板。
* 2. 挂载员工抽屉、个人排班抽屉、周排班抽屉与复制弹窗。
*/
import { Page } from '@vben/common-ui';
import { Card, Empty, Spin } from 'ant-design-vue';
import CopyToStoresModal from '../components/CopyToStoresModal.vue';
import StoreScopeToolbar from '../components/StoreScopeToolbar.vue';
import ShiftTemplateCard from './components/ShiftTemplateCard.vue';
import StaffEditorDrawer from './components/StaffEditorDrawer.vue';
import StaffFilterBar from './components/StaffFilterBar.vue';
import StaffScheduleDrawer from './components/StaffScheduleDrawer.vue';
import StaffTableSection from './components/StaffTableSection.vue';
import WeeklyScheduleBoard from './components/WeeklyScheduleBoard.vue';
import WeekScheduleDrawer from './components/WeekScheduleDrawer.vue';
import { useStoreStaffPage } from './composables/useStoreStaffPage';
const {
DAY_OPTIONS,
PAGE_SIZE_OPTIONS,
SHIFT_OPTIONS,
STAFF_PERMISSION_OPTIONS,
STAFF_ROLE_OPTIONS,
STAFF_STATUS_OPTIONS,
applyFilters,
boardRows,
copyCandidates,
copyTargetStoreIds,
cycleWeekShift,
deletingStaffId,
filters,
formatShiftTimeText,
getRoleLabel,
getRoleTagClass,
getShiftClass,
getShiftLabel,
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,
selectedStoreId,
selectedStoreName,
setCopyModalOpen,
setFilterKeyword,
setFilterRoleType,
setFilterStatus,
setPersonalDrawerOpen,
setPersonalShiftEnd,
setPersonalShiftStart,
setPersonalShiftType,
setSelectedStoreId,
setStaffDrawerOpen,
setStaffEmail,
setStaffName,
setStaffPermissions,
setStaffPhone,
setStaffRoleType,
setStaffStatus,
setTemplateTime,
setWeekDrawerOpen,
shiftLegendItems,
staffDrawerTitle,
staffForm,
staffRows,
staffSubmitText,
staffTotal,
storeOptions,
templates,
toggleCopyStore,
weekRows,
} = useStoreStaffPage();
</script>
<template>
<Page title="员工排班" content-class="space-y-4 page-store-staff">
<StoreScopeToolbar
:selected-store-id="selectedStoreId"
:store-options="storeOptions"
:is-store-loading="isStoreLoading"
:copy-disabled="!selectedStoreId || copyCandidates.length === 0"
copy-button-text="复制员工排班到其他门店"
@update:selected-store-id="setSelectedStoreId"
@copy="openCopyModal"
/>
<template v-if="storeOptions.length === 0">
<Card :bordered="false">
<Empty description="暂无门店,请先创建门店" />
</Card>
</template>
<template v-else>
<Spin :spinning="isPageLoading">
<StaffTableSection
:rows="staffRows"
:total="staffTotal"
:page="filters.page"
:page-size="filters.pageSize"
:page-size-options="PAGE_SIZE_OPTIONS"
:is-loading="isPageLoading"
:deleting-staff-id="deletingStaffId"
:format-role-label="getRoleLabel"
:format-status-label="getStatusLabel"
:get-role-tag-class="getRoleTagClass"
:get-status-dot-class="getStatusDotClass"
:mask-phone="maskPhone"
:is-schedule-disabled="isScheduleDisabled"
@add="openStaffDrawer('create')"
@edit="(staff) => openStaffDrawer('edit', staff)"
@schedule="handleOpenPersonalSchedule"
@delete="handleDeleteStaff"
@page-change="handlePageChange"
>
<template #filters>
<StaffFilterBar
:keyword="filters.keyword"
:role-type="filters.roleType"
:status="filters.status"
:is-loading="isPageLoading"
:role-options="STAFF_ROLE_OPTIONS"
:status-options="STAFF_STATUS_OPTIONS"
:on-set-keyword="setFilterKeyword"
:on-set-role-type="setFilterRoleType"
:on-set-status="setFilterStatus"
@search="applyFilters"
@reset="resetFilters"
/>
</template>
</StaffTableSection>
<ShiftTemplateCard
:templates="templates"
:is-saving="isTemplateSaving"
:on-set-template-time="setTemplateTime"
@save="handleSaveTemplates"
@reset="resetTemplates"
/>
<WeeklyScheduleBoard
:rows="boardRows"
:day-options="DAY_OPTIONS"
:get-shift-class="getShiftClass"
:get-shift-label="getShiftLabel"
:get-shift-time-text="formatShiftTimeText"
:role-label-resolver="getRoleLabel"
@edit-week="openWeekScheduleDrawer"
/>
</Spin>
</template>
<StaffEditorDrawer
:open="isStaffDrawerOpen"
:title="staffDrawerTitle"
:submit-text="staffSubmitText"
:form="staffForm"
:is-saving="isStaffSaving"
:role-options="STAFF_ROLE_OPTIONS"
:status-options="STAFF_STATUS_OPTIONS"
:permission-options="STAFF_PERMISSION_OPTIONS"
:on-set-name="setStaffName"
:on-set-phone="setStaffPhone"
:on-set-email="setStaffEmail"
:on-set-role-type="setStaffRoleType"
:on-set-status="setStaffStatus"
:on-set-permissions="setStaffPermissions"
@update:open="setStaffDrawerOpen"
@submit="handleSubmitStaff"
/>
<StaffScheduleDrawer
:open="isPersonalDrawerOpen"
:title="personalDrawerTitle"
:is-saving="isPersonalSaving"
:form="personalScheduleForm"
:day-options="DAY_OPTIONS"
:shift-options="SHIFT_OPTIONS"
:on-set-shift-type="setPersonalShiftType"
:on-set-shift-start="setPersonalShiftStart"
:on-set-shift-end="setPersonalShiftEnd"
@update:open="setPersonalDrawerOpen"
@submit="handleSavePersonalSchedule"
/>
<WeekScheduleDrawer
:open="isWeekDrawerOpen"
:rows="weekRows"
:is-saving="isWeekSaving"
:day-options="DAY_OPTIONS"
:legend-items="shiftLegendItems"
:get-shift-class="getShiftClass"
:get-shift-label="getShiftLabel"
:get-shift-time-text="formatShiftTimeText"
:role-label-resolver="getRoleLabel"
@update:open="setWeekDrawerOpen"
@cycle-shift="cycleWeekShift"
@submit="handleSaveWeekSchedule"
/>
<CopyToStoresModal
:open="isCopyModalOpen"
title="复制员工排班到其他门店"
confirm-text="确认复制"
warning-text="仅复制班次模板与排班安排不会复制员工档案"
:copy-candidates="copyCandidates"
:target-store-ids="copyTargetStoreIds"
:is-all-checked="isCopyAllChecked"
:is-indeterminate="isCopyIndeterminate"
:is-submitting="isCopySubmitting"
:selected-store-name="selectedStoreName"
@update:open="setCopyModalOpen"
@check-all="handleCopyCheckAll"
@submit="handleCopySubmit"
@toggle-store="
({ storeId, checked }) => toggleCopyStore(storeId, checked)
"
/>
</Page>
</template>
<style lang="less">
@import './styles/index.less';
</style>

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
/* 文件职责:员工排班页面样式聚合入口(仅负责分片导入)。 */
@import './base.less';
@import './list.less';
@import './template.less';
@import './schedule.less';
@import './drawer.less';
@import './responsive.less';

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<ShiftType, 'off'>;
}
export interface StaffScheduleSnapshot {
scheduleMap: Record<string, StaffDayShiftDto[]>;
templates: StoreShiftTemplatesDto;
}