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
This commit is contained in:
2026-07-05 19:49:53 +08:00
parent b6e308ae2a
commit 41f381e941
7 changed files with 763 additions and 0 deletions

View File

@@ -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 = () => {
}
/>
<Route
path="attendance"
element={
<PermissionRoute permission="attendance:view">
<AttendancePage />
</PermissionRoute>
}
/>
<Route
path="schedules"
element={

View File

@@ -22,6 +22,7 @@ import {
CalendarOutlined,
SafetyOutlined,
KeyOutlined,
CheckCircleOutlined,
} from '@ant-design/icons';
import { usePermission } from '../hooks/usePermission';
@@ -77,6 +78,7 @@ const allMenuItems: MenuItemType[] = [
{ key: '/tenants', icon: <TagsOutlined />, label: '租赁方', permission: 'tenant:view' },
],
},
{ key: '/attendance', icon: <CheckCircleOutlined />, label: '考勤管理', permission: 'attendance:view' },
{ key: '/schedules', icon: <CalendarOutlined />, label: '排课管理', permission: 'schedule:view' },
{ key: '/operation-logs', icon: <AuditOutlined />, label: '操作日志', permission: 'log:view' },
{ key: '/roles', icon: <SafetyOutlined />, label: '角色管理', permission: 'role:view' },

View File

