fix: UI improvements and DingTalk attendance refresh for completed sessions
All checks were successful
CI / check (pull_request) Successful in 1m33s

- 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
This commit is contained in:
2026-07-22 17:50:59 +08:00
parent d33c37b4b0
commit 90cc321221
14 changed files with 581 additions and 546 deletions

View File

@@ -245,10 +245,10 @@ const EditableCell = <Value,>({
/>
);
} else if (editor === 'date') {
control = <DatePicker {...commonProps} format="YYYY-MM-DD" />;
control = <DatePicker {...commonProps} format="YYYY-MM-DD" open />;
} else if (editor === 'date-range') {
const { placeholder: _placeholder, ...rangeProps } = commonProps;
control = <DatePicker.RangePicker {...rangeProps} format="YYYY-MM-DD" />;
control = <DatePicker.RangePicker {...rangeProps} format="YYYY-MM-DD" open />;
} else if (editor === 'number' || editor === 'money') {
control = (
<InputNumber {...commonProps} min={min} max={max} precision={editor === 'money' ? 2 : 0} />

View File

@@ -1470,7 +1470,7 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
<Space>
<Button type="text" icon={<CloseOutlined />} onClick={onClose} aria-label="关闭档案" />
<span style={{ fontSize: 16, fontWeight: 500 }}>
- {student.name} ({student.studentNo})
- {student.name}{student.studentNo ? ` (${student.studentNo})` : ''}
</span>
</Space>
<Space>

View File

@@ -157,6 +157,22 @@ canvas {
gap: 8px;
}
.student-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 20px;
}
.student-form-grid .ant-form-item {
min-width: 0;
}
@media (max-width: 575px) {
.student-form-grid {
grid-template-columns: 1fr;
}
}
.ant-drawer-content-wrapper {
max-width: 100vw !important;
}
@@ -178,6 +194,35 @@ canvas {
-webkit-overflow-scrolling: touch;
}
/* Keep wide tables scrollable without showing a heavy bar on every table. */
.ant-table-wrapper .ant-table-content {
scrollbar-color: transparent transparent;
scrollbar-width: thin;
}
.ant-table-wrapper .ant-table-content::-webkit-scrollbar {
height: 6px;
}
.ant-table-wrapper .ant-table-content::-webkit-scrollbar-track {
background: transparent;
}
.ant-table-wrapper .ant-table-content::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 999px;
}
.ant-table-wrapper:hover .ant-table-content,
.ant-table-wrapper:focus-within .ant-table-content {
scrollbar-color: #c8c8cc transparent;
}
.ant-table-wrapper:hover .ant-table-content::-webkit-scrollbar-thumb,
.ant-table-wrapper:focus-within .ant-table-content::-webkit-scrollbar-thumb {
background: #c8c8cc;
}
/* ── 表格单元格省略号截断(按需启用) ──
在 .ant-table-wrapper 上添加 .table-cell-ellipsis 类即可生效:
<Table className="table-cell-ellipsis" ... /> */

View File

@@ -0,0 +1,272 @@
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;

View File

