feat: 考勤模块重构与钉钉考勤同步

This commit is contained in:
2026-08-05 17:11:23 +08:00
parent c622e40a12
commit e9c8a1085d
36 changed files with 5122 additions and 3458 deletions

View File

@@ -0,0 +1,199 @@
import dayjs from 'dayjs';
import type { AttendanceSummary } from './attendance-workspace';
import type { LessonAttendanceRecord } from './types';
export type { AttendanceSummary } from './attendance-workspace';
export const DEFAULT_ATTENDANCE_PERIODS = [
{ periodKey: 'morning_reading', label: '早自习', startTime: '07:30', endTime: '08:30', sortOrder: 1, enabled: true },
{ periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 2, enabled: true },
{ periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3, enabled: true },
{ periodKey: 'evening_study', label: '晚自习', startTime: '18:30', endTime: '21:00', sortOrder: 4, enabled: true },
];
export const STATUS_META: Record<
string,
{ label: string; color: string; className: string; short: string }
> = {
present: { label: '出勤', color: 'success', className: 'is-present', short: '勤' },
absent: { label: '缺勤', color: 'error', className: 'is-absent', short: '缺' },
leave: { label: '请假', color: 'processing', className: 'is-leave', short: '假' },
pending: { label: '待确认', color: 'default', className: 'is-pending', short: '待' },
};
export const ADMIN_CORRECTION_OPTIONS = [
{ value: 'present', label: '正常' },
{ value: 'leave', label: '请假' },
{ value: 'absent', label: '缺勤' },
];
export interface ClassTeacherOption {
userId: number;
username: string | null;
name: string | null;
roleType: string;
subject: string | null;
}
export interface ClassOption {
classId: number;
className: string;
teachers?: ClassTeacherOption[];
}
export type AttendanceRecordItem = LessonAttendanceRecord;
export interface HistoryScheduleOption {
id: number;
classId: number;
weekDay: number;
startTime: string;
endTime: string;
startDate: string;
endDate: string;
subject: string;
teacherId: number | null;
teacherName?: string | null;
teacherUsername?: string | null;
status: string;
}
export interface AlertItem {
studentId: number;
studentName: string;
studentNo: string;
className: string;
type: string;
count: number;
lastDate: string;
}
export interface AttendancePeriodConfigItem {
id?: number;
periodKey: string;
label: string;
startTime: string;
endTime: string;
sortOrder: number;
enabled: boolean;
}
export interface DingTalkSyncStatus {
lastPulledAt: string | null;
action: string | null;
username: string | null;
detail: string | null;
}
export const EMPTY_SUMMARY: AttendanceSummary = {
total: 0,
present: 0,
late: 0,
absent: 0,
leave: 0,
pending: 0,
};
export function displayAttendanceStatus(status?: string | null): string {
return status === 'pending' || !status ? 'absent' : status;
}
export function getTeacherDisplayName(teacher?: {
name?: string | null;
username?: string | null;
}): string {
const name = teacher?.name?.trim();
if (name) return name;
return teacher?.username?.trim() || '未设置';
}
export function formatTeacherNames(
teachers: readonly { name?: string | null; username?: string | null }[],
): string {
const names = [
...new Set(
teachers
.map((teacher) => getTeacherDisplayName(teacher))
.filter((name) => name && name !== '未设置'),
),
];
return names.length > 0 ? names.join('、') : '未设置';
}
export function AttendanceStatusTag({ status }: { status: string }) {
const displayStatus = displayAttendanceStatus(status);
const meta = STATUS_META[displayStatus] ?? {
label: displayStatus,
color: 'default',
className: 'is-absent',
short: '?',
};
return (
<span className={`attendance-status ${meta.className}`}>
<span className="attendance-status__dot" />
{meta.label}
</span>
);
}
export interface AdminStudentPanel {
key: string;
studentId: number;
studentName: string;
studentNo: string;
className: string;
records: AttendanceRecordItem[];
statusBySession: Partial<Record<string, AttendanceRecordItem>>;
primaryStatus: string;
rate: number;
latestDate: string;
}
export const ADMIN_METRIC_META = [
{ key: 'all', label: '出勤率', short: '率' },
{ key: 'present', label: '正常', short: '正常' },
{ key: 'leave', label: '请假', short: '请假' },
{ key: 'absent', label: '缺勤', short: '缺勤' },
];
function pickPrimaryStatus(records: AttendanceRecordItem[]) {
const priority = ['absent', 'leave', 'present'];
return (
priority.find((item) =>
records.some((record) => displayAttendanceStatus(record.status) === item),
) || 'absent'
);
}
export function buildAdminStudentPanels(records: AttendanceRecordItem[]): AdminStudentPanel[] {
const map = new Map<number, AdminStudentPanel>();
for (const record of records) {
const current = map.get(record.studentId) ?? {
key: String(record.studentId),
studentId: record.studentId,
studentName: record.student?.name || '未知学生',
studentNo: record.student?.studentNo?.trim() || '',
className: record.class?.name || '未关联班级',
records: [],
statusBySession: {},
primaryStatus: 'absent',
rate: 0,
latestDate: record.attendanceDate,
};
current.records.push(record);
if (!current.statusBySession[record.session]) current.statusBySession[record.session] = record;
if (dayjs(record.attendanceDate).isAfter(dayjs(current.latestDate))) {
current.latestDate = record.attendanceDate;
}
map.set(record.studentId, current);
}
return Array.from(map.values()).map((item) => {
const checked = item.records.filter((record) => record.status === 'present').length;
return {
...item,
primaryStatus: pickPrimaryStatus(item.records),
rate: item.records.length > 0 ? Math.round((checked / item.records.length) * 100) : 0,
};
});
}

View File

