forked from wangziqi/gongxue-base
fix: harden permission-gated UI — minimum-org endpoint, modal/Popconfirm fail-closed on revocation
1402 lines
46 KiB
TypeScript
1402 lines
46 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
Alert,
|
||
Avatar,
|
||
Button,
|
||
Card,
|
||
Col,
|
||
DatePicker,
|
||
Drawer,
|
||
Grid,
|
||
Empty,
|
||
Form,
|
||
Input,
|
||
Modal,
|
||
Segmented,
|
||
Row,
|
||
Select,
|
||
Spin,
|
||
Table,
|
||
Tag,
|
||
Tooltip,
|
||
} from 'antd';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import {
|
||
ArrowRightOutlined,
|
||
CheckCircleFilled,
|
||
ClockCircleOutlined,
|
||
ExportOutlined,
|
||
FileSearchOutlined,
|
||
ReloadOutlined,
|
||
ScheduleOutlined,
|
||
TeamOutlined,
|
||
WarningFilled,
|
||
} from '@ant-design/icons';
|
||
import dayjs, { type Dayjs } from 'dayjs';
|
||
import api from '../../api';
|
||
import PermissionButton from '../../components/PermissionButton';
|
||
import EditableCell from '../../components/EditableCell';
|
||
import { usePermission } from '../../hooks/usePermission';
|
||
import { message } from '../../ui/app-message';
|
||
import {
|
||
canPullAttendance,
|
||
getAttendanceExperience,
|
||
getSchedulePhase,
|
||
getPunchDisplayInfo,
|
||
type AttendanceSummary,
|
||
type SchedulePhase,
|
||
} from './attendance-workspace';
|
||
import LessonAttendanceDetail from './LessonAttendanceDetail';
|
||
import type { LessonAttendanceRecord, LessonAttendanceSchedule } from './types';
|
||
import './attendance.css';
|
||
|
||
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,
|
||
},
|
||
];
|
||
|
||
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: '待' },
|
||
};
|
||
|
||
const ADMIN_CORRECTION_OPTIONS = [
|
||
{ value: 'present', label: '正常' },
|
||
{ value: 'leave', label: '请假' },
|
||
{ value: 'absent', label: '缺勤' },
|
||
];
|
||
|
||
interface ClassTeacherOption {
|
||
userId: number;
|
||
username: string | null;
|
||
name: string | null;
|
||
roleType: string;
|
||
subject: string | null;
|
||
}
|
||
|
||
interface ClassOption {
|
||
classId: number;
|
||
className: string;
|
||
teachers?: ClassTeacherOption[];
|
||
}
|
||
|
||
type AttendanceRecordItem = LessonAttendanceRecord;
|
||
|
||
interface AssignedClass {
|
||
classId: number;
|
||
className: string;
|
||
classCode: string;
|
||
roleType: string;
|
||
subject: string;
|
||
}
|
||
|
||
type TodaySchedule = LessonAttendanceSchedule;
|
||
|
||
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;
|
||
}
|
||
|
||
interface TeacherWorkspaceData {
|
||
assignedClasses: AssignedClass[];
|
||
todaySchedules: TodaySchedule[];
|
||
}
|
||
|
||
interface AlertItem {
|
||
studentId: number;
|
||
studentName: string;
|
||
studentNo: string;
|
||
className: string;
|
||
type: string;
|
||
count: number;
|
||
lastDate: string;
|
||
}
|
||
|
||
interface AttendancePeriodConfigItem {
|
||
id?: number;
|
||
periodKey: string;
|
||
label: string;
|
||
startTime: string;
|
||
endTime: string;
|
||
sortOrder: number;
|
||
enabled: boolean;
|
||
}
|
||
|
||
interface DingTalkSyncStatus {
|
||
lastPulledAt: string | null;
|
||
action: string | null;
|
||
username: string | null;
|
||
detail: string | null;
|
||
}
|
||
|
||
const EMPTY_SUMMARY: AttendanceSummary = {
|
||
total: 0,
|
||
present: 0,
|
||
late: 0,
|
||
absent: 0,
|
||
leave: 0,
|
||
pending: 0,
|
||
};
|
||
|
||
function readCurrentRoles(): string[] {
|
||
try {
|
||
const user = JSON.parse(localStorage.getItem('user') || '{}') as { roles?: string[] };
|
||
return Array.isArray(user.roles) ? user.roles : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function displayAttendanceStatus(status?: string | null): string {
|
||
return status === 'pending' || !status ? 'absent' : status;
|
||
}
|
||
|
||
function getTeacherDisplayName(teacher?: {
|
||
name?: string | null;
|
||
username?: string | null;
|
||
}): string {
|
||
const name = teacher?.name?.trim();
|
||
if (name) return name;
|
||
return teacher?.username?.trim() || '未设置';
|
||
}
|
||
|
||
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('、') : '未设置';
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
const AttendancePage: React.FC = () => {
|
||
const { permissions, hasPermission } = usePermission();
|
||
const roles = useMemo(readCurrentRoles, []);
|
||
const experience = getAttendanceExperience(permissions, roles);
|
||
|
||
if (experience === 'teacher') {
|
||
return <TeacherAttendanceWorkspace canCreate={hasPermission('attendance:create')} />;
|
||
}
|
||
|
||
return <AdminAttendanceArchive canEdit={hasPermission('attendance:edit')} />;
|
||
};
|
||
|
||
const TeacherAttendanceWorkspace: React.FC<{ canCreate: boolean }> = ({ canCreate }) => {
|
||
const [loading, setLoading] = useState(true);
|
||
const [workspace, setWorkspace] = useState<TeacherWorkspaceData | null>(null);
|
||
const [selectedSchedule, setSelectedSchedule] = useState<TodaySchedule | null>(null);
|
||
|
||
const loadWorkspace = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
setWorkspace(await api.get<TeacherWorkspaceData>('/rbac/teacher-workspace'));
|
||
} catch (error: unknown) {
|
||
message.error((error as { message?: string })?.message || '加载今日课程失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void loadWorkspace();
|
||
}, [loadWorkspace]);
|
||
|
||
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
|
||
schedule={selectedSchedule}
|
||
className={
|
||
selectedSchedule
|
||
? classNameById.get(selectedSchedule.classId) || `班级 ${selectedSchedule.classId}`
|
||
: ''
|
||
}
|
||
onClose={() => setSelectedSchedule(null)}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
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>
|
||
);
|
||
};
|
||
|
||
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;
|
||
}
|
||
|
||
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'
|
||
);
|
||
}
|
||
|
||
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,
|
||
};
|
||
});
|
||
}
|
||
|
||
const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
|
||
const screens = Grid.useBreakpoint();
|
||
const isMobile = !screens.sm;
|
||
const [records, setRecords] = useState<AttendanceRecordItem[]>([]);
|
||
const [summary, setSummary] = useState<AttendanceSummary>(EMPTY_SUMMARY);
|
||
const [alerts, setAlerts] = useState<AlertItem[]>([]);
|
||
const [classOptions, setClassOptions] = useState<ClassOption[]>([]);
|
||
const [scheduleOptions, setScheduleOptions] = useState<HistoryScheduleOption[]>([]);
|
||
const [scheduleOptionsLoading, setScheduleOptionsLoading] = useState(false);
|
||
const [periods, setPeriods] = useState<AttendancePeriodConfigItem[]>(DEFAULT_ATTENDANCE_PERIODS);
|
||
const [periodModalOpen, setPeriodModalOpen] = useState(false);
|
||
const [periodForm] = Form.useForm<{ periods: AttendancePeriodConfigItem[] }>();
|
||
const [loading, setLoading] = useState(true);
|
||
const [refreshingDingTalk, setRefreshingDingTalk] = useState(false);
|
||
const [syncStatus, setSyncStatus] = useState<DingTalkSyncStatus | null>(null);
|
||
const [page, setPage] = useState(1);
|
||
const [pageSize, setPageSize] = useState(48);
|
||
const [total, setTotal] = useState(0);
|
||
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 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 subjectScheduleOptions = useMemo(
|
||
() =>
|
||
scheduleOptions.map((schedule) => ({
|
||
value: schedule.id,
|
||
label: `${schedule.subject}(${schedule.startTime}-${schedule.endTime})`,
|
||
})),
|
||
[scheduleOptions],
|
||
);
|
||
|
||
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 api.put<AttendancePeriodConfigItem[]>(
|
||
'/attendance-period-configs',
|
||
payload,
|
||
);
|
||
setPeriods(data);
|
||
setPeriodModalOpen(false);
|
||
message.success('考勤时段配置已保存');
|
||
if (session && !data.some((period) => period.enabled && period.periodKey === session)) {
|
||
setSession(undefined);
|
||
}
|
||
} catch (error: unknown) {
|
||
if ((error as { errorFields?: unknown })?.errorFields) return;
|
||
message.error((error as { message?: string })?.message || '保存考勤时段配置失败');
|
||
}
|
||
};
|
||
|
||
const resetPeriodConfig = async () => {
|
||
try {
|
||
const data = await api.post<AttendancePeriodConfigItem[]>('/attendance-period-configs/reset');
|
||
setPeriods(data);
|
||
periodForm.setFieldsValue({ periods: data });
|
||
message.success('已恢复默认考勤时段');
|
||
} catch (error: unknown) {
|
||
message.error((error as { message?: string })?.message || '恢复默认配置失败');
|
||
}
|
||
};
|
||
|
||
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 (scheduleId) params.scheduleId = scheduleId;
|
||
return params;
|
||
},
|
||
[page, pageSize, classId, attendanceDate, status, session, scheduleId],
|
||
);
|
||
|
||
const loadSyncStatus = useCallback(async () => {
|
||
try {
|
||
setSyncStatus(await api.get<DingTalkSyncStatus>('/attendance-records/dingtalk-sync-status'));
|
||
} catch {
|
||
setSyncStatus(null);
|
||
}
|
||
}, []);
|
||
|
||
const loadRecords = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const [recordData, summaryData, statusData] = await Promise.all([
|
||
api.get<{ list: AttendanceRecordItem[]; total: number }>('/attendance-records', {
|
||
params: buildParams(true),
|
||
}),
|
||
api.get<AttendanceSummary>('/attendance-records/summary', {
|
||
params: buildParams(false),
|
||
}),
|
||
api.get<DingTalkSyncStatus>('/attendance-records/dingtalk-sync-status'),
|
||
]);
|
||
setRecords(recordData.list);
|
||
setTotal(recordData.total);
|
||
setSummary({ ...EMPTY_SUMMARY, ...summaryData });
|
||
setSyncStatus(statusData);
|
||
} catch (error: unknown) {
|
||
message.error((error as { message?: string })?.message || '加载学生考勤失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [buildParams]);
|
||
|
||
const refreshDingTalkAttendance = useCallback(async () => {
|
||
if (!attendanceDate) {
|
||
message.warning('请先选择日期');
|
||
return;
|
||
}
|
||
if (attendanceDate.isAfter(dayjs(), 'day')) {
|
||
message.warning('不能查看或刷新未来日期的考勤');
|
||
return;
|
||
}
|
||
setRefreshingDingTalk(true);
|
||
try {
|
||
const result = await api.post<{
|
||
refreshed: number;
|
||
imported: number;
|
||
matched: number;
|
||
errors: string[];
|
||
}>('/attendance-records/refresh-dingtalk', {
|
||
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 (error: unknown) {
|
||
message.error((error as { message?: string })?.message || '刷新钉钉考勤失败');
|
||
} finally {
|
||
setRefreshingDingTalk(false);
|
||
}
|
||
}, [attendanceDate, classId, loadRecords, loadSyncStatus, session]);
|
||
|
||
useEffect(() => {
|
||
void Promise.all([
|
||
api.get<ClassOption[]>('/attendance-records/classes').then(setClassOptions),
|
||
api.get<AlertItem[]>('/attendance-records/alerts').then(setAlerts),
|
||
api.get<AttendancePeriodConfigItem[]>('/attendance-period-configs').then((data) => {
|
||
setPeriods(data);
|
||
periodForm.setFieldsValue({ periods: data });
|
||
}),
|
||
]).catch(() => undefined);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!classId || !attendanceDate) {
|
||
setScheduleOptions([]);
|
||
setScheduleId(undefined);
|
||
return;
|
||
}
|
||
|
||
const selectedDate = attendanceDate.format('YYYY-MM-DD');
|
||
setScheduleOptionsLoading(true);
|
||
void api
|
||
.get<HistoryScheduleOption[]>('/attendance-records/schedules', {
|
||
params: { classId, date: selectedDate },
|
||
})
|
||
.then((options) => {
|
||
setScheduleOptions(options);
|
||
setScheduleId((current) =>
|
||
current && options.some((schedule) => schedule.id === current) ? current : undefined,
|
||
);
|
||
})
|
||
.catch((error: unknown) => {
|
||
setScheduleOptions([]);
|
||
setScheduleId(undefined);
|
||
message.error((error as { message?: string })?.message || '加载班级科目失败');
|
||
})
|
||
.finally(() => setScheduleOptionsLoading(false));
|
||
}, [attendanceDate, classId]);
|
||
|
||
useEffect(() => {
|
||
void loadRecords();
|
||
}, [loadRecords]);
|
||
|
||
const resetFilters = () => {
|
||
setClassId(undefined);
|
||
setAttendanceDate(dayjs());
|
||
setStatus(undefined);
|
||
setSession(undefined);
|
||
setScheduleId(undefined);
|
||
setScheduleOptions([]);
|
||
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 = localStorage.getItem('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) => {
|
||
setRecords((items) =>
|
||
items.map((item) => (item.id === recordId ? { ...item, status: nextStatus } : item)),
|
||
);
|
||
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 api.put(`/attendance-records/${record.id}`, { status: nextStatus });
|
||
message.success('考勤结果已更新');
|
||
void loadRecords();
|
||
} catch (error: unknown) {
|
||
patchRecordStatus(record.id, previousStatus);
|
||
message.error((error as { message?: string })?.message || '更新失败');
|
||
} finally {
|
||
setCorrectingRecordId(null);
|
||
}
|
||
};
|
||
|
||
const saveAdminRecordCell = async (
|
||
record: AttendanceRecordItem,
|
||
field: 'status' | 'remark',
|
||
value: unknown,
|
||
) => {
|
||
await api.put(`/attendance-records/${record.id}`, { [field]: value });
|
||
message.success('考勤记录已保存');
|
||
await loadRecords();
|
||
};
|
||
|
||
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: ColumnsType<AttendanceRecordItem> = [
|
||
{
|
||
title: '学生',
|
||
dataIndex: ['student', 'name'],
|
||
fixed: isMobile ? undefined : 'left',
|
||
width: 150,
|
||
render: (name: string, record) => (
|
||
<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) => (
|
||
<EditableCell
|
||
value={displayAttendanceStatus(value)}
|
||
editor="select"
|
||
options={ADMIN_CORRECTION_OPTIONS.map((item) => ({
|
||
value: String(item.value),
|
||
label: item.label,
|
||
}))}
|
||
disabled={!canEdit}
|
||
onSave={(next) => saveAdminRecordCell(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) => (
|
||
<EditableCell
|
||
value={value}
|
||
editor="textarea"
|
||
disabled={!canEdit}
|
||
onSave={(next) => saveAdminRecordCell(record, 'remark', next)}
|
||
>
|
||
{value || <span className="muted-text">—</span>}
|
||
</EditableCell>
|
||
),
|
||
},
|
||
...(canEdit
|
||
? [
|
||
{
|
||
title: '操作',
|
||
key: 'action',
|
||
fixed: isMobile ? undefined : ('right' as const),
|
||
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 updateAdminRecordStatus(record, String(value))}
|
||
/>
|
||
),
|
||
},
|
||
]
|
||
: []),
|
||
];
|
||
|
||
return (
|
||
<div className="attendance-page admin-attendance student-attendance-center">
|
||
<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={openPeriodConfig}>
|
||
时段配置
|
||
</Button>
|
||
)}
|
||
<PermissionButton
|
||
permission="attendance:create"
|
||
icon={<ReloadOutlined />}
|
||
loading={refreshingDingTalk}
|
||
onClick={() => void refreshDingTalkAttendance()}
|
||
>
|
||
刷新钉钉考勤
|
||
</PermissionButton>
|
||
<PermissionButton
|
||
permission="attendance:export"
|
||
icon={<ExportOutlined />}
|
||
onClick={handleExport}
|
||
>
|
||
导出当前报表
|
||
</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={(value) => {
|
||
setAttendanceDate(value);
|
||
setScheduleId(undefined);
|
||
setPage(1);
|
||
}}
|
||
/>
|
||
</div>
|
||
<div className="student-filter-field">
|
||
<label>班级</label>
|
||
<Select
|
||
allowClear
|
||
showSearch
|
||
optionFilterProp="label"
|
||
placeholder="全部班级"
|
||
value={classId}
|
||
onChange={(value) => {
|
||
setClassId(value);
|
||
setScheduleId(undefined);
|
||
setPage(1);
|
||
}}
|
||
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={scheduleId}
|
||
loading={scheduleOptionsLoading}
|
||
disabled={!classId || !attendanceDate}
|
||
notFoundContent={scheduleOptionsLoading ? <Spin size="small" /> : '当前日期没有排课'}
|
||
onChange={(value) => {
|
||
setScheduleId(value);
|
||
setPage(1);
|
||
}}
|
||
options={subjectScheduleOptions}
|
||
/>
|
||
</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={(value) => {
|
||
setSession(value);
|
||
setPage(1);
|
||
}}
|
||
options={sessionOptions}
|
||
/>
|
||
</div>
|
||
<div className="student-filter-actions">
|
||
<Button onClick={resetFilters}>重置</Button>
|
||
<Button type="primary" icon={<FileSearchOutlined />} onClick={() => void loadRecords()}>
|
||
查询
|
||
</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} · 当前展示 {visibleStudents.length} 名学生 / {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={() => setMetricFilter(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>
|
||
)}
|
||
|
||
<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) => setStudentSearch(event.target.value)}
|
||
/>
|
||
<PermissionButton permission="attendance:export" icon={<ExportOutlined />} onClick={handleExport}>
|
||
导出
|
||
</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 ${selectedStudent?.studentId === student.studentId ? 'selected' : ''}`}
|
||
onClick={() => setSelectedStudent(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: (nextPage, nextPageSize) => {
|
||
setPage(nextPage);
|
||
setPageSize(nextPageSize);
|
||
},
|
||
}}
|
||
locale={{
|
||
emptyText: (
|
||
<Empty
|
||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||
description="当前条件下没有历史考勤记录"
|
||
/>
|
||
),
|
||
}}
|
||
/>
|
||
</Card>
|
||
|
||
<Drawer
|
||
open={Boolean(selectedStudent)}
|
||
onClose={() => setSelectedStudent(null)}
|
||
width={isMobile ? '100%' : 520}
|
||
title="学生考勤明细"
|
||
className="student-detail-drawer"
|
||
>
|
||
{selectedStudent && (
|
||
<div className="student-detail-panel">
|
||
<section className="student-detail-profile">
|
||
<Avatar size={54}>{selectedStudent.studentName.slice(0, 1)}</Avatar>
|
||
<div>
|
||
<h4>{selectedStudent.studentName}</h4>
|
||
<p>
|
||
{selectedStudent.className}
|
||
{selectedStudent.studentNo ? ` · 学号 ${selectedStudent.studentNo}` : ''}
|
||
</p>
|
||
</div>
|
||
<div className="student-detail-rate">
|
||
<strong>{selectedStudent.rate}%</strong>
|
||
<span>累计出勤率</span>
|
||
</div>
|
||
</section>
|
||
<section className="student-detail-rates">
|
||
{sortAttendanceRecords(selectedStudent.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(selectedStudent.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 updateAdminRecordStatus(record, String(value))}
|
||
/>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
)}
|
||
</Drawer>
|
||
|
||
<Modal
|
||
open={periodModalOpen}
|
||
title="考勤时段配置"
|
||
okText="保存配置"
|
||
width={860}
|
||
className="attendance-period-modal"
|
||
onOk={savePeriodConfig}
|
||
onCancel={() => setPeriodModalOpen(false)}
|
||
footer={(_, { OkBtn, CancelBtn }) => (
|
||
<>
|
||
<Button onClick={resetPeriodConfig}>恢复默认</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={periodForm} 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>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default AttendancePage;
|