@@ -1335,22 +1335,6 @@
color: var(--student-muted);
}
.student-trend-bars {
display: flex;
align-items: end;
gap: 8px;
height: 110px;
padding: 12px;
border-radius: 8px;
background: var(--student-soft);
}
.student-trend-bars i {
flex: 1;
min-height: 18px;
border-radius: 5px 5px 0 0;
background: linear-gradient(180deg, #56bea3, #157a65);
}
@media (max-width: 1280px) {
.student-filter-panel {

View File

@@ -12,7 +12,6 @@ import {
Form,
Input,
Modal,
Progress,
Segmented,
Row,
Select,
@@ -41,15 +40,14 @@ import { usePermission } from '../../hooks/usePermission';
import { message } from '../../ui/app-message';
import {
canPullAttendance,
filterLessonAttendanceRecords,
getAttendanceExperience,
getPunchDisplayInfo,
getSchedulePhase,
summarizeLessonCheckins,
getPunchDisplayInfo,
type AttendanceSummary,
type LessonAttendanceFilter,
type SchedulePhase,
} from './attendance-workspace';
import LessonAttendanceDetail from './LessonAttendanceDetail';
import type { LessonAttendanceRecord, LessonAttendanceSchedule } from './types';
import './attendance.css';
const DEFAULT_ATTENDANCE_PERIODS = [
@@ -117,25 +115,7 @@ interface ClassOption {
teachers?: ClassTeacherOption[];
}
interface AttendanceRecordItem {
id: number;
studentId: number;
classId: number | null;
attendanceDate: string;
session: string;
status: string;
source?: string;
remark: string | null;
createdAt: string;
student: { id: number; name: string; studentNo?: string | null };
class: { id: number; name: string } | null;
scheduleId?: number | null;
attendanceSessionId?: number | null;
punchTime?: string | null;
punchSource?: string | null;
punchDeviceName?: string | null;
punchDeviceId?: string | null;
}
type AttendanceRecordItem = LessonAttendanceRecord;
interface AssignedClass {
classId: number;
@@ -145,14 +125,7 @@ interface AssignedClass {
subject: string;
}
interface TodaySchedule {
id: number;
classId: number;
classroomId: number;
startTime: string;
endTime: string;
subject: string;
}
type TodaySchedule = LessonAttendanceSchedule;
interface HistoryScheduleOption {
id: number;
@@ -169,20 +142,6 @@ interface HistoryScheduleOption {
status: string;
}
interface LessonAttendanceSession {
id: number;
scheduleId: number;
classId: number;
lessonDate: string;
status: 'in_progress' | 'completed';
}
interface LessonAttendanceResponse {
schedule: TodaySchedule;
session: LessonAttendanceSession | null;
records: AttendanceRecordItem[];
}
interface TeacherWorkspaceData {
assignedClasses: AssignedClass[];
todaySchedules: TodaySchedule[];
@@ -275,36 +234,6 @@ function AttendanceStatusTag({ status }: { status: string }) {
);
}
function LessonCheckinSummaryStrip({ records }: { records: readonly AttendanceRecordItem[] }) {
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 AttendancePage: React.FC = () => {
const { permissions, hasPermission } = usePermission();
const roles = useMemo(readCurrentRoles, []);
@@ -321,12 +250,6 @@ const TeacherAttendanceWorkspace: React.FC = () => {
const [loading, setLoading] = useState(true);
const [workspace, setWorkspace] = useState<TeacherWorkspaceData | null>(null);
const [selectedSchedule, setSelectedSchedule] = useState<TodaySchedule | null>(null);
const [lessonSession, setLessonSession] = useState<LessonAttendanceSession | null>(null);
const [lessonRecords, setLessonRecords] = useState<AttendanceRecordItem[]>([]);
const [recordLoading, setRecordLoading] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [studentKeyword, setStudentKeyword] = useState('');
const [checkinFilter, setCheckinFilter] = useState<LessonAttendanceFilter>('all');
const loadWorkspace = useCallback(async () => {
setLoading(true);
@@ -348,44 +271,8 @@ const TeacherAttendanceWorkspace: React.FC = () => {
[workspace],
);
const openAttendance = useCallback(async (schedule: TodaySchedule) => {
setStudentKeyword('');
setCheckinFilter('all');
const openAttendance = useCallback((schedule: TodaySchedule) => {
setSelectedSchedule(schedule);
setDrawerOpen(true);
setRecordLoading(true);
try {
const today = dayjs().format('YYYY-MM-DD');
const data = await api.post<LessonAttendanceResponse>(
`/attendance-lessons/schedules/${schedule.id}/pull`,
{ date: today },
);
setSelectedSchedule(data.schedule);
setLessonSession(data.session);
setLessonRecords(data.records);
message.success('钉钉打卡已更新;课程截止后系统将自动结算');
} catch (error: unknown) {
setLessonSession(null);
setLessonRecords([]);
message.error(error instanceof Error ? error.message : '加载本节课考勤失败');
} finally {
setRecordLoading(false);
}
}, []);
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: previous } : item)),
);
message.error((error as { message?: string })?.message || '更新考勤失败');
}
}, []);
const now = new Date();
@@ -396,11 +283,6 @@ const TeacherAttendanceWorkspace: React.FC = () => {
const nextSchedule = schedules.find(
(item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended',
);
const isAttendanceCompleted = lessonSession?.status === 'completed';
const filteredLessonRecords = useMemo(
() => filterLessonAttendanceRecords(lessonRecords, studentKeyword, checkinFilter),
[lessonRecords, studentKeyword, checkinFilter],
);
return (
<div className="attendance-page teacher-attendance">
@@ -473,7 +355,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
phase={phase}
className={classNameById.get(schedule.classId) || `班级 ${schedule.classId}`}
index={index + 1}
onOpen={() => void openAttendance(schedule)}
onOpen={() => openAttendance(schedule)}
/>
);
})}
@@ -481,143 +363,15 @@ const TeacherAttendanceWorkspace: React.FC = () => {
)}
</Spin>
<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>
</div>
{lessonSession && (
<Alert
type={isAttendanceCompleted ? 'success' : 'info'}
showIcon
title={
isAttendanceCompleted
? '本节课考勤已结算'
: '当前打卡结果;课程截止后将自动做最终结算'
}
style={{ marginBottom: 16 }}
/>
)}
<LessonCheckinSummaryStrip records={lessonRecords} />
<div className="lesson-record-filters">
<Input.Search
allowClear
value={studentKeyword}
placeholder="搜索学生姓名"
onChange={(event) => setStudentKeyword(event.target.value)}
className="lesson-record-search"
/>
<Select<LessonAttendanceFilter>
value={checkinFilter}
onChange={setCheckinFilter}
options={[
{ value: 'all', label: '全部学生' },
{ value: 'checked_in', label: '已打卡' },
{ value: 'not_checked_in', label: '未打卡' },
]}
className="lesson-record-filter-select"
/>
<span className="lesson-record-filter-count">
{filteredLessonRecords.length} / {lessonRecords.length}
</span>
</div>
<Table<AttendanceRecordItem>
rowKey="id"
loading={recordLoading}
dataSource={filteredLessonRecords}
pagination={false}
locale={{
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: 'status',
width: 230,
render: (value: string, record: AttendanceRecordItem) => {
const checkedIn = value === 'present' || value === 'late';
return (
<div className="attendance-marking-actions">
<Button
size="small"
type={checkedIn ? 'primary' : 'default'}
onClick={() => void updateLessonRecord(record, 'present')}
>
</Button>
<Button
size="small"
type={!checkedIn ? 'primary' : 'default'}
danger={!checkedIn}
onClick={() => void updateLessonRecord(record, 'absent')}
>
</Button>
</div>
);
},
},
{
title: '当前状态',
dataIndex: 'status',
width: 105,
render: (value: string) => (
<AttendanceStatusTag
status={value === 'present' || value === 'late' ? 'present' : 'absent'}
/>
),
},
{
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',
render: (value: string | null) => value || <span className="muted-text"></span>,
},
]}
/>
</Drawer>
<LessonAttendanceDetail
schedule={selectedSchedule}
className={
selectedSchedule
? classNameById.get(selectedSchedule.classId) || `班级 ${selectedSchedule.classId}`
: ''
}
onClose={() => setSelectedSchedule(null)}
/>
</div>
);
};
@@ -1539,15 +1293,6 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
))}
</div>
</section>
<section className="student-detail-section">
<h5> 7 </h5>
<div className="student-trend-bars">
{Array.from({ length: 7 }).map((_, index) => {
const value = Math.max(60, Math.min(100, selectedStudent.rate + index * 3 - 8));
return <i key={index} style={{ height: `${value}%` }} title={`${value}%`} />;
})}
</div>
</section>
</div>
)}
</Drawer>

