From 41f381e941b07313b9e0da9accceee791283be30 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sun, 5 Jul 2026 19:49:53 +0800 Subject: [PATCH] feat: attendance frontend page + backend list/classes endpoints - Create AttendancePage with filter bar, table, calendar view toggle, batch add modal - Register /attendance route with PermissionRoute (attendance:view) - Add menu item in MainLayout with CheckCircleOutlined icon - Add GET /attendance-records with pagination and filters - Add GET /attendance-records/classes for class dropdown - Add source column to AttendanceRecord entity (default: manual) - Frontend tsc --noEmit: clean; backend: only pre-existing test errors --- apps/admin/src/App.tsx | 10 + apps/admin/src/layouts/MainLayout.tsx | 2 + apps/admin/src/pages/Attendance/index.tsx | 632 ++++++++++++++++++ .../src/attendance/attendance.controller.ts | 15 + .../src/attendance/attendance.service.ts | 59 ++ .../src/attendance/dto/attendance.dto.ts | 42 ++ .../src/entities/attendance-record.entity.ts | 3 + 7 files changed, 763 insertions(+) create mode 100644 apps/admin/src/pages/Attendance/index.tsx diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 8ffbd2d..93c6743 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -23,6 +23,7 @@ import ClassroomSchedulePage from './pages/ClassroomSchedule'; import SchedulesPage from './pages/Schedules'; import RolesPage from './pages/Roles'; import PermissionsPage from './pages/Permissions'; +import AttendancePage from './pages/Attendance'; import PermissionRoute from './components/PermissionRoute'; const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { @@ -202,6 +203,15 @@ const App: React.FC = () => { } /> + + + + } + /> + , label: '租赁方', permission: 'tenant:view' }, ], }, + { key: '/attendance', icon: , label: '考勤管理', permission: 'attendance:view' }, { key: '/schedules', icon: , label: '排课管理', permission: 'schedule:view' }, { key: '/operation-logs', icon: , label: '操作日志', permission: 'log:view' }, { key: '/roles', icon: , label: '角色管理', permission: 'role:view' }, diff --git a/apps/admin/src/pages/Attendance/index.tsx b/apps/admin/src/pages/Attendance/index.tsx new file mode 100644 index 0000000..a8a6937 --- /dev/null +++ b/apps/admin/src/pages/Attendance/index.tsx @@ -0,0 +1,632 @@ +import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import { + Table, + Button, + Modal, + Form, + DatePicker, + Select, + Space, + message, + Tag, + Card, + Input, + Tooltip, + Row, + Col, +} from 'antd'; +import { + PlusOutlined, + CalendarOutlined, + UnorderedListOutlined, +} from '@ant-design/icons'; +import dayjs, { Dayjs } from 'dayjs'; +import api from '../../api'; +import PermissionButton from '../../components/PermissionButton'; + +const { RangePicker } = DatePicker; + +// ── Constants ── + +const SESSION_OPTIONS = [ + { value: 'morning_reading', label: '早自习' }, + { value: 'morning', label: '上午' }, + { value: 'afternoon', label: '下午' }, + { value: 'evening_study', label: '晚自习' }, + { value: 'night_check', label: '晚寝' }, +]; + +const SESSION_MAP: Record = { + morning_reading: '早自习', + morning: '上午', + afternoon: '下午', + evening_study: '晚自习', + night_check: '晚寝', +}; + +const STATUS_MAP: Record = { + present: { text: '出勤', color: 'green' }, + late: { text: '迟到', color: 'gold' }, + absent: { text: '缺勤', color: 'red' }, + leave: { text: '请假', color: 'blue' }, +}; + +const STATUS_OPTIONS = Object.entries(STATUS_MAP).map(([value, { text }]) => ({ + value, + label: text, +})); + +const SOURCE_OPTIONS = [ + { value: 'manual', label: '手动录入' }, + { value: 'dingtalk', label: '钉钉导入' }, +]; + +const SOURCE_MAP: Record = { + manual: '手动录入', + dingtalk: '钉钉导入', +}; + +// ── Types ── + +interface ClassOption { + classId: number; + className: string; +} + +interface AttendanceRecordItem { + id: number; + studentId: number; + classId: number | null; + attendanceDate: string; + session: string; + status: string; + source?: string; + remark: string | null; + checkTime?: string; + createdAt: string; + updatedAt: string; + student: { id: number; name: string }; + class: { id: number; name: string } | null; +} + +interface BatchRecordInput { + studentId: number; + studentName: string; +} + +// ── Component ── + +const AttendancePage: React.FC = () => { + // ── State ── + const [records, setRecords] = useState([]); + const [loading, setLoading] = useState(false); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [classOptions, setClassOptions] = useState([]); + + // Filters + const [filterClassId, setFilterClassId] = useState(undefined); + const [filterDateRange, setFilterDateRange] = useState<[Dayjs, Dayjs] | null>(null); + const [filterSession, setFilterSession] = useState(undefined); + const [filterStatus, setFilterStatus] = useState(undefined); + const [filterSource, setFilterSource] = useState(undefined); + + // View toggle + const [calendarView, setCalendarView] = useState(false); + const [calendarData, setCalendarData] = useState< + { studentId: number; studentName: string; days: { date: string; session: string; status: string }[] }[] + >([]); + const [calendarLoading, setCalendarLoading] = useState(false); + + // Batch modal + const [batchModalOpen, setBatchModalOpen] = useState(false); + const [batchStudents, setBatchStudents] = useState([]); + const [batchDate, setBatchDate] = useState(dayjs()); + const [batchSession, setBatchSession] = useState('morning'); + const [batchStatus, setBatchStatus] = useState('present'); + const [batchRemark, setBatchRemark] = useState(''); + const [batchSubmitting, setBatchSubmitting] = useState(false); + const [studentSearch, setStudentSearch] = useState(''); + const [studentSearchResults, setStudentSearchResults] = useState<{ id: number; name: string }[]>([]); + const [studentSearchLoading, setStudentSearchLoading] = useState(false); + + // ── Fetch classes ── + const fetchClasses = useCallback(async () => { + try { + const res = await api.get('/attendance-records/classes'); + setClassOptions(res as ClassOption[]); + } catch { + // Silently fail — class filter just stays empty + } + }, []); + + // ── Fetch records ── + const fetchRecords = useCallback(async () => { + setLoading(true); + try { + const params: Record = { page, pageSize }; + if (filterClassId) params.classId = filterClassId; + if (filterDateRange?.[0]) params.dateFrom = filterDateRange[0].format('YYYY-MM-DD'); + if (filterDateRange?.[1]) params.dateTo = filterDateRange[1].format('YYYY-MM-DD'); + if (filterSession) params.session = filterSession; + if (filterStatus) params.status = filterStatus; + if (filterSource) params.source = filterSource; + + const res = await api.get('/attendance-records', { params }); + const data = res as { list: AttendanceRecordItem[]; total: number }; + setRecords(data.list); + setTotal(data.total); + } catch (e: unknown) { + const err = e as { message?: string }; + message.error(err?.message || '获取考勤记录失败'); + } finally { + setLoading(false); + } + }, [page, pageSize, filterClassId, filterDateRange, filterSession, filterStatus, filterSource]); + + // ── Fetch calendar ── + const fetchCalendar = useCallback(async () => { + if (!filterClassId) { + message.warning('请先选择班级'); + return; + } + setCalendarLoading(true); + try { + const res = await api.get('/attendance-records/calendar', { + params: { classId: filterClassId }, + }); + setCalendarData(res as { studentId: number; studentName: string; days: { date: string; session: string; status: string }[] }[]); + } catch (e: unknown) { + const err = e as { message?: string }; + message.error(err?.message || '获取日历数据失败'); + } finally { + setCalendarLoading(false); + } + }, [filterClassId]); + + // ── Effects ── + useEffect(() => { + fetchClasses(); + }, [fetchClasses]); + + useEffect(() => { + if (calendarView) { + fetchCalendar(); + } else { + fetchRecords(); + } + }, [calendarView, fetchRecords, fetchCalendar]); + + // ── Student search ── + const handleStudentSearch = useCallback(async (value: string) => { + setStudentSearch(value); + if (!value || value.length < 1) { + setStudentSearchResults([]); + return; + } + setStudentSearchLoading(true); + try { + const res = await api.get('/students', { params: { search: value, pageSize: 10 } }); + const data = res as { list?: { id: number; name: string }[] } | { id: number; name: string }[]; + const list = Array.isArray(data) ? data : data.list ?? []; + setStudentSearchResults(list); + } catch { + setStudentSearchResults([]); + } finally { + setStudentSearchLoading(false); + } + }, []); + + const addStudentToBatch = useCallback( + (studentId: number, studentName: string) => { + if (batchStudents.some((s) => s.studentId === studentId)) { + message.warning('该学生已在列表中'); + return; + } + setBatchStudents((prev) => [...prev, { studentId, studentName }]); + setStudentSearch(''); + setStudentSearchResults([]); + }, + [batchStudents], + ); + + const removeBatchStudent = useCallback((studentId: number) => { + setBatchStudents((prev) => prev.filter((s) => s.studentId !== studentId)); + }, []); + + // ── Batch submit ── + const handleBatchSubmit = useCallback(async () => { + if (batchStudents.length === 0) { + message.warning('请添加至少一名学生'); + return; + } + setBatchSubmitting(true); + try { + const records = batchStudents.map((s) => ({ + studentId: s.studentId, + classId: filterClassId, + attendanceDate: batchDate.format('YYYY-MM-DD'), + session: batchSession, + status: batchStatus, + remark: batchRemark || undefined, + })); + await api.post('/attendance-records/batch', { records }); + message.success(`成功录入 ${batchStudents.length} 条考勤记录`); + setBatchModalOpen(false); + setBatchStudents([]); + setBatchRemark(''); + fetchRecords(); + } catch (e: unknown) { + const err = e as { message?: string }; + message.error(err?.message || '批量录入失败'); + } finally { + setBatchSubmitting(false); + } + }, [batchStudents, batchDate, batchSession, batchStatus, batchRemark, filterClassId, fetchRecords]); + + // ── Reset filters ── + const handleReset = useCallback(() => { + setFilterClassId(undefined); + setFilterDateRange(null); + setFilterSession(undefined); + setFilterStatus(undefined); + setFilterSource(undefined); + setPage(1); + }, []); + + // ── Table columns ── + const columns = useMemo( + () => [ + { + title: '学生姓名', + dataIndex: ['student', 'name'], + key: 'studentName', + width: 100, + fixed: 'left' as const, + }, + { + title: '班级', + dataIndex: ['class', 'name'], + key: 'className', + width: 120, + render: (v: string | undefined) => v || '-', + }, + { + title: '日期', + dataIndex: 'attendanceDate', + key: 'attendanceDate', + width: 110, + }, + { + title: '时段', + dataIndex: 'session', + key: 'session', + width: 90, + render: (v: string) => SESSION_MAP[v] || v, + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 80, + render: (v: string) => { + const item = STATUS_MAP[v]; + return item ? {item.text} : v; + }, + }, + { + title: '来源', + dataIndex: 'source', + key: 'source', + width: 90, + render: (v: string | undefined) => (v ? SOURCE_MAP[v] || v : '-'), + }, + { + title: '备注', + dataIndex: 'remark', + key: 'remark', + width: 150, + ellipsis: true, + render: (v: string | null) => v || '-', + }, + { + title: '录入时间', + dataIndex: 'createdAt', + key: 'createdAt', + width: 160, + render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'), + }, + { + title: '操作', + key: 'actions', + width: 80, + fixed: 'right' as const, + render: (_: unknown, record: AttendanceRecordItem) => ( + message.info('编辑功能开发中')}> + 编辑 + + ), + }, + ], + [], + ); + + // ── Calendar columns ── + const calendarDates = useMemo(() => { + const dates = new Set(); + for (const student of calendarData) { + for (const day of student.days) { + dates.add(day.date); + } + } + return Array.from(dates).sort(); + }, [calendarData]); + + // ── Render ── + return ( +
+ {/* ── Filter bar ── */} + + + + { + setFilterSession(v); + setPage(1); + }} + options={SESSION_OPTIONS} + /> + + + { + setFilterSource(v); + setPage(1); + }} + options={SOURCE_OPTIONS} + /> + + + + + + + + + + {/* ── Action bar ── */} +
+ + } + onClick={() => setBatchModalOpen(true)} + > + 批量录入 + + + +
+ + {/* ── Table view ── */} + {!calendarView && ( + `共 ${t} 条`, + onChange: (p, ps) => { + setPage(p); + setPageSize(ps); + }, + }} + /> + )} + + {/* ── Calendar view ── */} + {calendarView && ( + + {calendarData.length === 0 ? ( +
+ 暂无数据,请选择班级后查看 +
+ ) : ( +
({ + title: ( +
+
{dayjs(date).format('MM/DD')}
+
{dayjs(date).format('ddd')}
+
+ ), + key: date, + width: 80, + render: (_: unknown, record: { studentId: number; studentName: string; days: { date: string; session: string; status: string }[] }) => { + const dayRecord = record.days.find((d) => d.date === date); + if (!dayRecord) return -; + const statusInfo = STATUS_MAP[dayRecord.status]; + return ( + + + {statusInfo?.text || dayRecord.status} + + + ); + }, + })), + ]} + /> + )} + + )} + + {/* ── Batch add modal ── */} + { + setBatchModalOpen(false); + setBatchStudents([]); + setBatchRemark(''); + }} + confirmLoading={batchSubmitting} + width={640} + okText="提交" + cancelText="取消" + > +
+ + setBatchDate(d || dayjs())} + style={{ width: '100%' }} + /> + + + +
+ + + + + + + +