diff --git a/apps/admin/src/pages/Attendance/attendance-workspace.integration.test.ts b/apps/admin/src/pages/Attendance/attendance-workspace.integration.test.ts index eb42879..a8dd74e 100644 --- a/apps/admin/src/pages/Attendance/attendance-workspace.integration.test.ts +++ b/apps/admin/src/pages/Attendance/attendance-workspace.integration.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { canPullAttendance, getAttendanceExperience, + getPunchDisplayInfo, getSchedulePhase, summarizeAttendance, summarizeLessonCheckins, @@ -65,3 +66,34 @@ describe('lesson check-in summary', () => { ).toEqual({ total: 4, checkedIn: 2, notCheckedIn: 2 }); }); }); + + +describe('lesson punch device display', () => { + it('labels attendance machine punches with the machine name and id', () => { + expect( + getPunchDisplayInfo({ + status: 'present', + source: 'dingtalk', + punchSource: 'ATM', + punchDeviceName: '东门考勤机', + punchDeviceId: 'ATM-01', + punchTime: '2026-07-11T00:55:00.000Z', + }), + ).toEqual({ + label: '考勤机打卡', + machine: true, + detail: '东门考勤机(ATM-01)', + time: '2026-07-11T00:55:00.000Z', + }); + }); + + it('distinguishes mobile punches and manual teacher markings', () => { + expect( + getPunchDisplayInfo({ status: 'present', source: 'dingtalk', punchSource: 'USER' }), + ).toEqual({ label: '手机打卡', machine: false, detail: undefined, time: undefined }); + expect(getPunchDisplayInfo({ status: 'present', source: 'manual' })).toEqual({ + label: '老师手动标记', + machine: false, + }); + }); +}); diff --git a/apps/admin/src/pages/Attendance/attendance-workspace.ts b/apps/admin/src/pages/Attendance/attendance-workspace.ts index 42a6059..a78250d 100644 --- a/apps/admin/src/pages/Attendance/attendance-workspace.ts +++ b/apps/admin/src/pages/Attendance/attendance-workspace.ts @@ -82,3 +82,56 @@ export function summarizeLessonCheckins( notCheckedIn: records.length - checkedIn, }; } + + +export interface PunchDisplayRecord { + status: string; + source?: string; + punchTime?: string | null; + punchSource?: string | null; + punchDeviceName?: string | null; + punchDeviceId?: string | null; +} + +export interface PunchDisplayInfo { + label: string; + machine: boolean; + detail?: string; + time?: string; +} + +export function getPunchDisplayInfo(record: PunchDisplayRecord): PunchDisplayInfo | null { + if (record.status !== 'present' && record.status !== 'late') return null; + if (record.source === 'manual') return { label: '老师手动标记', machine: false }; + + const source = (record.punchSource || '').trim().toUpperCase(); + const machine = ['ATM', 'ATTENDANCE_MACHINE', 'MACHINE', 'DEVICE'].some( + (value) => source === value || source.includes(value), + ); + const label = machine + ? '考勤机打卡' + : source === 'USER' + ? '手机打卡' + : source.includes('BEACON') || source.includes('BLE') + ? '蓝牙打卡' + : source.includes('WIFI') + ? 'Wi-Fi 打卡' + : source.includes('APPROVE') + ? '审批补卡' + : source + ? `其他打卡(${record.punchSource})` + : '打卡来源未知'; + const device = record.punchDeviceName?.trim(); + const deviceId = record.punchDeviceId?.trim(); + const detail = device + ? deviceId && deviceId !== device + ? `${device}(${deviceId})` + : device + : deviceId || undefined; + return { + label, + machine, + detail, + time: record.punchTime || undefined, + }; +} diff --git a/apps/admin/src/pages/Attendance/attendance.css b/apps/admin/src/pages/Attendance/attendance.css index 77714dd..c9745e4 100644 --- a/apps/admin/src/pages/Attendance/attendance.css +++ b/apps/admin/src/pages/Attendance/attendance.css @@ -502,3 +502,25 @@ .attendance-marking-actions .ant-btn { min-width: 54px; } + + +.punch-device-cell { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; +} + +.punch-device-cell .ant-tag { + margin-inline-end: 0; +} + +.punch-device-cell strong { + color: #1f2937; + font-size: 13px; +} + +.punch-device-cell span { + color: #8c8c8c; + font-size: 12px; +} diff --git a/apps/admin/src/pages/Attendance/index.tsx b/apps/admin/src/pages/Attendance/index.tsx index 7af49c3..4c3a063 100644 --- a/apps/admin/src/pages/Attendance/index.tsx +++ b/apps/admin/src/pages/Attendance/index.tsx @@ -41,6 +41,7 @@ import { message } from '../../ui/app-message'; import { canPullAttendance, getAttendanceExperience, + getPunchDisplayInfo, getSchedulePhase, summarizeLessonCheckins, type AttendanceSummary, @@ -96,6 +97,10 @@ interface AttendanceRecordItem { class: { id: number; name: string } | null; scheduleId?: number | null; attendanceSessionId?: number | null; + punchTime?: string | null; + punchSource?: string | null; + punchDeviceName?: string | null; + punchDeviceId?: string | null; } interface AssignedClass { @@ -288,6 +293,7 @@ const TeacherAttendanceWorkspace: React.FC = () => { `/attendance-lessons/schedules/${schedule.id}/pull`, { date: today }, ); + setSelectedSchedule(data.schedule); setLessonSession(data.session); setLessonRecords(data.records); message.success('钉钉打卡已更新;课程截止后系统将自动结算'); @@ -386,7 +392,7 @@ const TeacherAttendanceWorkspace: React.FC = () => { )} - setDrawerOpen(false)} width={820} title={null} className="attendance-drawer"> + setDrawerOpen(false)} width={960} title={null} className="attendance-drawer">
LESSON ATTENDANCE

{selectedSchedule?.subject || '课程考勤'}

@@ -438,6 +444,21 @@ const TeacherAttendanceWorkspace: React.FC = () => { }, }, { title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => }, + { + title: '打卡设备', + width: 220, + render: (_: unknown, record: AttendanceRecordItem) => { + const info = getPunchDisplayInfo(record); + if (!info) return ; + return ( +
+ {info.label} + {info.detail && {info.detail}} + {info.time && {dayjs(info.time).format('HH:mm:ss')}} +
+ ); + }, + }, { title: '备注', dataIndex: 'remark', render: (value: string | null) => value || }, ]} /> diff --git a/apps/admin/src/pages/Bills/index.tsx b/apps/admin/src/pages/Bills/index.tsx index c4e1030..8494f17 100644 --- a/apps/admin/src/pages/Bills/index.tsx +++ b/apps/admin/src/pages/Bills/index.tsx @@ -89,9 +89,11 @@ const BillsPage: React.FC = () => { }, [bills, searchText, filterStatus]); const handleGenerate = async () => { - setSaving(true); - const values = await generateForm.validateFields(); + if (saving) return; + try { + const values = await generateForm.validateFields(); + setSaving(true); const res: any = await api.post('/bills/generate', { periodStart: values.period[0].format('YYYY-MM-DD'), periodEnd: values.period[1].format('YYYY-MM-DD'), @@ -99,9 +101,12 @@ const BillsPage: React.FC = () => { message.success(res.message || '生成成功'); setGenerateModal(false); generateForm.resetFields(); - fetchData(); + void fetchData(); } catch (e: any) { - message.error(e?.message || '生成失败'); + // Ant Design 的表单校验失败会 reject;字段本身已展示错误,无需再弹“生成失败”。 + if (!e?.errorFields) { + message.error(e?.message || '生成失败'); + } } finally { setSaving(false); } diff --git a/apps/admin/src/pages/Occupancies/index.tsx b/apps/admin/src/pages/Occupancies/index.tsx index b59066a..1ce8cdf 100644 --- a/apps/admin/src/pages/Occupancies/index.tsx +++ b/apps/admin/src/pages/Occupancies/index.tsx @@ -598,7 +598,7 @@ const OccupanciesPage: React.FC = () => { placeholder="默认为短租" /> - +