View File

@@ -0,0 +1,28 @@
export interface LessonAttendanceSchedule {
id: number;
classId: number;
classroomId: number;
startTime: string;
endTime: string;
subject: string;
}
export interface LessonAttendanceRecord {
id: number;
studentId: number;
classId: number | null;
attendanceDate: string;
session: string;
status: string;
source?: string;
remark: string | null;
createdAt: string;
student: { id: number; name: string; studentNo?: string | null };
class: { id: number; name: string } | null;
scheduleId?: number | null;
attendanceSessionId?: number | null;
punchTime?: string | null;
punchSource?: string | null;
punchDeviceName?: string | null;
punchDeviceId?: string | null;
}

View File

@@ -892,6 +892,8 @@ const StudentsPage: React.FC = () => {
<style>{`.archived-row { opacity: 0.6; background: #fafafa !important; }`}</style>
<Modal
title={editing ? '编辑学生' : '添加学生'}
className="student-form-modal"
width={720}
open={modalOpen}
onOk={handleSave}
onCancel={() => {
@@ -901,7 +903,7 @@ const StudentsPage: React.FC = () => {
okText="保存"
confirmLoading={saving}
>
<Form form={form} layout="vertical">
<Form form={form} layout="vertical" className="student-form-grid">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
<Input />
</Form.Item>