forked from wangziqi/gongxue-base
- DatePicker editors open immediately on double-click - Student archive drawer hides empty studentNo parentheses - Student add/edit form uses 2-column grid layout - Table horizontal scrollbars show only on hover/focus - Remove fake 近7日趋势 stats from attendance student detail - Allow DingTalk refresh to update completed attendance sessions (late-arriving punches can now change absent → present) - Switch dev command from concurrently to turbo run dev - Remove concurrently/wait-on dependencies
273 lines
8.9 KiB
TypeScript
273 lines
8.9 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import { Alert, Avatar, Button, Drawer, Empty, Input, Progress, Select, Table, Tag } from 'antd';
|
||
import dayjs from 'dayjs';
|
||
import api from '../../api';
|
||
import { message } from '../../ui/app-message';
|
||
import {
|
||
filterLessonAttendanceRecords,
|
||
getPunchDisplayInfo,
|
||
summarizeLessonCheckins,
|
||
type LessonAttendanceFilter,
|
||
} from './attendance-workspace';
|
||
import type { LessonAttendanceRecord, LessonAttendanceSchedule } from './types';
|
||
|
||
interface LessonAttendanceSession {
|
||
id: number;
|
||
scheduleId: number;
|
||
classId: number;
|
||
lessonDate: string;
|
||
status: 'in_progress' | 'completed';
|
||
}
|
||
|
||
interface LessonAttendanceResponse {
|
||
schedule: LessonAttendanceSchedule;
|
||
session: LessonAttendanceSession | null;
|
||
records: LessonAttendanceRecord[];
|
||
}
|
||
|
||
interface LessonAttendanceDetailProps {
|
||
schedule: LessonAttendanceSchedule | null;
|
||
className: string;
|
||
onClose: () => void;
|
||
}
|
||
|
||
function AttendanceStatus({ status }: { status: string }) {
|
||
const checkedIn = status === 'present' || status === 'late';
|
||
return (
|
||
<span className={`attendance-status ${checkedIn ? 'is-present' : 'is-absent'}`}>
|
||
<span className="attendance-status__dot" />
|
||
{checkedIn ? '出勤' : '缺勤'}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function LessonSummary({ records }: { records: readonly LessonAttendanceRecord[] }) {
|
||
const summary = summarizeLessonCheckins(records);
|
||
const rate = summary.total > 0 ? Math.round((summary.checkedIn / summary.total) * 100) : 0;
|
||
return (
|
||
<div className="attendance-summary-strip lesson-summary-strip">
|
||
<div className="attendance-rate">
|
||
<Progress type="circle" percent={rate} size={64} strokeWidth={9} />
|
||
<div>
|
||
<span>打卡率</span>
|
||
<strong>{summary.total} 人</strong>
|
||
</div>
|
||
</div>
|
||
<div className="attendance-summary-cell">
|
||
<span className="attendance-summary-icon is-present">勤</span>
|
||
<div>
|
||
<strong>{summary.checkedIn}</strong>
|
||
<span>已打卡</span>
|
||
</div>
|
||
</div>
|
||
<div className="attendance-summary-cell">
|
||
<span className="attendance-summary-icon is-absent">缺</span>
|
||
<div>
|
||
<strong>{summary.notCheckedIn}</strong>
|
||
<span>未打卡</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const LessonAttendanceDetail: React.FC<LessonAttendanceDetailProps> = ({
|
||
schedule,
|
||
className,
|
||
onClose,
|
||
}) => {
|
||
const [loadedSchedule, setLoadedSchedule] = useState<LessonAttendanceSchedule | null>(null);
|
||
const [session, setSession] = useState<LessonAttendanceSession | null>(null);
|
||
const [records, setRecords] = useState<LessonAttendanceRecord[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [keyword, setKeyword] = useState('');
|
||
const [filter, setFilter] = useState<LessonAttendanceFilter>('all');
|
||
|
||
useEffect(() => {
|
||
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
|
||
.post<LessonAttendanceResponse>(`/attendance-lessons/schedules/${schedule.id}/pull`, {
|
||
date,
|
||
})
|
||
.then((data) => {
|
||
if (cancelled) return;
|
||
setLoadedSchedule(data.schedule);
|
||
setSession(data.session);
|
||
setRecords(data.records);
|
||
message.success('钉钉打卡已更新;课程截止后系统将自动结算');
|
||
})
|
||
.catch((error: unknown) => {
|
||
if (cancelled) return;
|
||
message.error((error as { message?: string })?.message || '加载本节课考勤失败');
|
||
})
|
||
.finally(() => {
|
||
if (!cancelled) setLoading(false);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [schedule]);
|
||
|
||
const updateRecord = useCallback(async (record: LessonAttendanceRecord, status: string) => {
|
||
const previous = record.status;
|
||
setRecords((items) =>
|
||
items.map((item) => (item.id === record.id ? { ...item, status } : item)),
|
||
);
|
||
try {
|
||
await api.put(`/attendance-records/${record.id}`, { status });
|
||
} catch (error: unknown) {
|
||
setRecords((items) =>
|
||
items.map((item) => (item.id === record.id ? { ...item, status: previous } : item)),
|
||
);
|
||
message.error((error as { message?: string })?.message || '更新考勤失败');
|
||
}
|
||
}, []);
|
||
|
||
const filteredRecords = useMemo(
|
||
() => filterLessonAttendanceRecords(records, keyword, filter),
|
||
[filter, keyword, records],
|
||
);
|
||
const completed = session?.status === 'completed';
|
||
const displayedSchedule = loadedSchedule ?? schedule;
|
||
|
||
return (
|
||
<Drawer
|
||
open={schedule !== null}
|
||
onClose={onClose}
|
||
size={960}
|
||
title={null}
|
||
className="attendance-drawer"
|
||
destroyOnHidden
|
||
>
|
||
<div className="lesson-record-header">
|
||
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
|
||
<h2>{displayedSchedule?.subject || '课程考勤'}</h2>
|
||
<p>
|
||
{className} · {displayedSchedule?.startTime}–{displayedSchedule?.endTime} ·{' '}
|
||
{dayjs().format('YYYY-MM-DD')}
|
||
</p>
|
||
</div>
|
||
{session && (
|
||
<Alert
|
||
type={completed ? 'success' : 'info'}
|
||
showIcon
|
||
title={completed ? '本节课考勤已结算' : '当前打卡结果;课程截止后将自动做最终结算'}
|
||
style={{ marginBottom: 16 }}
|
||
/>
|
||
)}
|
||
<LessonSummary records={records} />
|
||
<div className="lesson-record-filters">
|
||
<Input.Search
|
||
allowClear
|
||
value={keyword}
|
||
placeholder="搜索学生姓名"
|
||
onChange={(event) => setKeyword(event.target.value)}
|
||
className="lesson-record-search"
|
||
/>
|
||
<Select<LessonAttendanceFilter>
|
||
value={filter}
|
||
onChange={setFilter}
|
||
options={[
|
||
{ value: 'all', label: '全部学生' },
|
||
{ value: 'checked_in', label: '已打卡' },
|
||
{ value: 'not_checked_in', label: '未打卡' },
|
||
]}
|
||
className="lesson-record-filter-select"
|
||
/>
|
||
<span className="lesson-record-filter-count">
|
||
显示 {filteredRecords.length} / {records.length} 人
|
||
</span>
|
||
</div>
|
||
<Table<LessonAttendanceRecord>
|
||
rowKey="id"
|
||
loading={loading}
|
||
dataSource={filteredRecords}
|
||
pagination={false}
|
||
locale={{
|
||
emptyText: (
|
||
<Empty
|
||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||
description={records.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: 'status',
|
||
width: 230,
|
||
render: (value: string, record) => {
|
||
const checkedIn = value === 'present' || value === 'late';
|
||
return (
|
||
<div className="attendance-marking-actions">
|
||
<Button
|
||
size="small"
|
||
type={checkedIn ? 'primary' : 'default'}
|
||
onClick={() => void updateRecord(record, 'present')}
|
||
>
|
||
已打卡
|
||
</Button>
|
||
<Button
|
||
size="small"
|
||
type={!checkedIn ? 'primary' : 'default'}
|
||
danger={!checkedIn}
|
||
onClick={() => void updateRecord(record, 'absent')}
|
||
>
|
||
未打卡
|
||
</Button>
|
||
</div>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
title: '当前状态',
|
||
dataIndex: 'status',
|
||
width: 105,
|
||
render: (value: string) => <AttendanceStatus status={value} />,
|
||
},
|
||
{
|
||
title: '打卡设备',
|
||
width: 220,
|
||
render: (_: unknown, record) => {
|
||
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',
|
||
render: (value: string | null) => value || <span className="muted-text">—</span>,
|
||
},
|
||
]}
|
||
/>
|
||
</Drawer>
|
||
);
|
||
};
|
||
|
||
export default LessonAttendanceDetail;
|