forked from wangziqi/gongxue-base
feat: 支持表格单元格快捷编辑
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user