@@ -0,0 +1,131 @@
import { Avatar, Segmented, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import EditableCell from '../../components/EditableCell';
import { getPunchDisplayInfo } from './attendance-workspace';
import {
ADMIN_CORRECTION_OPTIONS,
AttendanceStatusTag,
displayAttendanceStatus,
type AttendanceRecordItem,
} from './AttendanceAdmin.helpers';
export interface AttendanceAdminColumnContext {
isMobile: boolean;
canEdit: boolean;
sessionMap: Record<string, string>;
correctingRecordId: number | null;
onSaveAdminRecordCell: (
record: AttendanceRecordItem,
field: 'status' | 'remark',
value: unknown,
) => void;
onUpdateAdminRecordStatus: (record: AttendanceRecordItem, nextStatus: string) => void;
}
const buildAttendanceAdminDataColumns = (ctx: AttendanceAdminColumnContext) => {
const { isMobile, canEdit, sessionMap, onSaveAdminRecordCell } = ctx;
return [
{
title: '学生',
dataIndex: ['student', 'name'],
fixed: (isMobile ? undefined : 'left') as 'left' | undefined,
width: 150,
render: (name: string, record: AttendanceRecordItem) => (
<div className="student-cell">
<Avatar size={34}>{name?.slice(0, 1)}</Avatar>
<div>
<strong>{name || '-'}</strong>
<span>{record.class?.name || '未关联班级'}</span>
</div>
</div>
),
},
{ title: '日期', dataIndex: 'attendanceDate', width: 120 },
{
title: '时段',
dataIndex: 'session',
width: 110,
render: (value: string) => sessionMap[value] || value,
},
{
title: '状态',
dataIndex: 'status',
width: 105,
render: (value: string, record: AttendanceRecordItem) => (
<EditableCell
value={displayAttendanceStatus(value)}
editor="select"
options={ADMIN_CORRECTION_OPTIONS.map((item) => ({
value: String(item.value),
label: item.label,
}))}
disabled={!canEdit}
onSave={async (next) => {
onSaveAdminRecordCell(record, 'status', next);
}}
>
<AttendanceStatusTag status={displayAttendanceStatus(value)} />
</EditableCell>
),
},
{
title: '签到来源',
width: 220,
render: (_: unknown, record: AttendanceRecordItem) => {
const info = getPunchDisplayInfo(record);
if (!info) return <span className="muted-text"></span>;
return (
<div className="punch-device-cell">
<Tag color={info.machine ? 'green' : 'blue'}>{info.label}</Tag>
{info.detail && <strong>{info.detail}</strong>}
{info.time && <span>{dayjs(info.time).format('HH:mm:ss')}</span>}
</div>
);
},
},
{
title: '备注',
dataIndex: 'remark',
ellipsis: true,
render: (value: string | null, record: AttendanceRecordItem) => (
<EditableCell
value={value}
editor="textarea"
disabled={!canEdit}
onSave={async (next) => {
onSaveAdminRecordCell(record, 'remark', next);
}}
>
{value || <span className="muted-text"></span>}
</EditableCell>
),
},
] as ColumnsType<AttendanceRecordItem>;
};
const buildAttendanceAdminActionColumn = (ctx: AttendanceAdminColumnContext) => {
const { isMobile, correctingRecordId, onUpdateAdminRecordStatus } = ctx;
return {
title: '操作',
key: 'action',
fixed: (isMobile ? undefined : 'right') as 'right' | undefined,
width: 220,
render: (_: unknown, record: AttendanceRecordItem) => (
<Segmented
size="small"
className="attendance-correction-segment"
options={ADMIN_CORRECTION_OPTIONS}
value={displayAttendanceStatus(record.status)}
disabled={correctingRecordId === record.id}
onChange={(value) => void onUpdateAdminRecordStatus(record, String(value))}
/>
),
};
};
export const buildAttendanceAdminColumns = (ctx: AttendanceAdminColumnContext) => {
const dataColumns = buildAttendanceAdminDataColumns(ctx);
const actionColumn = ctx.canEdit ? [buildAttendanceAdminActionColumn(ctx)] : [];
return [...dataColumns, ...actionColumn] as ColumnsType<AttendanceRecordItem>;
};

View File

@@ -0,0 +1,275 @@
import React from 'react';
import {
Avatar,
Button,
DatePicker,
Select,
Spin,
Tooltip,
} from 'antd';
import {
ExportOutlined,
FileSearchOutlined,
ReloadOutlined,
ScheduleOutlined,
UndoOutlined,
WarningFilled,
} from '@ant-design/icons';
import dayjs, { type Dayjs } from 'dayjs';
import PermissionButton from '../../components/PermissionButton';
import {
ADMIN_METRIC_META,
STATUS_META,
type AlertItem,
type AttendanceSummary,
type ClassOption,
type DingTalkSyncStatus,
type HistoryScheduleOption,
} from './AttendanceAdmin.helpers';
export const AttendanceAdminHeader: React.FC<{
syncStatus: DingTalkSyncStatus | null;
canEdit: boolean;
refreshingDingTalk: boolean;
onOpenPeriodConfig: () => void;
onRefreshDingTalk: () => void;
onExport: () => void;
attendanceDate: Dayjs | null;
onDateChange: (date: Dayjs | null) => void;
classId?: number;
onClassChange: (value?: number) => void;
classOptions: ClassOption[];
effectiveScheduleId?: number;
onScheduleChange: (value?: number) => void;
scheduleOptions: HistoryScheduleOption[];
scheduleOptionsLoading: boolean;
session?: string;
onSessionChange: (value?: string) => void;
sessionOptions: Array<{ value: string; label: string }>;
onReset: () => void;
onQuery: () => void;
selectedClass: string;
dateLabel: string;
visibleStudentCount: number;
total: number;
headTeacherNames: string;
lifeTeacherNames: string;
subjectTeacherNames: string;
attendanceRate: number;
summary: AttendanceSummary;
metricFilter: string;
onMetricFilterChange: (key: string) => void;
alerts: AlertItem[];
}> = ({
syncStatus,
canEdit,
refreshingDingTalk,
onOpenPeriodConfig,
onRefreshDingTalk,
onExport,
attendanceDate,
onDateChange,
classId,
onClassChange,
classOptions,
effectiveScheduleId,
onScheduleChange,
scheduleOptions,
scheduleOptionsLoading,
session,
onSessionChange,
sessionOptions,
onReset,
onQuery,
selectedClass,
dateLabel,
visibleStudentCount,
total,
headTeacherNames,
lifeTeacherNames,
subjectTeacherNames,
attendanceRate,
summary,
metricFilter,
onMetricFilterChange,
alerts,
}) => {
return (
<>
<header className="student-center-topbar">
<div className="student-center-title">
<h1></h1>
<span></span>
</div>
<div className="student-center-actions">
<Tooltip title={syncStatus?.detail || syncStatus?.action || '暂无钉钉拉取记录'}>
<span className="student-sync-status">
<i />
{syncStatus?.lastPulledAt
? `最近拉取钉钉 ${dayjs(syncStatus.lastPulledAt).format('YYYY-MM-DD HH:mm')}`
: '暂无钉钉拉取记录'}
</span>
</Tooltip>
{canEdit && (
<Button icon={<ScheduleOutlined />} onClick={onOpenPeriodConfig}>
</Button>
)}
<PermissionButton
permission="attendance:create"
icon={<ReloadOutlined />}
loading={refreshingDingTalk}
onClick={onRefreshDingTalk}
>
</PermissionButton>
<PermissionButton permission="attendance:export" icon={<ExportOutlined />} onClick={onExport}>
</PermissionButton>
</div>
</header>
<section className="student-filter-panel" aria-label="考勤筛选">
<div className="student-filter-field student-filter-field--date">
<label></label>
<DatePicker
allowClear={false}
value={attendanceDate}
disabledDate={(current) => current.isAfter(dayjs(), 'day')}
onChange={onDateChange}
/>
</div>
<div className="student-filter-field">
<label></label>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="全部班级"
value={classId}
onChange={onClassChange}
options={classOptions.map((item) => ({ value: item.classId, label: item.className }))}
/>
</div>
<div className="student-filter-field">
<label></label>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder={classId ? '全部科目' : '请先选择班级'}
value={effectiveScheduleId}
loading={scheduleOptionsLoading}
disabled={!classId || !attendanceDate}
notFoundContent={scheduleOptionsLoading ? <Spin size="small" /> : '当前日期没有排课'}
onChange={onScheduleChange}
options={scheduleOptions.map((schedule) => ({
value: schedule.id,
label: `${schedule.subject}${schedule.startTime}-${schedule.endTime}`,
}))}
/>
</div>
<div className="student-filter-field">
<label></label>
<Select disabled placeholder="全部老师" />
</div>
<div className="student-filter-field">
<label></label>
<Select
allowClear
placeholder="全部时段"
value={session}
onChange={onSessionChange}
options={sessionOptions}
/>
</div>
<div className="student-filter-actions">
<Button icon={<UndoOutlined />} onClick={onReset}>
</Button>
<Button type="primary" icon={<FileSearchOutlined />} onClick={onQuery}>
</Button>
</div>
</section>
<section className="student-class-overview" aria-label="班级考勤汇总">
<div className="student-class-identity">
<div className="student-class-heading">
<span className="student-overview-kicker"></span>
<h2>{selectedClass}</h2>
<p>
{dateLabel} · {visibleStudentCount} / {total}
</p>
</div>
<div className="student-teacher-list" aria-label="筛选上下文">
<div className="student-teacher-item">
<Avatar></Avatar>
<div>
<span></span>
<strong>{headTeacherNames}</strong>
</div>
</div>
<div className="student-teacher-item">
<Avatar></Avatar>
<div>
<span></span>
<strong>{lifeTeacherNames}</strong>
</div>
</div>
<div className="student-teacher-item">
<Avatar></Avatar>
<div>
<span></span>
<strong>{subjectTeacherNames}</strong>
</div>
</div>
</div>
</div>
<div className="student-metric-strip">
{ADMIN_METRIC_META.map((metric) => {
const value =
metric.key === 'all'
? `${attendanceRate}%`
: metric.key === 'absent'
? summary.absent + summary.pending
: (summary[metric.key as keyof AttendanceSummary] ?? 0);
const meta = STATUS_META[metric.key] ?? { className: 'is-present' };
return (
<button
type="button"
key={metric.key}
className={`student-metric-card ${metricFilter === metric.key ? 'active' : ''}`}
onClick={() => onMetricFilterChange(metric.key)}
>
<span className={`student-metric-icon ${meta.className}`}>{metric.short}</span>
<span className="student-metric-copy">
<strong>{value}</strong>
<small>{metric.label}</small>
</span>
</button>
);
})}
</div>
</section>
{alerts.length > 0 && (
<div className="archive-alert">
<WarningFilled />
<div>
<strong>{alerts.length} </strong>
<span> 14 </span>
</div>
<Tooltip
title={alerts
.slice(0, 5)
.map((item) => `${item.studentName}${item.type}${item.count}`)
.join('')}
>
<Button type="link"></Button>
</Tooltip>
</div>
)}
</>
);
};

View File

@@ -0,0 +1,194 @@
import React from 'react';
import {
Alert,
Avatar,
Button,
Drawer,
Form,
Input,
Modal,
Segmented,
Select,
} from 'antd';
import { UndoOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import {
ADMIN_CORRECTION_OPTIONS,
AttendanceStatusTag,
displayAttendanceStatus,
type AdminStudentPanel,
type AttendancePeriodConfigItem,
type AttendanceRecordItem,
} from './AttendanceAdmin.helpers';
export const PeriodConfigModal: React.FC<{
open: boolean;
form: ReturnType<typeof Form.useForm<{ periods: AttendancePeriodConfigItem[] }>>[0];
onOk: () => void;
onCancel: () => void;
onReset: () => void;
}> = ({ open, form, onOk, onCancel, onReset }) => {
return (
<Modal
open={open}
title="考勤时段配置"
okText="保存配置"
width={860}
className="attendance-period-modal"
onOk={onOk}
onCancel={onCancel}
footer={(_, { OkBtn, CancelBtn }) => (
<>
<Button icon={<UndoOutlined />} onClick={onReset}>
</Button>
<CancelBtn />
<OkBtn />
</>
)}
>
<Alert
type="info"
showIcon
title="按课程开始时间自动匹配时段"
description="默认07:30-08:30 早自习09:00-12:00 早课14:00-17:00 晚课18:30-21:00 晚自习。"
style={{ marginBottom: 16 }}
/>
<Form form={form} layout="vertical">
<Form.List name="periods">
{(fields, { add, remove }) => (
<div className="attendance-period-editor">
{fields.map((field) => (
<div className="attendance-period-row" key={field.key}>
<Form.Item name={[field.name, 'label']} label="显示名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="早课" />
</Form.Item>
<Form.Item name={[field.name, 'periodKey']} label="类型标识" rules={[{ required: true, message: '请输入标识' }]}>
<Input placeholder="morning" />
</Form.Item>
<Form.Item name={[field.name, 'startTime']} label="开始时间" rules={[{ required: true, message: '请选择开始时间' }]}>
<Input type="time" />
</Form.Item>
<Form.Item name={[field.name, 'endTime']} label="结束时间" rules={[{ required: true, message: '请选择结束时间' }]}>
<Input type="time" />
</Form.Item>
<Form.Item name={[field.name, 'enabled']} label="状态">
<Select
options={[
{ value: true, label: '启用' },
{ value: false, label: '停用' },
]}
/>
</Form.Item>
<Button danger className="attendance-period-row__delete" onClick={() => remove(field.name)}>
</Button>
</div>
))}
<Button
block
type="dashed"
className="attendance-period-add"
onClick={() =>
add({
periodKey: '',
label: '',
startTime: '00:00',
endTime: '00:30',
enabled: true,
})
}
>
</Button>
</div>
)}
</Form.List>
</Form>
</Modal>
);
};
export const StudentDetailDrawer: React.FC<{
student: AdminStudentPanel | null;
isMobile: boolean;
canEdit: boolean;
sessionMap: Record<string, string>;
correctingRecordId: number | null;
sortAttendanceRecords: (items: readonly AttendanceRecordItem[]) => AttendanceRecordItem[];
onClose: () => void;
onUpdateAdminRecordStatus: (record: AttendanceRecordItem, nextStatus: string) => void;
}> = ({
student,
isMobile,
canEdit,
sessionMap,
correctingRecordId,
sortAttendanceRecords,
onClose,
onUpdateAdminRecordStatus,
}) => {
return (
<Drawer
open={Boolean(student)}
onClose={onClose}
size={isMobile ? '100%' : 520}
title="学生考勤明细"
className="student-detail-drawer"
>
{student && (
<div className="student-detail-panel">
<section className="student-detail-profile">
<Avatar size={54}>{student.studentName.slice(0, 1)}</Avatar>
<div>
<h4>{student.studentName}</h4>
<p>
{student.className}
{student.studentNo ? ` · 学号 ${student.studentNo}` : ''}
</p>
</div>
<div className="student-detail-rate">
<strong>{student.rate}%</strong>
<span></span>
</div>
</section>
<section className="student-detail-rates">
{sortAttendanceRecords(student.records).map((record) => {
const normal = record.status === 'present';
return (
<div key={record.id}>
<strong>{normal ? '100%' : '0%'}</strong>
<span>{sessionMap[record.session] || record.session}</span>
</div>
);
})}
</section>
<section className="student-detail-section">
<h5></h5>
<div className="student-detail-timeline">
{sortAttendanceRecords(student.records).map((record) => (
<div key={record.id}>
<span>{sessionMap[record.session] || record.session}</span>
<AttendanceStatusTag status={displayAttendanceStatus(record.status)} />
<strong>
{record.punchTime ? dayjs(record.punchTime).format('HH:mm:ss') : '未记录'}
</strong>
{canEdit && (
<Segmented
size="small"
className="attendance-correction-segment"
options={ADMIN_CORRECTION_OPTIONS}
value={displayAttendanceStatus(record.status)}
disabled={correctingRecordId === record.id}
onChange={(value) => void onUpdateAdminRecordStatus(record, String(value))}
/>
)}
</div>
))}
</div>
</section>
</div>
)}
</Drawer>
);
};

View File

@@ -0,0 +1,164 @@
import React from 'react';
import { Card, Empty, Input, Spin, Table } from 'antd';
import { ExportOutlined } from '@ant-design/icons';
import PermissionButton from '../../components/PermissionButton';
import {
ADMIN_METRIC_META,
STATUS_META,
displayAttendanceStatus,
type AdminStudentPanel,
type AttendanceRecordItem,
} from './AttendanceAdmin.helpers';
export const AttendanceAdminWorkspace: React.FC<{
metricFilter: string;
studentSearch: string;
onSearchChange: (value: string) => void;
onExport: () => void;
visibleStudents: AdminStudentPanel[];
loading: boolean;
selectedStudentId?: number;
onSelectStudent: (student: AdminStudentPanel) => void;
sortAttendanceRecords: (items: readonly AttendanceRecordItem[]) => AttendanceRecordItem[];
sessionMap: Record<string, string>;
records: AttendanceRecordItem[];
columns: any[];
page: number;
pageSize: number;
total: number;
onPageChange: (page: number, pageSize: number) => void;
}> = ({
metricFilter,
studentSearch,
onSearchChange,
onExport,
visibleStudents,
loading,
selectedStudentId,
onSelectStudent,
sortAttendanceRecords,
sessionMap,
records,
columns,
page,
pageSize,
total,
onPageChange,
}) => {
return (
<>
<section className="student-workspace">
<header className="student-workspace-header">
<div>
<h3></h3>
<span>
{metricFilter === 'all'
? `显示全部 ${visibleStudents.length} 名学生`
: `筛出 ${
ADMIN_METRIC_META.find((item) => item.key === metricFilter)?.label || ''
}相关 ${visibleStudents.length} 名学生`}
</span>
</div>
<div className="student-workspace-tools">
<Input.Search
allowClear
placeholder="搜索姓名或学号"
value={studentSearch}
onChange={(event) => onSearchChange(event.target.value)}
/>
<PermissionButton
permission="attendance:export"
icon={<ExportOutlined />}
onClick={onExport}
>
</PermissionButton>
</div>
</header>
<div className="student-legend">
<span>
<i className="is-present" />
</span>
<span>
<i className="is-leave" />
</span>
<span>
<i className="is-absent" />
</span>
<em>/</em>
</div>
<Spin spinning={loading}>
{visibleStudents.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="未找到匹配学生,请调整筛选条件或搜索关键词"
/>
) : (
<div className="student-card-grid">
{visibleStudents.map((student) => (
<button
type="button"
key={student.key}
className={`student-attendance-card ${
selectedStudentId === student.studentId ? 'selected' : ''
}`}
onClick={() => onSelectStudent(student)}
>
<span className="student-attendance-head">
<strong>{student.studentName}</strong>
{student.studentNo && <small> {student.studentNo}</small>}
</span>
<span className="student-status-blocks">
{sortAttendanceRecords(student.records).map((record) => {
const currentStatus = displayAttendanceStatus(record.status);
const meta = STATUS_META[currentStatus] ?? STATUS_META.absent;
const sessionLabel = sessionMap[record.session] || record.session;
return (
<span
key={record.id}
className={`student-status-block ${meta.className}`}
title={`${sessionLabel}${meta.label}`}
>
{meta.label === '出勤' ? '正常' : meta.label}
</span>
);
})}
</span>
</button>
))}
</div>
)}
</Spin>
</section>
<Card className="student-record-card" bordered={false} title="原始考勤明细">
<Table<AttendanceRecordItem>
rowKey="id"
columns={columns}
dataSource={records}
loading={loading}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (value) => `${value} 条历史记录`,
onChange: onPageChange,
}}
locale={{
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="当前条件下没有历史考勤记录"
/>
),
}}
/>
</Card>
</>
);
};