@@ -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<string, string> = {
morning_reading: '早自习',
morning: '上午',
afternoon: '下午',
evening_study: '晚自习',
night_check: '晚寝',
};
const STATUS_MAP: Record<string, { text: string; color: string }> = {
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<string, string> = {
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<AttendanceRecordItem[]>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [classOptions, setClassOptions] = useState<ClassOption[]>([]);
// Filters
const [filterClassId, setFilterClassId] = useState<number | undefined>(undefined);
const [filterDateRange, setFilterDateRange] = useState<[Dayjs, Dayjs] | null>(null);
const [filterSession, setFilterSession] = useState<string | undefined>(undefined);
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [filterSource, setFilterSource] = useState<string | undefined>(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<BatchRecordInput[]>([]);
const [batchDate, setBatchDate] = useState<Dayjs>(dayjs());
const [batchSession, setBatchSession] = useState<string>('morning');
const [batchStatus, setBatchStatus] = useState<string>('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<string, string | number> = { 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 ? <Tag color={item.color}>{item.text}</Tag> : 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) => (
<PermissionButton permission="attendance:edit" size="small" onClick={() => message.info('编辑功能开发中')}>
</PermissionButton>
),
},
],
[],
);
// ── Calendar columns ──
const calendarDates = useMemo(() => {
const dates = new Set<string>();
for (const student of calendarData) {
for (const day of student.days) {
dates.add(day.date);
}
}
return Array.from(dates).sort();
}, [calendarData]);
// ── Render ──
return (
<div>
{/* ── Filter bar ── */}
<Card size="small" style={{ marginBottom: 16 }}>
<Row gutter={[12, 12]} align="middle">
<Col>
<Select
placeholder="选择班级"
allowClear
style={{ width: 160 }}
value={filterClassId}
onChange={(v) => {
setFilterClassId(v);
setPage(1);
}}
options={classOptions.map((c) => ({
value: c.classId,
label: c.className,
}))}
/>
</Col>
<Col>
<RangePicker
value={filterDateRange}
onChange={(dates) => {
setFilterDateRange(dates as [Dayjs, Dayjs] | null);
setPage(1);
}}
placeholder={['开始日期', '结束日期']}
/>
</Col>
<Col>
<Select
placeholder="时段"
allowClear
style={{ width: 100 }}
value={filterSession}
onChange={(v) => {
setFilterSession(v);
setPage(1);
}}
options={SESSION_OPTIONS}
/>
</Col>
<Col>
<Select
placeholder="状态"
allowClear
style={{ width: 100 }}
value={filterStatus}
onChange={(v) => {
setFilterStatus(v);
setPage(1);
}}
options={STATUS_OPTIONS}
/>
</Col>
<Col>
<Select
placeholder="来源"
allowClear
style={{ width: 110 }}
value={filterSource}
onChange={(v) => {
setFilterSource(v);
setPage(1);
}}
options={SOURCE_OPTIONS}
/>
</Col>
<Col>
<Space>
<Button onClick={handleReset}></Button>
</Space>
</Col>
</Row>
</Card>
{/* ── Action bar ── */}
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<Space>
<PermissionButton
permission="attendance:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => setBatchModalOpen(true)}
>
</PermissionButton>
</Space>
<Button
icon={calendarView ? <UnorderedListOutlined /> : <CalendarOutlined />}
onClick={() => setCalendarView(!calendarView)}
>
{calendarView ? '列表视图' : '日历视图'}
</Button>
</div>
{/* ── Table view ── */}
{!calendarView && (
<Table
rowKey="id"
columns={columns}
dataSource={records}
loading={loading}
scroll={{ x: 1100 }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t: number) => `${t}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
)}
{/* ── Calendar view ── */}
{calendarView && (
<Card loading={calendarLoading} title={calendarView ? '考勤日历' : undefined}>
{calendarData.length === 0 ? (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
</div>
) : (
<Table
rowKey="studentId"
dataSource={calendarData}
pagination={false}
scroll={{ x: Math.max(800, calendarDates.length * 80) }}
columns={[
{
title: '学生',
dataIndex: 'studentName',
key: 'studentName',
width: 100,
fixed: 'left' as const,
},
...calendarDates.map((date) => ({
title: (
<div style={{ textAlign: 'center', fontSize: 12 }}>
<div>{dayjs(date).format('MM/DD')}</div>
<div style={{ color: '#999' }}>{dayjs(date).format('ddd')}</div>
</div>
),
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 <span style={{ color: '#d9d9d9' }}>-</span>;
const statusInfo = STATUS_MAP[dayRecord.status];
return (
<Tooltip title={`${SESSION_MAP[dayRecord.session] || dayRecord.session}: ${statusInfo?.text || dayRecord.status}`}>
<Tag color={statusInfo?.color || 'default'} style={{ margin: 0, cursor: 'pointer' }}>
{statusInfo?.text || dayRecord.status}
</Tag>
</Tooltip>
);
},
})),
]}
/>
)}
</Card>
)}
{/* ── Batch add modal ── */}
<Modal
title="批量录入考勤"
open={batchModalOpen}
onOk={handleBatchSubmit}
onCancel={() => {
setBatchModalOpen(false);
setBatchStudents([]);
setBatchRemark('');
}}
confirmLoading={batchSubmitting}
width={640}
okText="提交"
cancelText="取消"
>
<Form layout="vertical">
<Form.Item label="录入日期" required>
<DatePicker
value={batchDate}
onChange={(d) => setBatchDate(d || dayjs())}
style={{ width: '100%' }}
/>
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="时段" required>
<Select
value={batchSession}
onChange={setBatchSession}
options={SESSION_OPTIONS}
style={{ width: '100%' }}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="状态" required>
<Select
value={batchStatus}
onChange={setBatchStatus}
options={STATUS_OPTIONS}
style={{ width: '100%' }}
/>
</Form.Item>
</Col>
</Row>
<Form.Item label="添加学生" required>
<Select
showSearch
value={undefined}
placeholder="搜索学生姓名或学号"
filterOption={false}
onSearch={handleStudentSearch}
onSelect={(value: number) => {
const student = studentSearchResults.find((s) => s.id === value);
if (student) addStudentToBatch(student.id, student.name);
}}
loading={studentSearchLoading}
options={studentSearchResults.map((s) => ({
value: s.id,
label: `${s.name} (ID: ${s.id})`,
}))}
style={{ width: '100%' }}
notFoundContent={studentSearch ? '未找到匹配的学生' : '输入关键词搜索'}
/>
{batchStudents.length > 0 && (
<div style={{ marginTop: 8 }}>
{batchStudents.map((s) => (
<Tag
key={s.studentId}
closable
onClose={() => removeBatchStudent(s.studentId)}
style={{ marginBottom: 4 }}
>
{s.studentName}
</Tag>
))}
</div>
)}
</Form.Item>
<Form.Item label="备注">
<Input.TextArea
value={batchRemark}
onChange={(e) => setBatchRemark(e.target.value)}
placeholder="可选备注信息"
rows={2}
/>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default AttendancePage;

View File

@@ -13,6 +13,7 @@ import {
BatchCreateAttendanceDto,
AttendanceSummaryQueryDto,
AttendanceCalendarQueryDto,
QueryAttendanceRecordsDto,
QueryDingRawDto,
MatchDingRecordDto,
} from './dto/attendance.dto';
@@ -50,6 +51,20 @@ export class AttendanceController {
return result;
}
// ── List attendance records with filters ──
@Get('attendance-records')
@RequirePermission('attendance:view')
findAll(@Query() query: QueryAttendanceRecordsDto) {
return this.service.findAll(query);
}
// ── Get distinct classes with attendance records ──
@Get('attendance-records/classes')
@RequirePermission('attendance:view')
getClasses() {
return this.service.getClasses();
}
// ── Attendance summary ──
@Get('attendance-records/summary')
@RequirePermission('attendance:view')

View File

@@ -37,6 +37,7 @@ export class AttendanceService {
session: r.session,
status: r.status,
remark: r.remark,
source: r.source || 'manual',
}),
);
@@ -134,6 +135,64 @@ export class AttendanceService {
return Array.from(studentMap.values());
}
// ── List attendance records with filters ──
async findAll(query: {
classId?: number;
dateFrom?: string;
dateTo?: string;
session?: string;
status?: string;
source?: string;
page?: number;
pageSize?: number;
}) {
const page = query.page || 1;
const pageSize = query.pageSize || 20;
const qb = this.attendanceRepo
.createQueryBuilder('ar')
.leftJoinAndSelect('ar.student', 'student')
.leftJoinAndSelect('ar.class', 'class');
if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
}
if (query.dateFrom) {
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
}
if (query.dateTo) {
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
}
if (query.session) {
qb.andWhere('ar.session = :session', { session: query.session });
}
if (query.status) {
qb.andWhere('ar.status = :status', { status: query.status });
}
if (query.source) {
qb.andWhere('ar.source = :source', { source: query.source });
}
qb.orderBy('ar.attendanceDate', 'DESC').addOrderBy('ar.createdAt', 'DESC');
qb.skip((page - 1) * pageSize).take(pageSize);
const [list, total] = await qb.getManyAndCount();
return { list, total, page, pageSize };
}
// ── Get distinct classes with attendance records ──
async getClasses() {
const qb = this.attendanceRepo
.createQueryBuilder('ar')
.leftJoin('ar.class', 'class')
.select('DISTINCT ar.classId', 'classId')
.addSelect('class.name', 'className')
.where('ar.classId IS NOT NULL')
.orderBy('ar.classId', 'ASC');
return qb.getRawMany();
}
// ── DingAttendance raw records ──
async getDingRaw(query: QueryDingRawDto) {
const where: any = {};

View File

@@ -36,6 +36,10 @@ export class AttendanceRecordItem {
@IsOptional()
@IsString()
remark?: string;
@IsOptional()
@IsString()
source?: string;
}
export class BatchCreateAttendanceDto {
@@ -78,6 +82,44 @@ export class QueryDingRawDto {
matchStatus?: string;
}
export class QueryAttendanceRecordsDto {
@IsOptional()
@IsInt()
@Type(() => Number)
classId?: number;
@IsOptional()
@IsDateString()
dateFrom?: string;
@IsOptional()
@IsDateString()
dateTo?: string;
@IsOptional()
@IsString()
session?: string;
@IsOptional()
@IsString()
@IsIn(['present', 'late', 'absent', 'leave'])
status?: string;
@IsOptional()
@IsString()
source?: string;
@IsOptional()
@IsInt()
@Type(() => Number)
page?: number;
@IsOptional()
@IsInt()
@Type(() => Number)
pageSize?: number;
}
export class MatchDingRecordDto {
@IsInt()
@IsNotEmpty()

View File

@@ -44,6 +44,9 @@ export class AttendanceRecord {
@Column({ length: 200, nullable: true })
remark: string;
@Column({ name: 'source', length: 20, default: 'manual' })
source: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;