feat: 支持表格单元格快捷编辑

This commit is contained in:
2026-07-21 14:17:08 +08:00
parent fe0b71a0a9
commit a585fd42d5
17 changed files with 2197 additions and 303 deletions

View File

@@ -36,6 +36,7 @@ import {
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 {
@@ -52,10 +53,38 @@ import {
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 },
{
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<
@@ -319,7 +348,6 @@ const TeacherAttendanceWorkspace: React.FC = () => {
[workspace],
);
const openAttendance = useCallback(async (schedule: TodaySchedule) => {
setStudentKeyword('');
setCheckinFilter('all');
@@ -345,29 +373,25 @@ const TeacherAttendanceWorkspace: React.FC = () => {
}
}, []);
const updateLessonRecord = useCallback(
async (record: AttendanceRecordItem, status: string) => {
const previous = record.status;
const updateLessonRecord = useCallback(async (record: AttendanceRecordItem, status: string) => {
const previous = record.status;
setLessonRecords((items) =>
items.map((item) => (item.id === record.id ? { ...item, status } : item)),
);
try {
await api.put(`/attendance-records/${record.id}`, { status });
} catch (error: unknown) {
setLessonRecords((items) =>
items.map((item) => (item.id === record.id ? { ...item, status } : item)),
items.map((item) => (item.id === record.id ? { ...item, status: previous } : item)),
);
try {
await api.put(`/attendance-records/${record.id}`, { status });
} catch (error: unknown) {
setLessonRecords((items) =>
items.map((item) => (item.id === record.id ? { ...item, status: previous } : item)),
);
message.error((error as { message?: string })?.message || '更新考勤失败');
}
},
[],
);
message.error((error as { message?: string })?.message || '更新考勤失败');
}
}, []);
const now = new Date();
const schedules = workspace?.todaySchedules ?? [];
const startedCount = schedules.filter(
(item) => canPullAttendance(getSchedulePhase(item.startTime, item.endTime, now)),
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',
@@ -382,7 +406,9 @@ const TeacherAttendanceWorkspace: React.FC = () => {
<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>
<span className="attendance-eyebrow">
TEACHING DAY · {dayjs().format('MM月DD日 dddd')}
</span>
<h1></h1>
<p></p>
</div>
@@ -393,18 +419,33 @@ const TeacherAttendanceWorkspace: React.FC = () => {
<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>
<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>
<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>
<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>
<div>
<span></span>
<h2></h2>
</div>
<span className="attendance-section-note"></span>
</div>
@@ -413,7 +454,12 @@ const TeacherAttendanceWorkspace: React.FC = () => {
<Card className="attendance-empty-card">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={<div><strong></strong><p></p></div>}
description={
<div>
<strong></strong>
<p></p>
</div>
}
/>
</Card>
) : (
@@ -435,17 +481,31 @@ const TeacherAttendanceWorkspace: React.FC = () => {
)}
</Spin>
<Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} width={960} title={null} className="attendance-drawer">
<Drawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={960}
title={null}
className="attendance-drawer"
>
<div className="lesson-record-header">
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
<h2>{selectedSchedule?.subject || '课程考勤'}</h2>
<p>{selectedSchedule ? classNameById.get(selectedSchedule.classId) : ''} · {selectedSchedule?.startTime}{selectedSchedule?.endTime} · {dayjs().format('YYYY-MM-DD')}</p>
<p>
{selectedSchedule ? classNameById.get(selectedSchedule.classId) : ''} ·{' '}
{selectedSchedule?.startTime}{selectedSchedule?.endTime} ·{' '}
{dayjs().format('YYYY-MM-DD')}
</p>
</div>
{lessonSession && (
<Alert
type={isAttendanceCompleted ? 'success' : 'info'}
showIcon
title={isAttendanceCompleted ? '本节课考勤已结算' : '当前打卡结果;课程截止后将自动做最终结算'}
title={
isAttendanceCompleted
? '本节课考勤已结算'
: '当前打卡结果;课程截止后将自动做最终结算'
}
style={{ marginBottom: 16 }}
/>
)}
@@ -478,18 +538,30 @@ const TeacherAttendanceWorkspace: React.FC = () => {
dataSource={filteredLessonRecords}
pagination={false}
locale={{
emptyText: <Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={lessonRecords.length === 0 ? '本节课尚未开始点名' : '没有符合条件的学生'}
/>,
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
lessonRecords.length === 0 ? '本节课尚未开始点名' : '没有符合条件的学生'
}
/>
),
}}
columns={[
{
title: '学生', dataIndex: ['student', 'name'],
render: (name: string) => <div className="student-cell"><Avatar size={32}>{name?.slice(0, 1)}</Avatar><strong>{name || '-'}</strong></div>,
title: '学生',
dataIndex: ['student', 'name'],
render: (name: string) => (
<div className="student-cell">
<Avatar size={32}>{name?.slice(0, 1)}</Avatar>
<strong>{name || '-'}</strong>
</div>
),
},
{
title: '考勤结果', dataIndex: 'status', width: 230,
title: '考勤结果',
dataIndex: 'status',
width: 230,
render: (value: string, record: AttendanceRecordItem) => {
const checkedIn = value === 'present' || value === 'late';
return (
@@ -513,7 +585,16 @@ const TeacherAttendanceWorkspace: React.FC = () => {
);
},
},
{ title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => <AttendanceStatusTag status={value === 'present' || value === 'late' ? 'present' : 'absent'} /> },
{
title: '当前状态',
dataIndex: 'status',
width: 105,
render: (value: string) => (
<AttendanceStatusTag
status={value === 'present' || value === 'late' ? 'present' : 'absent'}
/>
),
},
{
title: '打卡设备',
width: 220,
@@ -529,7 +610,11 @@ const TeacherAttendanceWorkspace: React.FC = () => {
);
},
},
{ title: '备注', dataIndex: 'remark', render: (value: string | null) => value || <span className="muted-text"></span> },
{
title: '备注',
dataIndex: 'remark',
render: (value: string | null) => value || <span className="muted-text"></span>,
},
]}
/>
</Drawer>
@@ -553,14 +638,26 @@ const LessonCard: React.FC<{
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-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 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>
<Tooltip title="课程尚未开始">
<Button disabled></Button>
</Tooltip>
) : (
<Button type="primary" onClick={onOpen}>
{phase === 'ongoing' ? '查看当前考勤' : '拉取 / 查看考勤'} <ArrowRightOutlined />
@@ -571,7 +668,6 @@ const LessonCard: React.FC<{
);
};
interface AdminStudentPanel {
key: string;
studentId: number;
@@ -594,7 +690,11 @@ const ADMIN_METRIC_META = [
function pickPrimaryStatus(records: AttendanceRecordItem[]) {
const priority = ['absent', 'leave', 'present'];
return priority.find((item) => records.some((record) => displayAttendanceStatus(record.status) === item)) || 'absent';
return (
priority.find((item) =>
records.some((record) => displayAttendanceStatus(record.status) === item),
) || 'absent'
);
}
function buildAdminStudentPanels(records: AttendanceRecordItem[]): AdminStudentPanel[] {
@@ -621,9 +721,7 @@ function buildAdminStudentPanels(records: AttendanceRecordItem[]): AdminStudentP
}
return Array.from(map.values()).map((item) => {
const checked = item.records.filter(
(record) => record.status === 'present',
).length;
const checked = item.records.filter((record) => record.status === 'present').length;
return {
...item,
primaryStatus: pickPrimaryStatus(item.records),
@@ -660,7 +758,6 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
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],
@@ -712,7 +809,10 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
enabled: period.enabled ?? true,
})),
};
const data = await api.put<AttendancePeriodConfigItem[]>('/attendance-period-configs', payload);
const data = await api.put<AttendancePeriodConfigItem[]>(
'/attendance-period-configs',
payload,
);
setPeriods(data);
setPeriodModalOpen(false);
message.success('考勤时段配置已保存');
@@ -796,14 +896,16 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
}
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,
},
);
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 {
@@ -933,6 +1035,15 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
}
};
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(() => {
@@ -952,7 +1063,8 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
const selectedClassOption = classId
? classOptions.find((item) => item.classId === classId)
: undefined;
const selectedClass = selectedClassOption?.className || (classId ? `班级 ${classId}` : '全部班级');
const selectedClass =
selectedClassOption?.className || (classId ? `班级 ${classId}` : '全部班级');
const overviewTeachers = selectedClassOption?.teachers ?? [];
const headTeacherNames = classId
? formatTeacherNames(overviewTeachers.filter((teacher) => teacher.roleType === 'head_teacher'))
@@ -1004,7 +1116,20 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
title: '状态',
dataIndex: 'status',
width: 105,
render: (value: string) => <AttendanceStatusTag status={displayAttendanceStatus(value)} />,
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: '签到来源',
@@ -1025,7 +1150,16 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
title: '备注',
dataIndex: 'remark',
ellipsis: true,
render: (value: string | null) => value || <span className="muted-text"></span>,
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
? [
@@ -1418,7 +1552,6 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
)}
</Drawer>
<Modal
open={periodModalOpen}
title="考勤时段配置"
@@ -1514,7 +1647,6 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
</Form.List>
</Form>
</Modal>
</div>
);
};