View File

@@ -11,6 +11,7 @@ import {
} from './attendance-workspace';
import type { LessonAttendanceRecord, LessonAttendanceSchedule } from './types';
import { usePermission } from '../../hooks/usePermission';
import { getErrorMessage } from '../../utils/error';
interface LessonAttendanceSession {
id: number;
@@ -90,10 +91,6 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
if (!schedule) return;
let cancelled = false;
setLoadedSchedule(schedule);
setSession(null);
setRecords([]);
setKeyword('');
setFilter('all');
setLoading(true);
const date = dayjs().format('YYYY-MM-DD');
void api
@@ -109,7 +106,7 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
})
.catch((error: unknown) => {
if (cancelled) return;
message.error((error as { message?: string })?.message || '加载本节课考勤失败');
message.error(getErrorMessage(error, '加载本节课考勤失败'));
})
.finally(() => {
if (!cancelled) setLoading(false);
@@ -130,7 +127,7 @@ const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
setRecords((items) =>
items.map((item) => (item.id === record.id ? { ...item, status: previous } : item)),
);
message.error((error as { message?: string })?.message || '更新考勤失败');
message.error(getErrorMessage(error, '更新考勤失败'));
}
}, []);

View File

@@ -0,0 +1,580 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate';
import {
attendanceAlertsSchema,
attendanceClassOptionsSchema,
attendanceRecordsResponseSchema,
attendanceScheduleOptionsSchema,
attendancePeriodsSchema,
attendanceSummarySchema,
dingTalkSyncStatusSchema,
} from '../../api/schemas';
import { Form, Grid } from 'antd';
import dayjs, { type Dayjs } from 'dayjs';
import api from '../../api';
import { message } from '../../ui/app-message';
import { useUserStore } from '../../store/user/userStore';
import type { AttendanceSummary } from './attendance-workspace';
import { getErrorMessage } from '../../utils/error';
import { AttendanceAdminHeader } from './AttendanceAdminHeader';
import { buildAttendanceAdminColumns } from './AttendanceAdminColumns';
import { PeriodConfigModal, StudentDetailDrawer } from './AttendanceAdminModals';
import { AttendanceAdminWorkspace } from './AttendanceAdminWorkspace';
import {
DEFAULT_ATTENDANCE_PERIODS,
EMPTY_SUMMARY,
buildAdminStudentPanels,
displayAttendanceStatus,
formatTeacherNames,
getTeacherDisplayName,
type AdminStudentPanel,
type AlertItem,
type AttendancePeriodConfigItem,
type AttendanceRecordItem,
type ClassOption,
type DingTalkSyncStatus,
type HistoryScheduleOption,
} from './AttendanceAdmin.helpers';
export const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const screens = Grid.useBreakpoint();
const isMobile = !screens.sm;
const [periodModalOpen, setPeriodModalOpen] = useState(false);
const [periodForm] = Form.useForm<{ periods: AttendancePeriodConfigItem[] }>();
const [refreshingDingTalk, setRefreshingDingTalk] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(48);
const [classId, setClassId] = useState<number>();
const [attendanceDate, setAttendanceDate] = useState<Dayjs | null>(dayjs());
const [status, setStatus] = useState<string>();
const [session, setSession] = useState<string>();
const [scheduleId, setScheduleId] = useState<number>();
const [metricFilter, setMetricFilter] = useState('all');
const [studentSearch, setStudentSearch] = useState('');
const [selectedStudent, setSelectedStudent] = useState<AdminStudentPanel | null>(null);
const [correctingRecordId, setCorrectingRecordId] = useState<number | null>(null);
const queryClient = useQueryClient();
const recordQueryKey = [
'attendance',
'records',
page,
pageSize,
classId,
attendanceDate,
status,
session,
scheduleId,
] as const;
const { data: classOptions = [] } = useQuery<ClassOption[]>({
queryKey: ['attendance', 'meta', 'classes'],
queryFn: async () => {
try {
return validateResponse<ClassOption[]>(
attendanceClassOptionsSchema,
await api.get<ClassOption[]>('/attendance-records/classes'),
);
} catch {
return [];
}
},
});
const { data: alerts = [] } = useQuery<AlertItem[]>({
queryKey: ['attendance', 'meta', 'alerts'],
queryFn: async () => {
try {
return validateResponse<AlertItem[]>(
attendanceAlertsSchema,
await api.get<AlertItem[]>('/attendance-records/alerts'),
);
} catch {
return [];
}
},
});
const { data: periods = DEFAULT_ATTENDANCE_PERIODS } = useQuery<AttendancePeriodConfigItem[]>({
queryKey: ['attendance', 'meta', 'periods'],
queryFn: async () => {
try {
return validateResponse<AttendancePeriodConfigItem[]>(
attendancePeriodsSchema,
await api.get<AttendancePeriodConfigItem[]>('/attendance-period-configs'),
);
} catch {
return DEFAULT_ATTENDANCE_PERIODS;
}
},
});
const {
data: scheduleOptions = [],
isFetching: scheduleOptionsFetching,
} = useQuery<HistoryScheduleOption[]>({
queryKey: ['attendance', 'schedules', classId, attendanceDate],
enabled: !!classId && !!attendanceDate,
queryFn: async () => {
try {
return validateResponse<HistoryScheduleOption[]>(
attendanceScheduleOptionsSchema,
await api.get<HistoryScheduleOption[]>('/attendance-records/schedules', {
params: { classId, date: attendanceDate!.format('YYYY-MM-DD') },
}),
);
} catch (error: unknown) {
message.error(getErrorMessage(error, '加载班级科目失败'));
return [];
}
},
});
const scheduleOptionsLoading = scheduleOptionsFetching;
const effectiveScheduleId =
scheduleId && scheduleOptions.some((schedule) => schedule.id === scheduleId)
? scheduleId
: undefined;
useEffect(() => {
periodForm.setFieldsValue({ periods });
}, [periods, periodForm]);
const enabledPeriods = useMemo(
() => periods.filter((period) => period.enabled).sort((a, b) => a.sortOrder - b.sortOrder),
[periods],
);
const sessionOptions = useMemo(
() => enabledPeriods.map((period) => ({ value: period.periodKey, label: period.label })),
[enabledPeriods],
);
const sessionMap = useMemo(
() => Object.fromEntries(periods.map((period) => [period.periodKey, period.label])),
[periods],
);
const periodOrder = useMemo(
() => new Map(periods.map((period, index) => [period.periodKey, index])),
[periods],
);
const sortAttendanceRecords = useCallback(
(items: readonly AttendanceRecordItem[]) =>
[...items].sort((a, b) => {
const periodDiff =
(periodOrder.get(a.session) ?? Number.MAX_SAFE_INTEGER) -
(periodOrder.get(b.session) ?? Number.MAX_SAFE_INTEGER);
if (periodDiff !== 0) return periodDiff;
return (a.punchTime || a.createdAt || '').localeCompare(b.punchTime || b.createdAt || '');
}),
[periodOrder],
);
const openPeriodConfig = () => {
periodForm.setFieldsValue({ periods });
setPeriodModalOpen(true);
};
const savePeriodConfig = async () => {
try {
const values = await periodForm.validateFields();
const payload = {
periods: values.periods.map((period, index) => ({
...period,
sortOrder: index + 1,
enabled: period.enabled ?? true,
})),
};
const data = await savePeriodConfigMutation.mutateAsync(payload);
setPeriodModalOpen(false);
message.success('考勤时段配置已保存');
if (session && !data.some((period) => period.enabled && period.periodKey === session)) {
setSession(undefined);
}
} catch {
// 校验错误静默,接口错误由 useApiMutation 统一提示
}
};
const resetPeriodConfig = async () => {
try {
await resetPeriodConfigMutation.mutateAsync();
message.success('已恢复默认考勤时段');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const buildParams = useCallback(
(withPagination = true) => {
const params: Record<string, string | number> = {};
if (withPagination) Object.assign(params, { page, pageSize });
if (classId) params.classId = classId;
if (attendanceDate) {
const selectedDate = attendanceDate.format('YYYY-MM-DD');
params.dateFrom = selectedDate;
params.dateTo = selectedDate;
}
if (status) params.status = status;
if (session) params.session = session;
if (effectiveScheduleId) params.scheduleId = effectiveScheduleId;
return params;
},
[page, pageSize, classId, attendanceDate, status, session, scheduleId],
);
const { data: syncStatus = null, refetch: refetchSyncStatus } = useQuery<DingTalkSyncStatus | null>({
queryKey: ['attendance', 'sync-status'],
queryFn: async () => {
try {
return validateResponse<DingTalkSyncStatus>(
dingTalkSyncStatusSchema,
await api.get<DingTalkSyncStatus>('/attendance-records/dingtalk-sync-status'),
);
} catch {
return null;
}
},
});
const loadSyncStatus = useCallback(() => refetchSyncStatus(), [refetchSyncStatus]);
const {
data: recordQuery = { records: [], total: 0, summary: EMPTY_SUMMARY },
isFetching: recordsFetching,
refetch: refetchRecords,
} = useQuery<{
records: AttendanceRecordItem[];
total: number;
summary: AttendanceSummary;
}>({
queryKey: recordQueryKey,
queryFn: async () => {
try {
const [recordData, summaryData] = await Promise.all([
api.get<{ list: AttendanceRecordItem[]; total: number }>('/attendance-records', {
params: buildParams(true),
}),
api.get<AttendanceSummary>('/attendance-records/summary', {
params: buildParams(false),
}),
]);
const validatedRecords = validateResponse<{
list: AttendanceRecordItem[];
total: number;
}>(attendanceRecordsResponseSchema, recordData);
const validatedSummary = validateResponse<AttendanceSummary>(
attendanceSummarySchema,
summaryData,
);
return {
records: validatedRecords.list,
total: validatedRecords.total,
summary: { ...EMPTY_SUMMARY, ...validatedSummary },
};
} catch (error: unknown) {
message.error(getErrorMessage(error, '加载学生考勤失败'));
return { records: [], total: 0, summary: EMPTY_SUMMARY };
}
},
});
const records = recordQuery.records;
const total = recordQuery.total;
const summary = recordQuery.summary;
const loading = recordsFetching;
const loadRecords = useCallback(() => refetchRecords(), [refetchRecords]);
const savePeriodConfigMutation = useApiMutation(
async (payload: Record<string, unknown>) =>
api.put<AttendancePeriodConfigItem[]>('/attendance-period-configs', payload),
{ invalidate: [['attendance', 'meta', 'periods']] },
);
const resetPeriodConfigMutation = useApiMutation(
async () => api.post<AttendancePeriodConfigItem[]>('/attendance-period-configs/reset'),
{ invalidate: [['attendance', 'meta', 'periods']] },
);
const refreshDingTalkMutation = useApiMutation(
async (payload: Record<string, unknown>) =>
api.post<{
refreshed: number;
imported: number;
matched: number;
errors: string[];
}>('/attendance-records/refresh-dingtalk', payload),
{ invalidate: [['attendance', 'records'], ['attendance', 'sync-status']] },
);
const updateStatusMutation = useApiMutation(
async ({ id, status }: { id: number; status: string }) =>
api.put(`/attendance-records/${id}`, { status }),
{ invalidate: [['attendance', 'records']] },
);
const saveRecordCellMutation = useApiMutation(
async ({ id, field, value }: { id: number; field: string; value: unknown }) =>
api.put(`/attendance-records/${id}`, { [field]: value }),
{ invalidate: [['attendance', 'records']] },
);
const refreshDingTalkAttendance = useCallback(async () => {
if (!attendanceDate) {
message.warning('请先选择日期');
return;
}
if (attendanceDate.isAfter(dayjs(), 'day')) {
message.warning('不能查看或刷新未来日期的考勤');
return;
}
setRefreshingDingTalk(true);
try {
const result = await refreshDingTalkMutation.mutateAsync({
date: attendanceDate.format('YYYY-MM-DD'),
classId,
session,
});
if (result.errors.length > 0) {
message.warning(result.errors[0]);
} else {
message.success(`已刷新 ${result.refreshed} 节课程考勤`);
}
await Promise.all([loadRecords(), loadSyncStatus()]);
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setRefreshingDingTalk(false);
}
}, [attendanceDate, classId, loadRecords, loadSyncStatus, session]);
const resetFilters = () => {
setClassId(undefined);
setAttendanceDate(dayjs());
setStatus(undefined);
setSession(undefined);
setScheduleId(undefined);
setMetricFilter('all');
setStudentSearch('');
setPage(1);
};
const handleExport = useCallback(() => {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(buildParams(false))) params.set(key, String(value));
const token = useUserStore.getState().token;
fetch(`/api/attendance-records/export?${params.toString()}`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((response) => {
if (!response.ok) throw new Error('导出失败');
return response.blob();
})
.then((blob) => {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `学生考勤-${dayjs().format('YYYYMMDD')}.xlsx`;
anchor.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('导出失败'));
}, [buildParams]);
const patchRecordStatus = (recordId: number, nextStatus: string) => {
queryClient.setQueryData<{
records: AttendanceRecordItem[];
total: number;
summary: AttendanceSummary;
}>(recordQueryKey, (prev) =>
prev
? {
...prev,
records: prev.records.map((item) =>
item.id === recordId ? { ...item, status: nextStatus } : item,
),
}
: prev,
);
setSelectedStudent((student) => {
if (!student) return student;
return {
...student,
records: student.records.map((item) =>
item.id === recordId ? { ...item, status: nextStatus } : item,
),
statusBySession: Object.fromEntries(
Object.entries(student.statusBySession).map(([key, item]) => [
key,
item?.id === recordId ? { ...item, status: nextStatus } : item,
]),
),
};
});
};
const updateAdminRecordStatus = async (record: AttendanceRecordItem, nextStatus: string) => {
const previousStatus = record.status;
const normalizedPrevious = displayAttendanceStatus(previousStatus);
if (normalizedPrevious === nextStatus && previousStatus !== 'pending') return;
patchRecordStatus(record.id, nextStatus);
setCorrectingRecordId(record.id);
try {
await updateStatusMutation.mutateAsync({ id: record.id, status: nextStatus });
message.success('考勤结果已更新');
} catch {
patchRecordStatus(record.id, previousStatus);
} finally {
setCorrectingRecordId(null);
}
};
const saveAdminRecordCell = async (
record: AttendanceRecordItem,
field: 'status' | 'remark',
value: unknown,
) => {
try {
await saveRecordCellMutation.mutateAsync({ id: record.id, field, value });
message.success('考勤记录已保存');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const studentPanels = useMemo(() => buildAdminStudentPanels(records), [records]);
const visibleStudents = useMemo(() => {
const query = studentSearch.trim().toLocaleLowerCase('zh-CN');
return studentPanels.filter((student) => {
const matchesQuery =
!query ||
student.studentName.toLocaleLowerCase('zh-CN').includes(query) ||
student.studentNo.toLocaleLowerCase('zh-CN').includes(query);
const matchesMetric =
metricFilter === 'all' ||
student.records.some((record) => displayAttendanceStatus(record.status) === metricFilter);
return matchesQuery && matchesMetric;
});
}, [metricFilter, studentPanels, studentSearch]);
const selectedClassOption = classId
? classOptions.find((item) => item.classId === classId)
: undefined;
const selectedClass =
selectedClassOption?.className || (classId ? `班级 ${classId}` : '全部班级');
const overviewTeachers = selectedClassOption?.teachers ?? [];
const headTeacherNames = classId
? formatTeacherNames(overviewTeachers.filter((teacher) => teacher.roleType === 'head_teacher'))
: '请选择班级';
const lifeTeacherNames = classId
? formatTeacherNames(overviewTeachers.filter((teacher) => teacher.roleType === 'life_teacher'))
: '请选择班级';
const currentSchedule = scheduleId
? scheduleOptions.find((item) => item.id === scheduleId)
: undefined;
const subjectTeacherNames = currentSchedule
? getTeacherDisplayName({
name: currentSchedule.teacherName,
username: currentSchedule.teacherUsername,
})
: classId
? formatTeacherNames(
overviewTeachers.filter((teacher) => teacher.roleType === 'subject_teacher'),
)
: '请选择班级';
const attendanceRate =
summary.total > 0 ? Math.round((summary.present / summary.total) * 100) : 0;
const dateLabel = attendanceDate?.format('YYYY-MM-DD') || '未选择日期';
const columns = buildAttendanceAdminColumns({
isMobile,
canEdit,
sessionMap,
correctingRecordId,
onSaveAdminRecordCell: saveAdminRecordCell,
onUpdateAdminRecordStatus: updateAdminRecordStatus,
});
return (
<div className="attendance-page admin-attendance student-attendance-center">
<AttendanceAdminHeader
syncStatus={syncStatus}
canEdit={canEdit}
refreshingDingTalk={refreshingDingTalk}
onOpenPeriodConfig={openPeriodConfig}
onRefreshDingTalk={() => void refreshDingTalkAttendance()}
onExport={handleExport}
attendanceDate={attendanceDate}
onDateChange={(value) => {
setAttendanceDate(value);
setScheduleId(undefined);
setPage(1);
}}
classId={classId}
onClassChange={(value) => {
setClassId(value);
setScheduleId(undefined);
setPage(1);
}}
classOptions={classOptions}
effectiveScheduleId={effectiveScheduleId}
onScheduleChange={(value) => {
setScheduleId(value);
setPage(1);
}}
scheduleOptions={scheduleOptions}
scheduleOptionsLoading={scheduleOptionsLoading}
session={session}
onSessionChange={(value) => {
setSession(value);
setPage(1);
}}
sessionOptions={sessionOptions}
onReset={resetFilters}
onQuery={() => void loadRecords()}
selectedClass={selectedClass}
dateLabel={dateLabel}
visibleStudentCount={visibleStudents.length}
total={total}
headTeacherNames={headTeacherNames}
lifeTeacherNames={lifeTeacherNames}
subjectTeacherNames={subjectTeacherNames}
attendanceRate={attendanceRate}
summary={summary}
metricFilter={metricFilter}
onMetricFilterChange={setMetricFilter}
alerts={alerts}
/>
<AttendanceAdminWorkspace
metricFilter={metricFilter}
studentSearch={studentSearch}
onSearchChange={setStudentSearch}
onExport={handleExport}
visibleStudents={visibleStudents}
loading={loading}
selectedStudentId={selectedStudent?.studentId}
onSelectStudent={setSelectedStudent}
sortAttendanceRecords={sortAttendanceRecords}
sessionMap={sessionMap}
records={records}
columns={columns}
page={page}
pageSize={pageSize}
total={total}
onPageChange={(nextPage, nextPageSize) => {
setPage(nextPage);
setPageSize(nextPageSize);
}}
/>
<StudentDetailDrawer
student={selectedStudent}
isMobile={isMobile}
canEdit={canEdit}
sessionMap={sessionMap}
correctingRecordId={correctingRecordId}
sortAttendanceRecords={sortAttendanceRecords}
onClose={() => setSelectedStudent(null)}
onUpdateAdminRecordStatus={updateAdminRecordStatus}
/>
<PeriodConfigModal
open={periodModalOpen}
form={periodForm}
onOk={savePeriodConfig}
onCancel={() => setPeriodModalOpen(false)}
onReset={() => void resetPeriodConfig()}
/>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,218 @@
import React, { useCallback, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Button, Card, Col, Empty, Row, Spin, Tag, Tooltip } from 'antd';
import {
ArrowRightOutlined,
CheckCircleFilled,
ClockCircleOutlined,
ReloadOutlined,
ScheduleOutlined,
TeamOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import { message } from '../../ui/app-message';
import { getErrorMessage } from '../../utils/error';
import {
canPullAttendance,
getSchedulePhase,
type SchedulePhase,
} from './attendance-workspace';
import LessonAttendanceDetail from './LessonAttendanceDetail';
import type { LessonAttendanceSchedule } from './types';
type TodaySchedule = LessonAttendanceSchedule;
interface AssignedClass {
classId: number;
className: string;
classCode: string;
roleType: string;
subject: string;
}
interface TeacherWorkspaceData {
assignedClasses: AssignedClass[];
todaySchedules: TodaySchedule[];
}
export const TeacherAttendanceWorkspace: React.FC<{ canCreate: boolean }> = ({ canCreate }) => {
const [selectedSchedule, setSelectedSchedule] = useState<TodaySchedule | null>(null);
const {
data: workspace,
isLoading,
isFetching,
refetch,
} = useQuery<TeacherWorkspaceData | null>({
queryKey: ['attendance', 'workspace'],
queryFn: async () => {
try {
return await api.get<TeacherWorkspaceData>('/rbac/teacher-workspace');
} catch (error: unknown) {
message.error(getErrorMessage(error, '加载今日课程失败'));
return null;
}
},
});
const loading = isLoading || isFetching;
const loadWorkspace = useCallback(() => refetch(), [refetch]);
const classNameById = useMemo(
() => new Map(workspace?.assignedClasses.map((item) => [item.classId, item.className]) ?? []),
[workspace],
);
const openAttendance = useCallback((schedule: TodaySchedule) => {
setSelectedSchedule(schedule);
}, []);
const now = new Date();
const schedules = workspace?.todaySchedules ?? [];
const startedCount = schedules.filter((item) =>
canPullAttendance(getSchedulePhase(item.startTime, item.endTime, now)),
).length;
const nextSchedule = schedules.find(
(item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended',
);
return (
<div className="attendance-page teacher-attendance">
<section className="attendance-hero attendance-hero--teacher">
<div>
<span className="attendance-eyebrow">
TEACHING DAY · {dayjs().format('MM月DD日 dddd')}
</span>
<h1></h1>
<p></p>
</div>
<Button icon={<ReloadOutlined />} onClick={() => void loadWorkspace()}>
</Button>
</section>
<Row gutter={[16, 16]} className="teacher-overview">
<Col xs={24} md={8}>
<div className="teacher-kpi">
<span></span>
<strong>{schedules.length}</strong>
<small></small>
</div>
</Col>
<Col xs={24} md={8}>
<div className="teacher-kpi">
<span></span>
<strong>{startedCount}</strong>
<small></small>
</div>
</Col>
<Col xs={24} md={8}>
<div className="teacher-kpi teacher-kpi--next">
<span></span>
<strong>{nextSchedule ? nextSchedule.startTime : '—'}</strong>
<small>{nextSchedule?.subject || '今天没有更多课程'}</small>
</div>
</Col>
</Row>
<div className="attendance-section-heading">
<div>
<span></span>
<h2></h2>
</div>
<span className="attendance-section-note"></span>
</div>
<Spin spinning={loading}>
{schedules.length === 0 ? (
<Card className="attendance-empty-card">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<div>
<strong></strong>
<p></p>
</div>
}
/>
</Card>
) : (
<div className="lesson-timeline">
{schedules.map((schedule, index) => {
const phase = getSchedulePhase(schedule.startTime, schedule.endTime, now);
return (
<LessonCard
key={schedule.id}
schedule={schedule}
phase={phase}
className={classNameById.get(schedule.classId) || `班级 ${schedule.classId}`}
index={index + 1}
onOpen={canCreate ? () => openAttendance(schedule) : undefined}
/>
);
})}
</div>
)}
</Spin>
{canCreate ? (
<LessonAttendanceDetail
key={selectedSchedule?.id ?? 'closed'}
schedule={selectedSchedule}
className={
selectedSchedule
? classNameById.get(selectedSchedule.classId) || `班级 ${selectedSchedule.classId}`
: ''
}
onClose={() => setSelectedSchedule(null)}
/>
) : null}
</div>
);
};
export const LessonCard: React.FC<{
schedule: TodaySchedule;
phase: SchedulePhase;
className: string;
index: number;
onOpen?: () => void;
}> = ({ schedule, phase, className, index, onOpen }) => {
const phaseMeta = {
upcoming: { label: '待上课', icon: <ClockCircleOutlined />, tone: 'upcoming' },
ongoing: { label: '进行中', icon: <ScheduleOutlined />, tone: 'ongoing' },
ended: { label: '已结束', icon: <CheckCircleFilled />, tone: 'ended' },
}[phase];
return (
<article className={`lesson-card lesson-card--${phaseMeta.tone}`}>
<div className="lesson-sequence">{String(index).padStart(2, '0')}</div>
<div className="lesson-time">
<strong>{schedule.startTime}</strong>
<span />
<strong>{schedule.endTime}</strong>
</div>
<div className="lesson-main">
<div className="lesson-title-row">
<h3>{schedule.subject}</h3>
<Tag icon={phaseMeta.icon}>{phaseMeta.label}</Tag>
</div>
<p>
<TeamOutlined /> {className}
<span> {schedule.classroomId}</span>
</p>
</div>
<div className="lesson-action">
{phase === 'upcoming' ? (
<Tooltip title="课程尚未开始">
<Button disabled></Button>
</Tooltip>
) : onOpen ? (
<Button type="primary" onClick={onOpen}>
{phase === 'ongoing' ? '查看当前考勤' : '拉取 / 查看考勤'} <ArrowRightOutlined />
</Button>
) : null}
</div>
</article>
);
};

View File

@@ -1,4 +1,8 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../hooks/useApiMutation';
import { validateResponse } from '../utils/validate';
import { attendanceDevicesSchema, classroomOptionsSchema } from '../api/schemas';
import { Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { PlusOutlined } from '@ant-design/icons';
@@ -30,34 +34,57 @@ const statusMeta = {
} as const;
const AttendanceDevicesPage: React.FC = () => {
const [data, setData] = useState<AttendanceDeviceRow[]>([]);
const [classrooms, setClassrooms] = useState<ClassroomOption[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<AttendanceDeviceRow | null>(null);
const [saving, setSaving] = useState(false);
const [keyword, setKeyword] = useState('');
const [form] = Form.useForm();
const loadData = async () => {
setLoading(true);
try {
const [devices, classroomList] = await Promise.all([
api.get<AttendanceDeviceRow[]>('/attendance-devices'),
api.get<ClassroomOption[]>('/classrooms'),
]);
setData(devices);
setClassrooms(classroomList.filter((item: any) => item.status !== 'archived'));
} catch (error: any) {
message.error(error?.message || '加载考勤机绑定失败');
} finally {
setLoading(false);
}
};
const {
data: fetchResult = { devices: [], classrooms: [] },
isLoading,
isFetching,
} = useQuery<{ devices: AttendanceDeviceRow[]; classrooms: ClassroomOption[] }>({
queryKey: ['attendance-devices'],
queryFn: async () => {
try {
const [devices, classroomList] = await Promise.all([
api.get<AttendanceDeviceRow[]>('/attendance-devices'),
api.get<ClassroomOption[]>('/classrooms'),
]);
return {
devices: validateResponse<AttendanceDeviceRow[]>(attendanceDevicesSchema, devices),
classrooms: validateResponse<ClassroomOption[]>(
classroomOptionsSchema,
classroomList,
).filter((item: any) => item.status !== 'archived'),
};
} catch (error: any) {
message.error(error?.message || '加载考勤机绑定失败');
return { devices: [], classrooms: [] };
}
},
});
const data = fetchResult.devices;
const classrooms = fetchResult.classrooms;
const loading = isLoading || isFetching;
useEffect(() => {
void loadData();
}, []);
const saveMutation = useApiMutation(
async (values: Record<string, unknown>) =>
editing
? api.put(`/attendance-devices/${editing.id}`, values)
: api.post('/attendance-devices', values),
{ invalidate: [['attendance-devices']] },
);
const saveCellMutation = useApiMutation(
async ({ record, field, value }: { record: AttendanceDeviceRow; field: string; value: unknown }) =>
api.put(`/attendance-devices/${record.id}`, { [field]: value }),
{ invalidate: [['attendance-devices']] },
);
const deleteMutation = useApiMutation(
async (id: number) => api.delete(`/attendance-devices/${id}`),
{ invalidate: [['attendance-devices']] },
);
const classroomOptions = useMemo(
() =>
@@ -102,37 +129,33 @@ const AttendanceDevicesPage: React.FC = () => {
const values = await form.validateFields();
setSaving(true);
try {
if (editing) {
await api.put(`/attendance-devices/${editing.id}`, values);
message.success('考勤机绑定已更新');
} else {
await api.post('/attendance-devices', values);
message.success('考勤机绑定已创建');
}
await saveMutation.mutateAsync(values);
message.success(editing ? '考勤机绑定已更新' : '考勤机绑定已创建');
setModalOpen(false);
setEditing(null);
form.resetFields();
await loadData();
} catch (error: any) {
message.error(error?.message || '保存失败');
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
};
const saveCell = async (record: AttendanceDeviceRow, field: string, value: unknown) => {
await api.put(`/attendance-devices/${record.id}`, { [field]: value });
message.success('已保存');
await loadData();
try {
await saveCellMutation.mutateAsync({ record, field, value });
message.success('已保存');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handleDelete = async (id: number) => {
try {
await api.delete(`/attendance-devices/${id}`);
await deleteMutation.mutateAsync(id);
message.success('已停用绑定');
await loadData();
} catch (error: any) {
message.error(error?.message || '停用失败');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};