feat: 重构各业务模块管理页面与服务

This commit is contained in:
2026-08-05 17:12:00 +08:00
parent 80e6fccf05
commit fd39e1686a
163 changed files with 18409 additions and 13449 deletions

View File

@@ -0,0 +1,330 @@
// aislop-ignore-file: duplicate-block -- 周/月视图表格结构相似且展示维度不同,已共享 ScheduleGrid 组件
import React from 'react';
import { Badge, Empty, Spin, Tooltip } from 'antd';
import type { Dayjs } from 'dayjs';
import { isMaskedSchedule } from './schedule-visibility';
export const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
export const WEEKDAY_NUMBERS = [1, 2, 3, 4, 5, 6, 7];
export interface ClassScheduleItem {
id: number | null;
classId: number | null;
classroomId: number;
weekDay: number;
startTime: string;
endTime: string;
attendanceAdvanceMinutes: number;
startDate: string;
endDate: string;
subject: string;
teacherId: number | null;
scheduleType: string;
status: string;
notes: string | null;
createdAt: string;
updatedAt: string;
canViewDetails?: boolean;
}
export interface ClassroomItem {
id: number;
name: string;
building: string;
floor: number;
roomType: string;
}
export interface ClassItem {
id: number;
name: string;
code: string;
}
export interface ClassTeacherOption {
id: number;
userId: number;
username?: string;
name?: string;
roleType: string;
subject?: string | null;
}
export const ScheduleGrid: React.FC<{
loading: boolean;
viewMode: 'week' | 'month';
classrooms: ClassroomItem[];
filteredClassrooms: ClassroomItem[];
displayMatrix: Record<number, Record<number, ClassScheduleItem[]>>;
weeks: Dayjs[][];
monthStart: Dayjs;
monthScheduleMap: Record<string, ClassScheduleItem[]>;
onCellClick: (classroomId: number, weekDay: number) => void;
onDateClick: (date: Dayjs) => void;
}> = ({
loading,
viewMode,
classrooms,
filteredClassrooms,
displayMatrix,
weeks,
monthStart,
monthScheduleMap,
onCellClick,
onDateClick,
}) => {
return (
<Spin spinning={loading}>
{classrooms.length === 0 ? (
<Empty description="暂无教室数据" />
) : viewMode === 'week' ? (
<div style={{ overflowX: 'auto' }}>
<table
style={{
width: '100%',
borderCollapse: 'collapse',
fontSize: 13,
tableLayout: 'fixed',
}}
>
<thead>
<tr style={{ background: '#fafafa' }}>
<th
style={{
position: 'sticky',
left: 0,
background: '#fafafa',
zIndex: 2,
padding: '10px 12px',
border: '1px solid #f0f0f0',
width: 150,
textAlign: 'left',
}}
>
</th>
{WEEKDAYS.map((day) => (
<th
key={day}
style={{
padding: '10px 8px',
border: '1px solid #f0f0f0',
textAlign: 'center',
background: '#fafafa',
fontWeight: 500,
}}
>
{day}
</th>
))}
</tr>
</thead>
<tbody>
{filteredClassrooms.map((classroom) => (
<tr key={classroom.id}>
<td
style={{
position: 'sticky',
left: 0,
background: '#fff',
zIndex: 1,
padding: '8px 12px',
border: '1px solid #f0f0f0',
fontWeight: 500,
}}
>
<div>{classroom.name}</div>
{classroom.building && (
<div style={{ fontSize: 11, color: '#8c8c8c', marginTop: 2 }}>
{classroom.building}
{classroom.floor ? ` ${classroom.floor}F` : ''}
</div>
)}
</td>
{WEEKDAY_NUMBERS.map((wd) => {
const schedules = displayMatrix[classroom.id]?.[wd] || [];
const hasContent = schedules.length > 0;
return (
<td
key={wd}
tabIndex={0}
role="button"
aria-label={`选择教室 ${classroom.name} ${WEEKDAYS[wd - 1]} 排课`}
onClick={() => onCellClick(classroom.id, wd)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onCellClick(classroom.id, wd);
}
}}
style={{
padding: 4,
border: '1px solid #f0f0f0',
verticalAlign: 'top',
cursor: 'pointer',
minHeight: 56,
transition: 'background 0.15s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = '#f6f8fa';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = '';
}}
>
{hasContent ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{schedules.map((s) => (
<Tooltip
key={`${s.id ?? 'busy'}-${s.classroomId}-${s.weekDay}-${s.startTime}-${s.endTime}`}
title={
isMaskedSchedule(s)
? `已占用 · ${s.startTime}-${s.endTime}`
: `${s.subject} · ${s.startTime}-${s.endTime} · ${s.startDate}~${s.endDate}`
}
>
<div
style={{
background: isMaskedSchedule(s) ? '#f5f5f5' : '#e6f4ff',
border: isMaskedSchedule(s)
? '1px solid #d9d9d9'
: '1px solid #91caff',
borderRadius: 4,
padding: '2px 6px',
fontSize: 12,
lineHeight: '18px',
}}
>
<div
style={{
fontWeight: 600,
color: isMaskedSchedule(s) ? '#595959' : '#1677ff',
}}
>
{s.subject}
</div>
<div style={{ color: '#595959' }}>
{s.startTime}-{s.endTime}
</div>
</div>
</Tooltip>
))}
</div>
) : (
<div
style={{
color: '#d9d9d9',
fontSize: 20,
textAlign: 'center',
lineHeight: '44px',
}}
>
</div>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table
style={{
width: '100%',
borderCollapse: 'collapse',
fontSize: 13,
tableLayout: 'fixed',
}}
>
<thead>
<tr style={{ background: '#fafafa' }}>
{WEEKDAYS.map((d) => (
<th
key={d}
style={{
padding: '10px 8px',
border: '1px solid #f0f0f0',
textAlign: 'center',
fontWeight: 500,
}}
>
{d}
</th>
))}
</tr>
</thead>
<tbody>
{weeks.map((week, wi) => (
<tr key={wi}>
{week.map((day, di) => {
const isCurrentMonth = day.month() === monthStart.month();
const dateKey = day.format('YYYY-MM-DD');
const daySchedules = monthScheduleMap[dateKey] || [];
const count = daySchedules.length;
return (
<td
key={di}
tabIndex={0}
role="button"
aria-label={`${day.format('YYYY-MM-DD')} 排课详情`}
onClick={() => onDateClick(day)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onDateClick(day);
}
}}
style={{
padding: '6px 8px',
border: '1px solid #f0f0f0',
verticalAlign: 'top',
cursor: 'pointer',
height: 90,
background: isCurrentMonth ? '#fff' : '#fafafa',
transition: 'background 0.15s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = isCurrentMonth
? '#f0f5ff'
: '#f0f0f0';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = isCurrentMonth
? ''
: '#fafafa';
}}
>
<div
style={{
fontWeight: isCurrentMonth ? 600 : 400,
color: isCurrentMonth ? '#262626' : '#bfbfbf',
fontSize: 14,
marginBottom: 4,
}}
>
{day.date()}
</div>
{count > 0 && (
<Badge
count={count}
size="small"
overflowCount={99}
style={{ backgroundColor: '#1677ff' }}
/>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</Spin>
);
};

View File

@@ -0,0 +1,561 @@
import React from 'react';
import {
Alert,
Button,
Card,
Col,
DatePicker,
Empty,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Row,
Select,
Space,
Spin,
Statistic,
Switch,
Tag,
TimePicker,
} from 'antd';
import { CloudSyncOutlined, EditOutlined, PlusOutlined, StopOutlined } from '@ant-design/icons';
import type { Dayjs } from 'dayjs';
import PermissionButton from '../../components/PermissionButton';
import { isMaskedSchedule } from './schedule-visibility';
import type { ScheduleFormValues } from './schedule-form';
import type { ClassItem, ClassScheduleItem, ClassTeacherOption, ClassroomItem } from './ScheduleGrids';
import { WEEKDAYS } from './ScheduleGrids';
export interface ScheduleModalProps {
open: boolean;
mode: 'create' | 'edit' | 'detail';
submitting: boolean;
form: ReturnType<typeof Form.useForm<ScheduleFormValues>>[0];
selectedCell: { classroomId: number; weekDay: number } | null;
selectedDate: Dayjs | null;
selectedSchedules: ClassScheduleItem[];
editingSchedule: ClassScheduleItem | null;
selectedClassroom?: ClassroomItem;
classOptions: Array<{ value: number; label: string }>;
classroomOptions: Array<{ value: number; label: string }>;
classTeachers: ClassTeacherOption[];
classes: ClassItem[];
onCancel: () => void;
onSubmit: () => void;
onStartCreate: () => void;
onEdit: (schedule: ClassScheduleItem) => void;
onDisable: (id: number | null) => void;
onClassChange: (classId: number) => void;
onSubjectBlur: (value: string) => void;
}
export const ScheduleModal: React.FC<ScheduleModalProps> = ({
open,
mode,
submitting,
form,
selectedCell,
selectedDate,
selectedSchedules,
editingSchedule,
selectedClassroom,
classOptions,
classroomOptions,
classTeachers,
classes,
onCancel,
onSubmit,
onStartCreate,
onEdit,
onDisable,
onClassChange,
onSubjectBlur,
}) => {
const title =
mode === 'create'
? `新增排课 — ${selectedClassroom?.name || ''} · ${
selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : ''
}`
: mode === 'edit'
? `编辑排课 — ${editingSchedule?.subject || ''}`
: selectedDate
? `排课详情 — ${selectedDate.format('YYYY-MM-DD')} ${
WEEKDAYS[selectedDate.day() === 0 ? 6 : selectedDate.day() - 1]
}`
: `排课详情 — ${selectedClassroom?.name || ''} · ${
selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : ''
}`;
return (
<Modal
title={title}
open={open}
onCancel={onCancel}
onOk={mode !== 'detail' ? onSubmit : undefined}
confirmLoading={submitting}
okText={mode === 'edit' ? '保存' : mode === 'create' ? '创建' : undefined}
footer={mode === 'detail' ? null : undefined}
width={600}
destroyOnHidden
>
{mode !== 'detail' ? (
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="classId" label="班级" rules={[{ required: true, message: '请选择班级' }]}>
<Select
placeholder="选择班级"
showSearch
optionFilterProp="label"
options={classOptions}
onChange={onClassChange}
/>
</Form.Item>
<Form.Item
name="classroomId"
label="教室"
rules={[{ required: true, message: '请选择教室' }]}
>
<Select
placeholder="选择教室"
showSearch
optionFilterProp="label"
options={classroomOptions}
/>
</Form.Item>
<Form.Item
name="weekDay"
label="星期"
rules={[{ required: true, message: '请选择星期' }]}
>
<Select
options={WEEKDAYS.map((label, index) => ({ value: index + 1, label }))}
/>
</Form.Item>
<Form.Item
name="subject"
label="科目"
rules={[{ required: true, message: '请输入科目' }]}
>
<Input
placeholder="如:数学、语文"
onBlur={(event) => onSubjectBlur(event.target.value)}
/>
</Form.Item>
<Form.Item name="teacherId" label="任课老师">
<Select
showSearch
allowClear
placeholder="先选班级;系统会按科目自动带出任课老师"
optionFilterProp="label"
options={classTeachers.map((teacher) => ({
value: teacher.userId,
label: `${teacher.name || teacher.username || `#${teacher.userId}`}${
teacher.subject ? ` · ${teacher.subject}` : ''
}`,
}))}
notFoundContent="该班级暂无可选教师,请先在班级详情配置教师"
/>
</Form.Item>
<Form.Item name="notes" label="备注" rules={[{ max: 500, message: '备注不能超过500字' }]}>
<Input.TextArea
rows={3}
maxLength={500}
showCount
placeholder="可填写排课说明、设备需求或临时调整原因"
/>
</Form.Item>
<Form.Item
name="attendanceAdvanceMinutes"
label="课前签到时间"
tooltip="从上课前指定分钟开始,到下课时间结束;期间任意上班或下班打卡都计为出勤"
initialValue={30}
rules={[{ required: true, message: '请设置课前签到时间' }]}
>
<InputNumber
min={0}
max={1440}
step={5}
addonAfter="分钟"
style={{ width: '100%' }}
placeholder="例如 30"
/>
</Form.Item>
<Form.Item
name="timeRange"
label="上课时段"
tooltip="同一教室的前后两节排课必须至少间隔10分钟"
extra="系统按10分钟选择时间并为相邻排课强制预留至少10分钟。"
rules={[{ required: true, message: '请选择时段' }]}
>
<TimePicker.RangePicker
format="HH:mm"
minuteStep={10}
style={{ width: '100%' }}
placeholder={['开始时间', '结束时间']}
/>
</Form.Item>
<Form.Item
name="dateRange"
label="日期范围"
rules={[{ required: true, message: '请选择日期范围' }]}
>
<DatePicker.RangePicker
style={{ width: '100%' }}
placeholder={['开始日期', '结束日期']}
/>
</Form.Item>
</Form>
) : (
<div style={{ lineHeight: 2 }}>
<div
style={{
marginBottom: 12,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span style={{ fontWeight: 500 }}></span>
<PermissionButton
permission="schedule:create"
type="primary"
icon={<PlusOutlined />}
onClick={onStartCreate}
>
</PermissionButton>
</div>
{selectedSchedules.length === 0 ? (
<Empty description="该时段暂无排课" />
) : (
selectedSchedules.map((s) => (
<Card
key={`${s.id ?? 'busy'}-${s.classroomId}-${s.weekDay}-${s.startTime}-${s.endTime}`}
size="small"
style={{ marginBottom: 8 }}
styles={{ body: { padding: 12 } }}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
}}
>
<div>
<div>
<strong>{isMaskedSchedule(s) ? '状态:' : '科目:'}</strong>
<Tag color={isMaskedSchedule(s) ? 'default' : 'blue'}>{s.subject}</Tag>
</div>
{!isMaskedSchedule(s) && (
<div>
<strong></strong>
{classes.find((c) => c.id === s.classId)?.name || `#${s.classId}`}
</div>
)}
{!isMaskedSchedule(s) && s.teacherId != null && (
<div>
<strong></strong>
{classTeachers.find((u) => u.userId === s.teacherId)?.name ||
classTeachers.find((u) => u.userId === s.teacherId)?.username ||
`#${s.teacherId}`}
</div>
)}
<div>
<strong></strong>
{s.startTime} ~ {s.endTime}
</div>
{!isMaskedSchedule(s) && (
<div>
<strong></strong>
{s.attendanceAdvanceMinutes ?? 30}
</div>
)}
<div>
<strong></strong>
{s.startDate} ~ {s.endDate}
</div>
{!isMaskedSchedule(s) && s.notes && (
<div>
<strong></strong>
{s.notes}
</div>
)}
{!isMaskedSchedule(s) && (
<div>
<Tag color={s.scheduleType === 'RENTAL' ? 'orange' : 'green'}>
{s.scheduleType === 'RENTAL' ? '租赁' : '内部'}
</Tag>
<Tag color={s.status === 'active' ? 'green' : 'default'}>{s.status}</Tag>
</div>
)}
</div>
{!isMaskedSchedule(s) && (
<Space>
{s.scheduleType !== 'RENTAL' && (
<PermissionButton
permission="schedule:edit"
size="small"
icon={<EditOutlined />}
onClick={() => onEdit(s)}
>
</PermissionButton>
)}
{s.scheduleType !== 'RENTAL' && s.status === 'active' && (
<Popconfirm
title="确认停用该排课?"
description="停用后历史考勤记录会保留,但该排课不会再显示或占用教室。"
onConfirm={() => onDisable(s.id)}
okText="停用"
cancelText="取消"
>
<PermissionButton
permission="schedule:edit"
size="small"
icon={<StopOutlined />}
>
</PermissionButton>
</Popconfirm>
)}
</Space>
)}
</div>
</Card>
))
)}
</div>
)}
</Modal>
);
};
export interface SyncModalProps {
open: boolean;
syncing: boolean;
syncStatus: {
activeSchedules: number;
mappedClasses: number;
totalClasses: number;
} | null;
syncResult: {
scheduleCount: number;
shiftCount: number;
groupCount: number;
syncedItems: number;
skippedNoMapping: number;
failedBatchCount: number;
failedItems: number;
errors: string[];
groups: Array<{ className: string; groupId: number; itemCount: number }>;
} | null;
syncDateFrom: Dayjs;
syncDays: number;
attendanceMachineOnly: boolean;
onClose: () => void;
onSync: () => void;
onDateChange: (date: Dayjs) => void;
onDaysChange: (days: number) => void;
onMachineOnlyChange: (value: boolean) => void;
}
export const SyncModal: React.FC<SyncModalProps> = ({
open,
syncing,
syncStatus,
syncResult,
syncDateFrom,
syncDays,
attendanceMachineOnly,
onClose,
onSync,
onDateChange,
onDaysChange,
onMachineOnlyChange,
}) => {
return (
<Modal
title="同步排课到钉钉考勤排班"
open={open}
onCancel={onClose}
footer={
syncResult
? [
<Button key="close" onClick={onClose}>
</Button>,
]
: [
<Button key="cancel" onClick={onClose}>
</Button>,
<Button
key="sync"
type="primary"
icon={<CloudSyncOutlined />}
loading={syncing}
onClick={onSync}
disabled={!syncStatus || syncStatus.activeSchedules === 0}
>
</Button>,
]
}
width={560}
>
{syncResult ? (
<div>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6}>
<Statistic title="排课" value={syncResult.scheduleCount} suffix="条" />
</Col>
<Col xs={12} sm={6}>
<Statistic title="班次" value={syncResult.shiftCount} suffix="个" />
</Col>
<Col xs={12} sm={6}>
<Statistic title="考勤组" value={syncResult.groupCount} suffix="个" />
</Col>
<Col xs={12} sm={6}>
<Statistic
title="排班数"
value={syncResult.syncedItems}
suffix="条"
valueStyle={{ color: '#3f8600' }}
/>
</Col>
</Row>
{syncResult.skippedNoMapping > 0 && (
<Alert
type="warning"
title={`${syncResult.skippedNoMapping} 条排课因班级无钉钉绑定学生而跳过`}
style={{ marginBottom: 16 }}
showIcon
/>
)}
{syncResult.failedBatchCount > 0 && (
<>
<Alert
type="error"
title={`${syncResult.failedBatchCount} 批写入失败,共 ${syncResult.failedItems}`}
description={
syncResult.errors.length > 0
? syncResult.errors.slice(0, 5).map((err, i) => (
<div key={i} style={{ wordBreak: 'break-all' }}>
{err}
</div>
))
: undefined
}
style={{ marginBottom: 16 }}
showIcon
/>
{syncResult.errors.length > 5 && (
<div style={{ fontSize: 12, color: '#999', marginBottom: 16, marginTop: -12 }}>
... {syncResult.errors.length - 5}
</div>
)}
</>
)}
{syncResult.groups.length > 0 && (
<div>
<div style={{ fontWeight: 500, marginBottom: 8 }}></div>
{syncResult.groups.map((g) => (
<Tag key={g.groupId} color="blue" style={{ marginBottom: 4 }}>
{g.className}{g.itemCount}
</Tag>
))}
</div>
)}
</div>
) : syncStatus ? (
<div>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={8}>
<Statistic title="活跃排课" value={syncStatus.activeSchedules} suffix="条" />
</Col>
<Col xs={24} sm={8}>
<Statistic
title="已就绪班级"
value={syncStatus.mappedClasses}
suffix={`/ ${syncStatus.totalClasses}`}
valueStyle={{
color:
syncStatus.mappedClasses < syncStatus.totalClasses ? '#faad14' : '#3f8600',
}}
/>
</Col>
<Col xs={24} sm={8}>
<Statistic
title="无绑定学生班级"
value={syncStatus.totalClasses - syncStatus.mappedClasses}
suffix="个"
/>
</Col>
</Row>
{syncStatus.mappedClasses < syncStatus.totalClasses && (
<Alert
type="warning"
title={`${syncStatus.totalClasses - syncStatus.mappedClasses} 个班级没有已绑定钉钉的学生,其排课将被跳过。请先在钉钉集成页导入并绑定学生。`}
style={{ marginBottom: 16 }}
showIcon
/>
)}
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8, fontWeight: 500 }}></div>
<Space wrap>
<span></span>
<DatePicker
value={syncDateFrom}
onChange={(d) => d && onDateChange(d)}
allowClear={false}
/>
<span></span>
<Select
value={syncDays}
onChange={onDaysChange}
style={{ width: 100 }}
options={[
{ value: 7, label: '7 天' },
{ value: 14, label: '14 天' },
{ value: 30, label: '30 天' },
{ value: 60, label: '60 天' },
{ value: 90, label: '90 天' },
]}
/>
</Space>
<div style={{ marginTop: 16 }}>
<Space align="start">
<Switch checked={attendanceMachineOnly} onChange={onMachineOnlyChange} />
<div>
<div style={{ fontWeight: 500 }}></div>
<div style={{ color: '#8c8c8c', fontSize: 12, marginTop: 2 }}>
Wi-Fi
</div>
</div>
</Space>
</div>
{attendanceMachineOnly && (
<Alert
type="info"
showIcon
message="已存在的同名考勤组也会在本次同步中更新为仅考勤机打卡。"
style={{ marginTop: 12 }}
/>
)}
</div>
{syncStatus.activeSchedules === 0 && (
<Alert
type="info"
message="当前没有活跃排课。请先在排课页面创建排课记录。"
showIcon
/>
)}
</div>
) : (
<Spin description="查询同步状态..." />
)}
</Modal>
);
};

File diff suppressed because it is too large Load Diff