feat: improve attendance scheduling and API validation

This commit is contained in:
2026-07-14 23:12:14 +08:00
parent c75a08affe
commit e45da7f998
33 changed files with 869 additions and 297 deletions

View File

@@ -105,6 +105,20 @@ interface AttachmentRecord {
fileSize: number;
}
interface AttendanceRecordItem {
id: number;
attendanceDate: string;
session: string;
status: string;
source?: string;
remark?: string | null;
punchTime?: string | null;
punchDeviceName?: string | null;
punchDeviceId?: string | null;
schedule?: { subject?: string } | null;
class?: { name?: string } | null;
}
interface StudentProfileAggregate {
student: StudentInfo;
profile: ProfileData | null;
@@ -113,6 +127,7 @@ interface StudentProfileAggregate {
learningRecords: LearningRecord[];
result: ResultData | null;
attachments: AttachmentRecord[];
attendances: AttendanceRecordItem[];
}
export interface StudentProfileContentProps {
@@ -178,6 +193,58 @@ const formatFileSize = (bytes: number): string => {
// ---- Tab Components ----
const ATTENDANCE_STATUS_MAP: Record<string, { text: string; color: string }> = {
present: { text: '出勤', color: 'green' },
late: { text: '迟到', color: 'orange' },
absent: { text: '缺勤', color: 'red' },
leave: { text: '请假', color: 'blue' },
pending: { text: '待确认', color: 'default' },
};
const SESSION_LABELS: Record<string, string> = {
morning_reading: '早自习',
morning: '上午',
afternoon: '下午',
evening_study: '晚自习',
night_check: '晚寝',
};
const AttendanceTab: React.FC<{ data: AttendanceRecordItem[] }> = ({ data }) => {
const columns: ColumnsType<AttendanceRecordItem> = [
{ title: '日期', dataIndex: 'attendanceDate', width: 120 },
{ title: '课程', render: (_: unknown, record) => record.schedule?.subject || record.class?.name || '课程考勤' },
{ title: '时段', dataIndex: 'session', width: 100, render: (value: string) => SESSION_LABELS[value] || value || '-' },
{
title: '结果', dataIndex: 'status', width: 90,
render: (value: string) => {
const meta = ATTENDANCE_STATUS_MAP[value] || { text: value || '-', color: 'default' };
return <Tag color={meta.color}>{meta.text}</Tag>;
},
},
{ title: '打卡时间', dataIndex: 'punchTime', width: 170, render: (value?: string | null) => value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-' },
{
title: '打卡设备',
render: (_: unknown, record) => {
const name = record.punchDeviceName?.trim();
const id = record.punchDeviceId?.trim();
if (name && id && name !== id) return `${name}${id}`;
return name || id || (record.source === 'manual' ? '老师手动标记' : '-');
},
},
{ title: '备注', dataIndex: 'remark', render: (value?: string | null) => value || '-' },
];
return data.length > 0 ? (
<Table<AttendanceRecordItem>
columns={columns}
dataSource={data}
rowKey="id"
scroll={{ x: 900 }}
pagination={{ defaultPageSize: 15, showSizeChanger: true, pageSizeOptions: [15, 30, 50] }}
/>
) : <Empty description="暂无出勤记录" />;
};
interface TabProps {
studentId: number;
onRefresh: () => void;
@@ -779,7 +846,7 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
const tabItems = useMemo(() => {
if (!aggregateData) return [];
const { profile, enrollments, examScores, learningRecords, result, attachments } = aggregateData;
const { profile, enrollments, examScores, learningRecords, result, attachments, attendances } = aggregateData;
return [
{
key: 'profile',
@@ -807,8 +874,8 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
},
{
key: 'attendance',
label: '出勤记录',
children: <Empty description="暂无出勤记录" />,
label: `出勤记录 (${attendances.length})`,
children: <AttendanceTab data={attendances} />,
},
{
key: 'learning',

View File

@@ -39,6 +39,7 @@ interface ClassScheduleItem {
weekDay: number;
startTime: string;
endTime: string;
attendanceAdvanceMinutes: number;
startDate: string;
endDate: string;
subject: string;
@@ -353,6 +354,7 @@ const ClassDetailPage: React.FC = () => {
{ title: '教室', dataIndex: 'classroomName', render: (v: string | null) => v || '-' },
{ title: '星期', dataIndex: 'weekDay', render: (v: number) => WEEK_DAY_MAP[v] || v },
{ title: '时间', render: (_: unknown, r: ClassScheduleItem) => `${r.startTime} - ${r.endTime}` },
{ title: '签到窗口', render: (_: unknown, r: ClassScheduleItem) => `课前 ${r.attendanceAdvanceMinutes ?? 30} 分钟至下课` },
{ title: '日期范围', render: (_: unknown, r: ClassScheduleItem) => `${r.startDate} ~ ${r.endDate}` },
{ title: '科目', dataIndex: 'subject' },
{ title: '类型', dataIndex: 'scheduleType', render: (v: string) => SCHEDULE_TYPE_MAP[v] || v },

View File

@@ -6,6 +6,7 @@ import {
Modal,
Form,
Input,
InputNumber,
DatePicker,
TimePicker,
Popconfirm,
@@ -53,6 +54,7 @@ interface ClassScheduleItem {
weekDay: number;
startTime: string;
endTime: string;
attendanceAdvanceMinutes: number;
startDate: string;
endDate: string;
subject: string;
@@ -351,7 +353,7 @@ const SchedulesPage: React.FC = () => {
setEditingSchedule(null);
setModalMode('create');
form.resetFields();
form.setFieldsValue({ classroomId, weekDay });
form.setFieldsValue({ classroomId, weekDay, attendanceAdvanceMinutes: 30 });
setModalOpen(true);
}
};
@@ -960,9 +962,28 @@ const SchedulesPage: React.FC = () => {
/>
</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
@@ -1008,6 +1029,7 @@ const SchedulesPage: React.FC = () => {
weekDay:
selectedCell?.weekDay ?? (selectedDate ? selectedDate.day() || 7 : undefined),
dateRange: selectedDate ? [selectedDate, selectedDate] : undefined,
attendanceAdvanceMinutes: 30,
});
}}
>
@@ -1054,6 +1076,12 @@ const SchedulesPage: React.FC = () => {
<strong></strong>
{s.startTime} ~ {s.endTime}
</div>
{!isMaskedSchedule(s) && (
<div>
<strong></strong>
{s.attendanceAdvanceMinutes ?? 30}
</div>
)}
<div>
<strong></strong>
{s.startDate} ~ {s.endDate}

View File

@@ -16,11 +16,13 @@ describe('schedule edit form mapping', () => {
startDate: '2026-07-01',
endDate: '2026-07-31',
notes: '需要投影设备',
attendanceAdvanceMinutes: 45,
});
expect(values.classroomId).toBe(1);
expect(values.weekDay).toBe(5);
expect(values.notes).toBe('需要投影设备');
expect(values.attendanceAdvanceMinutes).toBe(45);
expect(values.timeRange.map((item) => item.format('HH:mm'))).toEqual(['14:00', '18:00']);
expect(values.dateRange.map((item) => item.format('YYYY-MM-DD'))).toEqual([
'2026-07-01',
@@ -39,6 +41,7 @@ describe('schedule edit form mapping', () => {
timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')],
dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')],
notes: ' 临时调整教室 ',
attendanceAdvanceMinutes: 20,
}),
).toEqual({
classId: 1,
@@ -51,6 +54,7 @@ describe('schedule edit form mapping', () => {
startDate: '2026-08-01',
endDate: '2026-08-31',
notes: '临时调整教室',
attendanceAdvanceMinutes: 20,
});
});
});
@@ -67,6 +71,7 @@ describe('schedule notes normalization', () => {
timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')],
dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')],
notes: ' ',
attendanceAdvanceMinutes: 30,
}).notes,
).toBeUndefined();
});

View File

@@ -7,6 +7,7 @@ export interface ScheduleFormValues {
subject: string;
teacherId?: number;
notes?: string;
attendanceAdvanceMinutes: number;
timeRange: [Dayjs, Dayjs];
dateRange: [Dayjs, Dayjs];
}
@@ -19,6 +20,7 @@ export interface EditableSchedule {
subject: string;
teacherId: number | null;
notes?: string | null;
attendanceAdvanceMinutes?: number | null;
startTime: string;
endTime: string;
startDate: string;
@@ -32,6 +34,7 @@ export const scheduleToFormValues = (schedule: EditableSchedule): ScheduleFormVa
subject: schedule.subject,
teacherId: schedule.teacherId ?? undefined,
notes: schedule.notes ?? undefined,
attendanceAdvanceMinutes: schedule.attendanceAdvanceMinutes ?? 30,
timeRange: [dayjs(`2000-01-01 ${schedule.startTime}`), dayjs(`2000-01-01 ${schedule.endTime}`)],
dateRange: [dayjs(schedule.startDate), dayjs(schedule.endDate)],
});
@@ -43,6 +46,7 @@ export const buildSchedulePayload = (values: ScheduleFormValues) => ({
subject: values.subject,
teacherId: values.teacherId,
notes: values.notes?.trim() || undefined,
attendanceAdvanceMinutes: values.attendanceAdvanceMinutes,
startTime: values.timeRange[0].format('HH:mm'),
endTime: values.timeRange[1].format('HH:mm'),
startDate: values.dateRange[0].format('YYYY-MM-DD'),