feat(web): migrate teacher attendance workflow
Add the React attendance console with manual, QR, and location check-in flows, record editing, Excel exchange, submission, risk signals, and course statistics. Preserve server-side task scope and archived-task restrictions.
This commit is contained in:
1 parent
b19c40f09a
commit
1b27029b5a
8 files changed
+458
No files matched your search
@@ -37,6 +37,7 @@ const StudentExperimentGradesPage = lazy(() => import('../pages/StudentExperimen
|
||||
const CampusRegistrationsPage = lazy(() => import('../pages/CampusRegistrationsPage'))
|
||||
const StudentApprovalsPage = lazy(() => import('../pages/StudentApprovalsPage'))
|
||||
const TeacherRosterPage = lazy(() => import('../pages/TeacherRosterPage'))
|
||||
const TeacherAttendancePage = lazy(() => import('../pages/TeacherAttendancePage'))
|
||||
const AccountActivationPage = lazy(() => import('../pages/AccountActivationPage'))
|
||||
const OfficialDocumentVerifyPage = lazy(() => import('../pages/OfficialDocumentVerifyPage'))
|
||||
const ClassroomRepairReportPage = lazy(() => import('../pages/ClassroomRepairReportPage'))
|
||||
@@ -116,6 +117,7 @@ function AppRoutes() {
|
||||
<Route path="/campus-registrations" element={<CampusRegistrationsPage />} />
|
||||
<Route path="/approvals" element={<StudentApprovalsPage />} />
|
||||
<Route path="/teacher-roster" element={<TeacherRosterPage />} />
|
||||
<Route path="/teacher-attendance" element={<TeacherAttendancePage />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
BankOutlined,
|
||||
ApartmentOutlined,
|
||||
TeamOutlined,
|
||||
CarryOutOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Avatar, Badge, Button, Layout, Menu, Tag } from 'antd'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
@@ -102,6 +103,9 @@ export default function AppShell() {
|
||||
...(roles.includes('Teacher') ? [
|
||||
{ key: '/teacher-roster', icon: <TeamOutlined />, label: '选课名单' },
|
||||
] : []),
|
||||
...(roles.some((role) => ['Teacher', 'SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(role)) ? [
|
||||
{ key: '/teacher-attendance', icon: <CarryOutOutlined />, label: '教学点名' },
|
||||
] : []),
|
||||
...(user?.roles.includes('SuperAdmin')
|
||||
? [{ key: '/users', icon: <SafetyCertificateOutlined />, label: '用户与权限' }]
|
||||
: []),
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { Geolocation } from '@capacitor/geolocation'
|
||||
import { CheckOutlined, ClockCircleOutlined, DeleteOutlined, DownloadOutlined, EnvironmentOutlined, QrcodeOutlined, ReloadOutlined, SearchOutlined, UploadOutlined, WarningOutlined } from '@ant-design/icons'
|
||||
import { Alert, App, Button, DatePicker, Empty, Form, Input, InputNumber, Modal, Pagination, Radio, Select, Skeleton, Table, Tag } from 'antd'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import QRCode from 'qrcode'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { downloadApiFile, importExcel } from '../api/files'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import PageHeader from '../components/PageHeader'
|
||||
import './teacher-attendance.css'
|
||||
|
||||
type Mode = 'rollcall' | 'statistics'
|
||||
type CheckInMethod = 'Manual' | 'QrCode' | 'Location'
|
||||
type AttendanceStatus = 'Present' | 'Absent' | 'Late' | 'Leave' | 'Excused'
|
||||
interface Term { id: string; name: string; isCurrent: boolean; isArchived: boolean; isEnabled: boolean }
|
||||
interface Paged<T> { items: T[]; total: number; page: number; pageSize: number }
|
||||
interface Task { id: string; status: string; taskNumber: string; name: string; academicTermId: string; termName: string; courseCode: string; courseName: string; studentCount: number; sheetCount: number }
|
||||
interface Sheet { id: string; name: string; attendanceDate: string; status: string | number; checkInMethod: string | number; checkInStartsAt?: string; checkInEndsAt?: string; checkedInCount: number; presentCount: number; absentCount: number; lateCount: number; leaveCount: number; excusedCount: number; totalCount: number }
|
||||
interface RecordRow { studentId: string; studentNumber: string; name: string; className: string; status: AttendanceStatus | number; notes?: string; checkInAt?: string; checkedInMethod?: string | number; checkInDistanceMeters?: number; isExempt: boolean; isDeferred: boolean; attemptCount: number; failedAttemptCount: number; riskFlags: string[]; checkInAudit?: { ipAddress?: string; devicePlatform?: string; deviceCode?: string; sameIpStudentCount: number } }
|
||||
interface SheetDetail { canEdit: boolean; sheet: Sheet & { taskNumber: string; courseCode: string; courseName: string; locationRadiusMeters?: number; records: RecordRow[]; riskSummary?: { riskStudentCount: number; sharedDeviceStudentCount: number } } }
|
||||
interface StudentStat { studentId: string; studentNumber: string; studentName: string; className: string; attendanceRate?: number; presentCount: number; absentCount: number; lateCount: number; leaveCount: number; excusedCount: number; requiredCount: number }
|
||||
interface Statistics { course: { courseName: string; courseCode: string; taskNumber: string; termName: string }; summary: { overallAttendanceRate?: number; studentCount: number; submittedSheetCount: number; perfectAttendanceCount: number; abnormalStudentCount: number; recordCount: number }; statusDistribution: Array<{ status: string | number; label: string; count: number }>; sessions: Array<{ attendanceDate: string; attendanceRate?: number }>; students: StudentStat[] }
|
||||
interface CreateValues { name: string; attendanceDate: dayjs.Dayjs; checkInMethod: CheckInMethod; checkInDurationMinutes: number; locationRadiusMeters: number }
|
||||
|
||||
const statuses: Array<{ value: AttendanceStatus; label: string }> = [{ value: 'Present', label: '出勤' }, { value: 'Absent', label: '缺勤' }, { value: 'Late', label: '迟到' }, { value: 'Leave', label: '请假' }, { value: 'Excused', label: '免修' }]
|
||||
const statusNames: Record<string | number, string> = { Present: '出勤', Absent: '缺勤', Late: '迟到', Leave: '请假', Excused: '免修', 1: '出勤', 2: '缺勤', 3: '迟到', 4: '请假', 5: '免修' }
|
||||
const statusValues: Record<number, AttendanceStatus> = { 1: 'Present', 2: 'Absent', 3: 'Late', 4: 'Leave', 5: 'Excused' }
|
||||
const riskLabels: Record<string, string> = { SharedDevice: '多人共用设备', HighFrequency: '高频尝试', RepeatedFailures: '多次失败', MissingDeviceId: '缺少设备标识' }
|
||||
const methodName = (value: string | number) => value === 'QrCode' || value === 2 ? '扫码签到' : value === 'Location' || value === 3 ? '定位签到' : '教师点名'
|
||||
const online = (value: string | number) => value === 'QrCode' || value === 'Location' || value === 2 || value === 3
|
||||
const draft = (value: string | number) => value === 'Draft' || value === 1
|
||||
const submitted = (value: string | number) => value === 'Submitted' || value === 2
|
||||
const normalizedStatus = (value: AttendanceStatus | number) => typeof value === 'number' ? statusValues[value] : value
|
||||
const serverTime = (value?: string) => !value ? Number.NaN : new Date(/(?:Z|[+-]\d{2}:\d{2})$/i.test(value) ? value : `${value}Z`).getTime()
|
||||
const rate = (value?: number) => value == null ? '暂无' : `${value.toFixed(1)}%`
|
||||
|
||||
function Trend({ sessions }: { sessions: Statistics['sessions'] }) {
|
||||
if (!sessions.length) return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无趋势数据" />
|
||||
const width = Math.max(520, sessions.length * 72), height = 190, pad = 30
|
||||
const points = sessions.map((item, index) => `${pad + index * (width - pad * 2) / Math.max(1, sessions.length - 1)},${height - pad - (item.attendanceRate ?? 0) / 100 * (height - pad * 2)}`).join(' ')
|
||||
return <div className="attendance-trend"><svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label="历次点名出勤率趋势"><g>{[0, 25, 50, 75, 100].map((tick) => <g key={tick}><line x1={pad} x2={width - pad} y1={height - pad - tick / 100 * (height - pad * 2)} y2={height - pad - tick / 100 * (height - pad * 2)} /><text x={4} y={height - pad - tick / 100 * (height - pad * 2) + 4}>{tick}%</text></g>)}</g><polyline points={points} />{sessions.map((item, index) => <g key={`${item.attendanceDate}-${index}`}><circle cx={pad + index * (width - pad * 2) / Math.max(1, sessions.length - 1)} cy={height - pad - (item.attendanceRate ?? 0) / 100 * (height - pad * 2)} r="4" /><text className="date" x={pad + index * (width - pad * 2) / Math.max(1, sessions.length - 1)} y={height - 8} textAnchor="middle">{dayjs(item.attendanceDate).format('M/D')}</text></g>)}</svg></div>
|
||||
}
|
||||
|
||||
export default function TeacherAttendancePage() {
|
||||
const { message, modal } = App.useApp(); const client = useQueryClient(); const [form] = Form.useForm<CreateValues>()
|
||||
const createMethod = Form.useWatch('checkInMethod', form)
|
||||
const [mode, setMode] = useState<Mode>('rollcall'); const [termId, setTermId] = useState<string>(); const [taskPage, setTaskPage] = useState(1); const [taskKeyword, setTaskKeyword] = useState(''); const [classKeyword, setClassKeyword] = useState(''); const [taskFilters, setTaskFilters] = useState({ keyword: '', classKeyword: '' })
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string>(); const [sheetPage, setSheetPage] = useState(1); const [sheetKeyword, setSheetKeyword] = useState(''); const [sheetStatus, setSheetStatus] = useState<string>(); const [sheetMethod, setSheetMethod] = useState<string>(); const [selectedSheetId, setSelectedSheetId] = useState<string>(); const [records, setRecords] = useState<RecordRow[]>([])
|
||||
const [createOpen, setCreateOpen] = useState(false); const [location, setLocation] = useState<{ latitude: number; longitude: number; accuracy: number }>(); const [locating, setLocating] = useState(false); const [qrOpen, setQrOpen] = useState(false); const [qrImage, setQrImage] = useState(''); const [qrExpires, setQrExpires] = useState(0); const [now, setNow] = useState(Date.now()); const fileRef = useRef<HTMLInputElement>(null)
|
||||
const [studentKeyword, setStudentKeyword] = useState(''); const [classFilter, setClassFilter] = useState<string>(); const [attentionFilter, setAttentionFilter] = useState<string>()
|
||||
const terms = useQuery({ queryKey: ['attendance-terms'], queryFn: async () => (await http.get<Term[]>('/base-data/terms')).data })
|
||||
const tasks = useQuery({ queryKey: ['attendance-tasks', termId ?? 'all', taskFilters, taskPage], enabled: terms.isSuccess, queryFn: async () => (await http.get<Paged<Task>>('/attendance/my-tasks', { params: { academicTermId: termId || undefined, keyword: taskFilters.keyword || undefined, classKeyword: taskFilters.classKeyword || undefined, page: taskPage, pageSize: 12 } })).data })
|
||||
const selectedTask = tasks.data?.items.find((item) => item.id === selectedTaskId)
|
||||
const sheets = useQuery({ queryKey: ['attendance-sheets', selectedTaskId, sheetKeyword, sheetStatus, sheetMethod, sheetPage], enabled: !!selectedTaskId, queryFn: async () => (await http.get<Paged<Sheet>>('/attendance/sheets', { params: { teachingTaskId: selectedTaskId, keyword: sheetKeyword || undefined, status: sheetStatus, checkInMethod: sheetMethod, page: sheetPage, pageSize: 12 } })).data })
|
||||
const detail = useQuery({ queryKey: ['attendance-sheet', selectedSheetId], enabled: !!selectedSheetId, queryFn: async () => (await http.get<SheetDetail>(`/attendance/sheets/${selectedSheetId}`)).data })
|
||||
const statistics = useQuery({ queryKey: ['attendance-statistics', selectedTaskId], enabled: mode === 'statistics' && !!selectedTaskId, queryFn: async () => (await http.get<Statistics>(`/attendance/tasks/${selectedTaskId}/statistics`)).data })
|
||||
useEffect(() => { if (terms.data && termId === undefined) setTermId(terms.data.find((item) => item.isCurrent)?.id ?? '') }, [termId, terms.data])
|
||||
useEffect(() => { if (!tasks.data) return; setSelectedTaskId((value) => tasks.data.items.some((item) => item.id === value) ? value : tasks.data.items[0]?.id) }, [tasks.data])
|
||||
useEffect(() => { setSelectedSheetId(undefined); setSheetPage(1); setSheetKeyword(''); setSheetStatus(undefined); setSheetMethod(undefined) }, [selectedTaskId])
|
||||
useEffect(() => { if (detail.data) setRecords(detail.data.sheet.records.map((item) => ({ ...item, status: normalizedStatus(item.status) }))) }, [detail.data])
|
||||
useEffect(() => { const timer = window.setInterval(() => setNow(Date.now()), 1000); return () => window.clearInterval(timer) }, [])
|
||||
useEffect(() => { if (!qrOpen || !selectedSheetId) return; const timer = window.setInterval(() => void refreshQr(false), 10000); return () => window.clearInterval(timer) }, [qrOpen, selectedSheetId])
|
||||
|
||||
const invalidate = async () => { await Promise.all([client.invalidateQueries({ queryKey: ['attendance-sheets'] }), client.invalidateQueries({ queryKey: ['attendance-sheet'] }), client.invalidateQueries({ queryKey: ['attendance-statistics'] }), client.invalidateQueries({ queryKey: ['attendance-tasks'] })]) }
|
||||
const create = useMutation({ mutationFn: async (values: CreateValues) => { let anchor = location; if (values.checkInMethod === 'Location' && !anchor) anchor = await captureLocation(); return http.post('/attendance/sheets', { teachingTaskId: selectedTaskId, name: values.name.trim(), attendanceDate: values.attendanceDate.startOf('day').toISOString(), checkInMethod: values.checkInMethod, checkInDurationMinutes: values.checkInMethod === 'Manual' ? null : values.checkInDurationMinutes, targetLatitude: values.checkInMethod === 'Location' ? anchor?.latitude : null, targetLongitude: values.checkInMethod === 'Location' ? anchor?.longitude : null, locationRadiusMeters: values.checkInMethod === 'Location' ? values.locationRadiusMeters : null }) }, onSuccess: async (response, values) => { setCreateOpen(false); message.success(values.checkInMethod === 'Manual' ? '考勤表已建立' : '签到活动已发起'); await invalidate(); setSelectedSheetId(response.data.id); if (response.data.checkInMethod === 'QrCode') await openQr(response.data.id) }, onError: (error) => message.error(apiErrorMessage(error)) })
|
||||
const save = useMutation({ mutationFn: () => http.put(`/attendance/sheets/${selectedSheetId}/records`, { records: records.map((item) => ({ studentId: item.studentId, status: item.status, notes: item.notes })) }), onSuccess: async () => { message.success('考勤记录已保存'); await invalidate() }, onError: (error) => message.error(apiErrorMessage(error)) })
|
||||
const submit = useMutation({ mutationFn: () => http.post(`/attendance/sheets/${selectedSheetId}/submit`), onSuccess: async () => { message.success('考勤表已提交'); await invalidate() }, onError: (error) => message.error(apiErrorMessage(error)) })
|
||||
|
||||
async function captureLocation() {
|
||||
setLocating(true)
|
||||
try {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
let permission = await Geolocation.checkPermissions()
|
||||
if (permission.location !== 'granted') permission = await Geolocation.requestPermissions({ permissions: ['location'] })
|
||||
if (permission.location !== 'granted') throw new Error('location permission denied')
|
||||
}
|
||||
const position = Capacitor.isNativePlatform() ? await Geolocation.getCurrentPosition({ enableHighAccuracy: true, timeout: 12000, maximumAge: 0 }) : await new Promise<GeolocationPosition>((resolve, reject) => {
|
||||
if (!navigator.geolocation) { reject(new Error('当前设备不支持定位')); return }
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: true, timeout: 12000, maximumAge: 0 })
|
||||
})
|
||||
const value = { latitude: Number(position.coords.latitude.toFixed(7)), longitude: Number(position.coords.longitude.toFixed(7)), accuracy: Math.round(position.coords.accuracy) }; setLocation(value); message.success('已获取教师当前位置'); return value
|
||||
} catch { message.error(Capacitor.isNativePlatform() ? '无法获取精确位置,请检查 App 定位权限。' : '无法获取位置,请允许浏览器使用位置信息。'); throw new Error('location unavailable') } finally { setLocating(false) }
|
||||
}
|
||||
function checkInOpen(sheet?: Sheet) { return !!sheet && draft(sheet.status) && online(sheet.checkInMethod) && serverTime(sheet.checkInStartsAt) <= now && now < serverTime(sheet.checkInEndsAt) }
|
||||
function remaining(sheet?: Sheet) { const ms = serverTime(sheet?.checkInEndsAt) - now; return ms <= 0 ? '签到已结束' : `${Math.floor(ms / 60000)}分${String(Math.floor(ms % 60000 / 1000)).padStart(2, '0')}秒后结束` }
|
||||
async function refreshQr(showError: boolean, sheetId = selectedSheetId) { if (!sheetId) return false; try { const { data } = await http.get<{ token: string; expiresAt: string }>(`/attendance/sheets/${sheetId}/qr-challenge`); const base = import.meta.env.VITE_PUBLIC_BASE_URL ?? window.location.origin; setQrImage(await QRCode.toDataURL(`${base}/my-attendance?token=${encodeURIComponent(data.token)}`, { width: 360, margin: 2, errorCorrectionLevel: 'M', color: { dark: '#172b4d', light: '#ffffff' } })); setQrExpires(serverTime(data.expiresAt)); return true } catch (error) { if (showError) message.error(apiErrorMessage(error)); return false } }
|
||||
async function openQr(sheetId = selectedSheetId) { if (await refreshQr(true, sheetId)) setQrOpen(true) }
|
||||
async function closeCheckIn() { if (!selectedSheetId) return; modal.confirm({ title: '提前结束签到?', content: '学生将不能再扫码或定位签到,教师仍可调整名单。', okText: '结束签到', cancelText: '继续签到', onOk: async () => { try { await http.post(`/attendance/sheets/${selectedSheetId}/close-check-in`); setQrOpen(false); message.success('签到已结束'); await invalidate() } catch (error) { message.error(apiErrorMessage(error)); throw error } } }) }
|
||||
async function removeSheet(item: Sheet) { modal.confirm({ title: `删除考勤表“${item.name}”?`, okText: '删除', okButtonProps: { danger: true }, onOk: async () => { try { await http.delete(`/attendance/sheets/${item.id}`); if (selectedSheetId === item.id) setSelectedSheetId(undefined); message.success('考勤表已删除'); await invalidate() } catch (error) { message.error(apiErrorMessage(error)); throw error } } }) }
|
||||
async function handleImport(file?: File) { if (!file || !selectedSheetId) return; try { const { data } = await importExcel(`/attendance/sheets/${selectedSheetId}/import`, file); message.success(`已从 Excel 更新 ${data.updated} 条记录`); await invalidate() } catch (error) { message.error(apiErrorMessage(error)) } finally { if (fileRef.current) fileRef.current.value = '' } }
|
||||
async function exportFile(path: string, name: string) { try { await downloadApiFile(path, name) } catch (error) { message.error(apiErrorMessage(error)) } }
|
||||
const classOptions = useMemo(() => [...new Set((statistics.data?.students ?? []).map((item) => item.className))].sort((a, b) => a.localeCompare(b, 'zh-CN')), [statistics.data])
|
||||
const filteredStudents = useMemo(() => (statistics.data?.students ?? []).filter((item) => { const keyword = studentKeyword.trim().toLowerCase(); if (keyword && !`${item.studentNumber} ${item.studentName}`.toLowerCase().includes(keyword)) return false; if (classFilter && item.className !== classFilter) return false; if (attentionFilter === 'abnormal' && item.absentCount === 0 && item.lateCount === 0) return false; return !(attentionFilter === 'below90' && (item.attendanceRate == null || item.attendanceRate >= 90)) }), [statistics.data, studentKeyword, classFilter, attentionFilter])
|
||||
const activeSheet = detail.data?.sheet
|
||||
if (terms.isLoading) return <main className="page-container"><Skeleton active paragraph={{ rows: 12 }} /></main>
|
||||
if (terms.isError) return <main className="page-container"><Alert type="error" showIcon message="教学点名无法加载" description={apiErrorMessage(terms.error)} /></main>
|
||||
return <main className="page-container teacher-attendance-page">
|
||||
<PageHeader kicker="ATTENDANCE CONTROL" title="教学点名" description="发起课堂签到、校正逐人记录,并持续查看课程出勤表现。" aside={<Button icon={<ReloadOutlined />} loading={tasks.isFetching} onClick={() => void client.invalidateQueries({ queryKey: ['attendance-tasks'] })}>刷新</Button>} />
|
||||
<section className="attendance-filters"><Input value={taskKeyword} allowClear prefix={<SearchOutlined />} placeholder="课程、任务号或教学班" onChange={(event) => setTaskKeyword(event.target.value)} onPressEnter={() => { setTaskPage(1); setTaskFilters({ keyword: taskKeyword.trim(), classKeyword: classKeyword.trim() }) }} /><Select allowClear placeholder="全部学期" value={termId || undefined} onChange={(value) => { setTermId(value ?? ''); setTaskPage(1) }} options={terms.data?.map((term) => ({ value: term.id, label: `${term.name}${term.isCurrent ? ' · 当前' : term.isArchived ? ' · 已归档' : ''}` }))} /><Input value={classKeyword} allowClear placeholder="行政班、专业或班级代码" onChange={(event) => setClassKeyword(event.target.value)} onPressEnter={() => { setTaskPage(1); setTaskFilters({ keyword: taskKeyword.trim(), classKeyword: classKeyword.trim() }) }} /><Button icon={<SearchOutlined />} onClick={() => { setTaskPage(1); setTaskFilters({ keyword: taskKeyword.trim(), classKeyword: classKeyword.trim() }) }}>查询</Button><Button onClick={() => { setTaskKeyword(''); setClassKeyword(''); setTaskFilters({ keyword: '', classKeyword: '' }); setTermId(terms.data?.find((item) => item.isCurrent)?.id ?? ''); setTaskPage(1) }}>重置</Button><span>共 {tasks.data?.total ?? 0} 个教学班</span><Radio.Group className="attendance-mode" value={mode} onChange={(event) => setMode(event.target.value)} optionType="button" options={[{ value: 'rollcall', label: '点名记录' }, { value: 'statistics', label: '课程统计' }]} /></section>
|
||||
{tasks.isError ? <Alert type="error" showIcon message="教学班无法加载" description={apiErrorMessage(tasks.error)} /> : <section className={`attendance-workspace ${mode === 'statistics' ? 'statistics-mode' : ''}`}>
|
||||
<aside className="attendance-task-list">{tasks.isLoading ? <Skeleton active paragraph={{ rows: 8 }} /> : tasks.data?.items.map((task) => <button key={task.id} className={`${selectedTaskId === task.id ? 'active' : ''} ${task.status === 'Closed' ? 'archived' : ''}`} onClick={() => setSelectedTaskId(task.id)}><span>{task.courseCode} · {task.taskNumber}</span><strong>{task.courseName}</strong><small>{task.termName} · {task.studentCount} 名学生 · {task.sheetCount} 张考勤表{task.status === 'Closed' ? ' · 已归档' : ''}</small></button>)}{!tasks.isLoading && !tasks.data?.items.length && <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有可点名的教学班" />}{(tasks.data?.total ?? 0) > 12 && <Pagination simple current={taskPage} pageSize={12} total={tasks.data?.total} onChange={setTaskPage} />}</aside>
|
||||
{mode === 'rollcall' ? <><section className="attendance-sheets">{!selectedTask ? <Empty description="请选择一个教学班" /> : <><header><div><strong>{selectedTask.courseName}</strong><span>{selectedTask.taskNumber} · {selectedTask.studentCount} 名学生</span></div><Button type="primary" disabled={selectedTask.status !== 'Published'} onClick={() => { setLocation(undefined); form.setFieldsValue({ name: '', attendanceDate: dayjs(), checkInMethod: 'Manual', checkInDurationMinutes: 15, locationRadiusMeters: 100 }); setCreateOpen(true) }}>新建考勤表</Button></header><div className="sheet-filters"><Input allowClear value={sheetKeyword} placeholder="考勤表名称或备注" onChange={(event) => { setSheetKeyword(event.target.value); setSheetPage(1) }} /><Select allowClear value={sheetStatus} placeholder="全部状态" onChange={(value) => { setSheetStatus(value); setSheetPage(1) }} options={[{ value: 'Draft', label: '草稿' }, { value: 'Submitted', label: '已提交' }]} /><Select allowClear value={sheetMethod} placeholder="全部方式" onChange={(value) => { setSheetMethod(value); setSheetPage(1) }} options={[{ value: 'Manual', label: '教师点名' }, { value: 'QrCode', label: '扫码签到' }, { value: 'Location', label: '定位签到' }]} /></div><div className="attendance-sheet-list">{sheets.isLoading ? <Skeleton active paragraph={{ rows: 7 }} /> : sheets.data?.items.map((sheet) => <button key={sheet.id} className={selectedSheetId === sheet.id ? 'active' : ''} onClick={() => setSelectedSheetId(sheet.id)}><div><strong>{sheet.name}{online(sheet.checkInMethod) && <Tag color="blue">{methodName(sheet.checkInMethod)}</Tag>}</strong><span>{dayjs(sheet.attendanceDate).format('YYYY/M/D')}</span></div><small>已签 {sheet.checkedInCount} · 出勤 {sheet.presentCount}{sheet.absentCount ? ` · 缺勤 ${sheet.absentCount}` : ''}{sheet.lateCount ? ` · 迟到 ${sheet.lateCount}` : ''}</small><em>{submitted(sheet.status) ? '已提交' : '草稿'}</em>{draft(sheet.status) && <DeleteOutlined className="delete-sheet" onClick={(event) => { event.stopPropagation(); void removeSheet(sheet) }} />}</button>)}{!sheets.isLoading && !sheets.data?.items.length && <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="尚未创建考勤表" />}</div>{(sheets.data?.total ?? 0) > 12 && <Pagination simple current={sheetPage} pageSize={12} total={sheets.data?.total} onChange={setSheetPage} />}</>}</section>
|
||||
<aside className="attendance-records">{!selectedSheetId ? <Empty description="选择考勤表后登记" /> : detail.isLoading ? <Skeleton active paragraph={{ rows: 10 }} /> : detail.isError ? <Alert type="error" showIcon message="考勤记录无法加载" description={apiErrorMessage(detail.error)} /> : activeSheet && <><header><div><strong>{activeSheet.name}</strong><span>{activeSheet.courseCode} · {activeSheet.taskNumber} · {methodName(activeSheet.checkInMethod)}</span></div><div>{detail.data?.canEdit && (activeSheet.checkInMethod === 'QrCode' || activeSheet.checkInMethod === 2) && <Button size="small" icon={<QrcodeOutlined />} onClick={() => void openQr()}>签到码</Button>}{detail.data?.canEdit && checkInOpen(activeSheet) && <Button size="small" danger onClick={() => void closeCheckIn()}>提前结束</Button>}<input ref={fileRef} hidden type="file" accept=".xlsx" onChange={(event) => void handleImport(event.target.files?.[0])} />{detail.data?.canEdit && <Button size="small" icon={<UploadOutlined />} onClick={() => fileRef.current?.click()}>导入</Button>}<Button size="small" icon={<DownloadOutlined />} onClick={() => void exportFile(`/attendance/sheets/${activeSheet.id}/export.xlsx`, `考勤表-${activeSheet.name}.xlsx`)}>导出</Button>{detail.data?.canEdit && <Button size="small" type="primary" loading={save.isPending} onClick={() => save.mutate()} icon={<CheckOutlined />}>保存</Button>}{detail.data?.canEdit && <Button size="small" type="primary" loading={submit.isPending} onClick={() => modal.confirm({ title: '提交考勤表?', content: '提交后不能继续修改记录。', okText: '提交', onOk: () => submit.mutateAsync() })}>提交</Button>}</div></header>{online(activeSheet.checkInMethod) && <div className={`check-in-console ${checkInOpen(activeSheet) ? 'open' : ''}`}><span><i />{checkInOpen(activeSheet) ? '签到进行中' : '签到已结束'}</span><strong>{activeSheet.checkedInCount}<small> / {records.length} 人已自主签到</small></strong><p><ClockCircleOutlined /> {remaining(activeSheet)}{activeSheet.locationRadiusMeters ? ` · 签到点 ${activeSheet.locationRadiusMeters} 米内有效` : ''}</p>{activeSheet.riskSummary?.riskStudentCount ? <p><WarningOutlined /> {activeSheet.riskSummary.riskStudentCount} 人存在风险信号,共用设备 {activeSheet.riskSummary.sharedDeviceStudentCount} 人</p> : null}</div>}{detail.data?.canEdit && <div className="record-batch"><span>批量设置</span>{statuses.slice(0, 3).map((item) => <Button key={item.value} size="small" onClick={() => setRecords((rows) => rows.map((row) => ({ ...row, status: item.value })))}>全部{item.label}</Button>)}</div>}<Table<RecordRow> rowKey="studentId" size="small" pagination={false} scroll={{ x: online(activeSheet.checkInMethod) ? 980 : 620, y: 520 }} dataSource={records} columns={[{ title: '学号', dataIndex: 'studentNumber', width: 120, fixed: 'left', render: (value: string) => <span className="registry-number">{value}</span> }, { title: '姓名', width: 125, fixed: 'left', render: (_, row) => <>{row.name}{row.isExempt && <Tag color="green">免修</Tag>}{row.isDeferred && <Tag color="gold">缓考</Tag>}</> }, { title: '考勤', width: 110, render: (_, row) => detail.data?.canEdit ? <Select size="small" value={row.status} options={statuses} onChange={(value) => setRecords((items) => items.map((item) => item.studentId === row.studentId ? { ...item, status: value } : item))} /> : statusNames[row.status] }, ...(online(activeSheet.checkInMethod) ? [{ title: '自主签到', width: 270, render: (_: unknown, row: RecordRow) => row.checkInAt ? <div className="record-audit"><strong>{methodName(row.checkedInMethod ?? '')} · {dayjs(serverTime(row.checkInAt)).format('HH:mm')}</strong>{row.checkInDistanceMeters != null && <span>距签到点 {Math.round(row.checkInDistanceMeters)} 米</span>}{row.checkInAudit && <span>{row.checkInAudit.devicePlatform || '未知设备'} · 设备 {row.checkInAudit.deviceCode || '未识别'}{row.checkInAudit.sameIpStudentCount > 1 ? ` · 同 IP ${row.checkInAudit.sameIpStudentCount} 人` : ''}</span>}{row.riskFlags?.map((flag) => <Tag key={flag} color="red">{riskLabels[flag] || flag}</Tag>)}</div> : <span className="muted">尚未签到</span> }] : []), { title: '备注', width: 150, render: (_, row) => detail.data?.canEdit ? <Input size="small" maxLength={300} value={row.notes} onChange={(event) => setRecords((items) => items.map((item) => item.studentId === row.studentId ? { ...item, notes: event.target.value } : item))} /> : row.notes || '—' }]} /></>}</aside></> : <section className="attendance-statistics">{!selectedTask ? <Empty description="请选择一个教学班查看统计" /> : statistics.isLoading ? <Skeleton active paragraph={{ rows: 12 }} /> : statistics.isError ? <Alert type="error" showIcon message="课程统计无法加载" description={apiErrorMessage(statistics.error)} /> : statistics.data && <><header><div><span>课程出勤档案</span><strong>{statisticLine truncated
|
||||
</section>}
|
||||
<Modal open={createOpen} title="发起课堂考勤" okText={createMethod === 'Manual' ? '建立考勤表' : '立即发起签到'} confirmLoading={create.isPending} onCancel={() => setCreateOpen(false)} onOk={() => form.submit()} width={650}><Form form={form} layout="vertical" onFinish={(values) => create.mutate(values)}><Form.Item name="name" label="考勤名称" rules={[{ required: true, whitespace: true, message: '请填写考勤名称' }]}><Input maxLength={120} placeholder="如:第3周课堂点名" /></Form.Item><Form.Item name="attendanceDate" label="考勤日期" rules={[{ required: true }]}><DatePicker className="full-width" /></Form.Item><Form.Item name="checkInMethod" label="签到方式"><Radio.Group className="check-in-methods" optionType="button" options={[{ value: 'Manual', label: '教师点名' }, { value: 'QrCode', label: '扫码签到' }, { value: 'Location', label: '定位签到' }]} /></Form.Item><Form.Item noStyle shouldUpdate={(before, after) => before.checkInMethod !== after.checkInMethod}>{({ getFieldValue }) => getFieldValue('checkInMethod') !== 'Manual' && <div className="online-settings"><Form.Item name="checkInDurationMinutes" label="签到时长(分钟)"><InputNumber min={1} max={180} step={5} /></Form.Item>{getFieldValue('checkInMethod') === 'Location' && <><Form.Item name="locationRadiusMeters" label="有效范围(米)"><InputNumber min={20} max={1000} step={10} /></Form.Item><div className="location-anchor"><EnvironmentOutlined /><div><strong>教师当前位置作为签到点</strong><span>{location ? `已定位,精度约 ${location.accuracy} 米` : '创建前需要允许设备获取精确位置'}</span></div><Button loading={locating} onClick={() => void captureLocation().catch(() => undefined)}>{location ? '重新定位' : '获取位置'}</Button></div></>}</div>}</Form.Item></Form></Modal>
|
||||
<Modal open={qrOpen} title="课堂扫码签到" width={520} onCancel={() => setQrOpen(false)} footer={<><Button danger disabled={!checkInOpen(activeSheet)} onClick={() => void closeCheckIn()}>提前结束签到</Button><Button type="primary" onClick={() => setQrOpen(false)}>完成</Button></>}><div className="qr-stage"><div><span>{activeSheet?.courseCode} · {activeSheet?.taskNumber}</span><strong>{activeSheet?.name}</strong><small>{activeSheet?.courseName}</small></div>{qrImage && <img src={qrImage} alt="课堂签到二维码" />}<p className={checkInOpen(activeSheet) ? 'active' : ''}><i />{checkInOpen(activeSheet) ? `动态二维码 · ${Math.max(0, Math.ceil((qrExpires - now) / 1000))} 秒内有效` : '本次签到已结束'}</p><small>二维码每 10 秒自动刷新,过期截图和旧链接不能签到</small></div></Modal>
|
||||
</main>
|
||||
}
|
||||
Loaded 3 of 8 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user