{hasPermission('notification:view') &&
}
diff --git a/apps/admin/src/pages/AiConfig/helpers.integration.test.ts b/apps/admin/src/pages/AiConfig/helpers.integration.test.ts
index 636ae50..5932fa9 100644
--- a/apps/admin/src/pages/AiConfig/helpers.integration.test.ts
+++ b/apps/admin/src/pages/AiConfig/helpers.integration.test.ts
@@ -17,11 +17,7 @@ describe('AiConfig helpers', () => {
});
it('swaps when current baseUrl matches previous provider default', () => {
- const result = shouldAutoSwapBaseUrl(
- 'DEEPSEEK',
- 'https://api.openai.com/v1',
- 'OPENAI',
- );
+ const result = shouldAutoSwapBaseUrl('DEEPSEEK', 'https://api.openai.com/v1', 'OPENAI');
expect(result.shouldSwap).toBe(true);
expect(result.baseUrl).toBe(PROVIDER_DEFAULTS.DEEPSEEK);
});
@@ -32,11 +28,7 @@ describe('AiConfig helpers', () => {
});
it('keeps custom baseUrl unchanged', () => {
- const result = shouldAutoSwapBaseUrl(
- 'OPENAI',
- 'https://custom.api.com/v1',
- 'DEEPSEEK',
- );
+ const result = shouldAutoSwapBaseUrl('OPENAI', 'https://custom.api.com/v1', 'DEEPSEEK');
expect(result.shouldSwap).toBe(false);
expect(result.baseUrl).toBe('https://custom.api.com/v1');
});
diff --git a/apps/admin/src/pages/Attendance/attendance-workspace.integration.test.ts b/apps/admin/src/pages/Attendance/attendance-workspace.integration.test.ts
index 440012a..003f591 100644
--- a/apps/admin/src/pages/Attendance/attendance-workspace.integration.test.ts
+++ b/apps/admin/src/pages/Attendance/attendance-workspace.integration.test.ts
@@ -68,7 +68,6 @@ describe('lesson check-in summary', () => {
});
});
-
describe('lesson attendance filters', () => {
const records = [
{ id: 1, student: { name: '张三' }, status: 'present' },
@@ -78,15 +77,21 @@ describe('lesson attendance filters', () => {
];
it('searches students by name and ignores surrounding whitespace', () => {
- expect(filterLessonAttendanceRecords(records, ' 张 ', 'all').map((item) => item.id)).toEqual([1]);
+ expect(filterLessonAttendanceRecords(records, ' 张 ', 'all').map((item) => item.id)).toEqual([
+ 1,
+ ]);
});
it('groups present and late as checked in', () => {
- expect(filterLessonAttendanceRecords(records, '', 'checked_in').map((item) => item.id)).toEqual([1, 2]);
+ expect(filterLessonAttendanceRecords(records, '', 'checked_in').map((item) => item.id)).toEqual(
+ [1, 2],
+ );
});
it('groups pending and absent as not checked in and combines with search', () => {
- expect(filterLessonAttendanceRecords(records, '王', 'not_checked_in').map((item) => item.id)).toEqual([3]);
+ expect(
+ filterLessonAttendanceRecords(records, '王', 'not_checked_in').map((item) => item.id),
+ ).toEqual([3]);
});
});
diff --git a/apps/admin/src/pages/Attendance/attendance-workspace.ts b/apps/admin/src/pages/Attendance/attendance-workspace.ts
index c49485a..46771bb 100644
--- a/apps/admin/src/pages/Attendance/attendance-workspace.ts
+++ b/apps/admin/src/pages/Attendance/attendance-workspace.ts
@@ -8,11 +8,7 @@ export function getAttendanceExperience(
roles: readonly string[],
): AttendanceExperience {
const domains = getRoleDomains(roles, permissions);
- if (
- permissions.includes('attendance:edit') ||
- domains.has('academic') ||
- domains.has('super')
- ) {
+ if (permissions.includes('attendance:edit') || domains.has('academic') || domains.has('super')) {
return 'admin';
}
return 'teacher';
@@ -83,7 +79,6 @@ export function summarizeLessonCheckins(
};
}
-
export type LessonAttendanceFilter = 'all' | 'checked_in' | 'not_checked_in';
export interface LessonAttendanceFilterRecord {
diff --git a/apps/admin/src/pages/Attendance/attendance.css b/apps/admin/src/pages/Attendance/attendance.css
index 03befa0..8e542ff 100644
--- a/apps/admin/src/pages/Attendance/attendance.css
+++ b/apps/admin/src/pages/Attendance/attendance.css
@@ -225,7 +225,10 @@
border-left: 4px solid #c9d2df;
border-radius: 14px;
background: white;
- transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease;
+ transition:
+ transform 180ms ease,
+ box-shadow 180ms ease,
+ border-color 180ms ease;
}
.lesson-card:hover {
@@ -366,6 +369,36 @@
font-size: 20px;
}
+
+.lesson-summary-strip {
+ grid-template-columns: minmax(210px, 1fr) repeat(2, minmax(120px, 0.7fr));
+}
+
+.lesson-summary-strip .attendance-rate,
+.lesson-summary-strip .attendance-summary-cell {
+ justify-content: center;
+}
+
+.lesson-summary-strip .attendance-rate {
+ text-align: left;
+}
+
+.attendance-correction-segment.ant-segmented {
+ padding: 2px;
+ border: 1px solid var(--student-line, #e1e8e5);
+ background: #f8faf9;
+}
+
+.attendance-correction-segment .ant-segmented-item {
+ min-width: 52px;
+}
+
+.attendance-correction-segment .ant-segmented-item-label {
+ min-height: 28px;
+ line-height: 28px;
+ font-size: 12px;
+}
+
.attendance-summary-icon {
display: grid !important;
flex: 0 0 auto;
@@ -376,11 +409,26 @@
font-weight: 700;
}
-.is-present { color: #198754 !important; background: #eaf8f1; }
-.is-late { color: #b56b00 !important; background: #fff4db; }
-.is-absent { color: #cf3030 !important; background: #fff0f0; }
-.is-leave { color: #2874c6 !important; background: #edf5ff; }
-.is-pending { color: #667085 !important; background: #f1f3f6; }
+.is-present {
+ color: #198754 !important;
+ background: #eaf8f1;
+}
+.is-late {
+ color: #b56b00 !important;
+ background: #fff4db;
+}
+.is-absent {
+ color: #cf3030 !important;
+ background: #fff0f0;
+}
+.is-leave {
+ color: #2874c6 !important;
+ background: #edf5ff;
+}
+.is-pending {
+ color: #667085 !important;
+ background: #f1f3f6;
+}
.lesson-record-filters {
display: flex;
@@ -659,7 +707,6 @@
min-width: 54px;
}
-
.punch-device-cell {
display: flex;
flex-direction: column;
@@ -692,9 +739,11 @@
--student-line: #e1e8e5;
--student-primary: #157a65;
--student-primary-soft: #e8f4f0;
+ min-width: 0;
min-height: calc(100vh - 64px);
margin: -24px;
padding: 0 24px 28px;
+ overflow: hidden;
background: var(--student-bg);
color: var(--student-ink);
}
@@ -755,9 +804,11 @@
.student-filter-panel {
display: grid;
- grid-template-columns: minmax(260px, 1.4fr) repeat(4, minmax(150px, 1fr)) auto;
- gap: 12px;
+ grid-template-columns: 176px 220px 142px 142px 142px auto;
+ gap: 10px;
align-items: end;
+ justify-content: start;
+ min-width: 0;
margin-bottom: 16px;
padding: 16px;
border: 1px solid var(--student-line);
@@ -783,13 +834,56 @@
.student-filter-actions {
display: flex;
+ justify-content: flex-start;
+ flex-wrap: nowrap;
gap: 8px;
+ padding-top: 2px;
+}
+
+.student-filter-actions .ant-btn {
+ min-width: 88px;
+}
+
+
+.attendance-period-editor {
+ display: grid;
+ gap: 12px;
+}
+
+.attendance-period-row {
+ display: grid;
+ grid-template-columns: 1.05fr 1fr 132px 132px 92px auto;
+ gap: 10px;
+ align-items: start;
+ padding: 12px;
+ border: 1px solid var(--student-line);
+ border-radius: 12px;
+ background: #fff;
+}
+
+.attendance-period-row .ant-form-item {
+ margin-bottom: 0;
+}
+
+.attendance-period-row__delete {
+ align-self: end;
+ min-width: 72px;
+}
+
+.attendance-period-add {
+ height: 44px;
+ border-radius: 12px;
+}
+
+.attendance-period-modal .ant-modal-body {
+ padding-top: 12px;
}
.student-class-overview {
display: grid;
- grid-template-columns: minmax(320px, .9fr) minmax(0, 1.1fr);
+ grid-template-columns: minmax(360px, 0.95fr) minmax(360px, 1.05fr);
gap: 16px;
+ min-width: 0;
margin-bottom: 16px;
}
@@ -797,32 +891,79 @@
.student-metric-strip,
.student-workspace,
.student-record-card {
- border: 1px solid var(--student-line);
- border-radius: 10px;
+ border: 1px solid rgb(21 122 101 / 12%);
+ border-radius: 18px;
background: var(--student-surface);
+ box-shadow: 0 14px 36px rgb(24 44 38 / 7%);
+}
+
+.student-class-overview > *,
+.student-workspace,
+.student-record-card {
+ min-width: 0;
}
.student-class-identity {
+ position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
- min-height: 156px;
- padding: 18px;
+ min-height: 176px;
+ overflow: hidden;
+ padding: 20px;
+ background:
+ radial-gradient(circle at 100% 0%, rgb(21 122 101 / 10%), transparent 34%),
+ linear-gradient(135deg, #ffffff 0%, #f8fcfa 100%);
+}
+
+.student-class-identity::after {
+ content: '';
+ position: absolute;
+ right: -36px;
+ bottom: -44px;
+ width: 118px;
+ height: 118px;
+ border: 18px solid rgb(21 122 101 / 7%);
+ border-radius: 999px;
+ pointer-events: none;
+}
+
+.student-class-heading {
+ position: relative;
+ z-index: 1;
+}
+
+.student-overview-kicker {
+ display: inline-flex;
+ align-items: center;
+ min-height: 24px;
+ margin-bottom: 8px;
+ padding: 0 9px;
+ border-radius: 999px;
+ background: rgb(21 122 101 / 10%);
+ color: var(--student-primary);
+ font-size: 12px;
+ font-weight: 700;
}
.student-class-identity h2 {
- margin: 0 0 5px;
- font-size: 22px;
+ margin: 0 0 6px;
+ color: #111c18;
+ font-size: 24px;
+ line-height: 1.2;
}
-.student-class-identity span {
+.student-class-identity p {
+ margin: 0;
color: var(--student-muted);
- font-size: 12px;
+ font-size: 13px;
}
.student-teacher-list {
- display: flex;
- flex-wrap: wrap;
+ position: relative;
+ z-index: 1;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
margin-top: 20px;
}
@@ -830,77 +971,118 @@
.student-teacher-item {
display: flex;
align-items: center;
- gap: 9px;
- min-width: 150px;
- padding: 9px;
- border-radius: 8px;
- background: var(--student-soft);
+ gap: 10px;
+ min-width: 0;
+ padding: 10px;
+ border: 1px solid rgb(21 122 101 / 10%);
+ border-radius: 12px;
+ background: rgb(255 255 255 / 76%);
+}
+
+.student-teacher-item.is-muted {
+ opacity: 0.72;
}
.student-teacher-item .ant-avatar {
+ flex: 0 0 auto;
color: var(--student-primary);
background: var(--student-primary-soft);
}
+.student-teacher-item > div {
+ min-width: 0;
+}
+
.student-teacher-item strong,
.student-teacher-item span {
display: block;
}
+.student-teacher-item span {
+ color: var(--student-muted);
+ font-size: 12px;
+}
+
.student-teacher-item strong {
+ min-width: 0;
margin-top: 2px;
+ overflow-wrap: anywhere;
+ color: #15231f;
font-size: 13px;
+ line-height: 1.35;
}
.student-metric-strip {
display: grid;
- grid-template-columns: repeat(6, minmax(82px, 1fr));
- overflow: hidden;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ min-width: 0;
+ padding: 10px;
}
.student-metric-card {
appearance: none;
- display: grid;
- align-content: center;
- gap: 6px;
- min-height: 156px;
- padding: 16px 12px;
- border: 0;
- border-right: 1px solid var(--student-line);
- background: #fff;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+ min-height: 76px;
+ padding: 13px 14px;
+ border: 1px solid transparent;
+ border-radius: 14px;
+ background: #f9fbfa;
color: inherit;
text-align: left;
cursor: pointer;
-}
-
-.student-metric-card:last-child {
- border-right: 0;
+ transition:
+ background 160ms ease,
+ border-color 160ms ease,
+ box-shadow 160ms ease,
+ transform 160ms ease;
}
.student-metric-card:hover,
.student-metric-card.active {
- background: var(--student-primary-soft);
+ transform: translateY(-1px);
+ border-color: rgb(21 122 101 / 24%);
+ background: #ffffff;
+ box-shadow: 0 10px 22px rgb(24 44 38 / 8%);
+}
+
+.student-metric-card:focus-visible {
+ outline: 3px solid rgb(21 122 101 / 22%);
+ outline-offset: 2px;
}
.student-metric-icon {
display: inline-grid;
- width: max-content;
- min-width: 34px;
- height: 26px;
+ flex: 0 0 auto;
+ width: 40px;
+ height: 40px;
place-items: center;
- padding: 0 7px;
- border-radius: 999px;
+ border-radius: 12px;
font-size: 12px;
- font-weight: 700;
+ font-weight: 800;
+}
+
+.student-metric-copy {
+ display: grid;
+ gap: 4px;
+ min-width: 0;
}
.student-metric-card strong {
- font-size: 24px;
+ overflow: hidden;
+ color: #111c18;
+ font-size: clamp(19px, 3vw, 24px);
line-height: 1;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.student-metric-card small {
color: var(--student-muted);
+ font-size: 12px;
}
.student-workspace {
@@ -930,18 +1112,27 @@
.student-workspace-tools {
display: flex;
align-items: center;
+ flex: 0 0 auto;
+ flex-wrap: nowrap;
gap: 8px;
+ min-width: 0;
+ white-space: nowrap;
}
.student-workspace-tools .ant-input-search {
+ flex: 0 0 240px;
width: 240px;
}
+.student-workspace-tools .ant-btn {
+ flex: 0 0 auto;
+}
+
.student-legend {
display: flex;
align-items: center;
- justify-content: space-between;
- gap: 12px;
+ flex-wrap: wrap;
+ gap: 8px 12px;
padding: 12px 0;
color: var(--student-muted);
font-size: 12px;
@@ -960,11 +1151,21 @@
border-radius: 2px;
}
-.student-legend i.is-present { background: #2f9c78; }
-.student-legend i.is-late { background: #d78a18; }
-.student-legend i.is-leave { background: #397bbf; }
-.student-legend i.is-absent { background: #d44d4d; }
-.student-legend i.is-pending { background: #8a9692; }
+.student-legend i.is-present {
+ background: #2f9c78;
+}
+.student-legend i.is-late {
+ background: #d78a18;
+}
+.student-legend i.is-leave {
+ background: #397bbf;
+}
+.student-legend i.is-absent {
+ background: #d44d4d;
+}
+.student-legend i.is-pending {
+ background: #8a9692;
+}
.student-legend em {
color: var(--student-quiet);
@@ -973,7 +1174,7 @@
.student-card-grid {
display: grid;
- grid-template-columns: repeat(auto-fill, minmax(184px, 1fr));
+ grid-template-columns: repeat(auto-fill, minmax(216px, 1fr));
gap: 10px;
}
@@ -989,7 +1190,10 @@
color: inherit;
text-align: left;
cursor: pointer;
- transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
+ transition:
+ border-color 160ms ease,
+ box-shadow 160ms ease,
+ transform 160ms ease;
}
.student-attendance-card:hover,
@@ -1011,7 +1215,12 @@
}
.student-attendance-head small {
+ flex: 0 0 auto;
+ max-width: 112px;
+ overflow: hidden;
color: var(--student-quiet);
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.student-status-blocks {
@@ -1139,43 +1348,144 @@
background: linear-gradient(180deg, #56bea3, #157a65);
}
-@media (max-width: 1180px) {
- .student-filter-panel,
- .student-class-overview {
- grid-template-columns: 1fr 1fr;
+@media (max-width: 1280px) {
+ .student-filter-panel {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+
+ .student-filter-field--date {
+ grid-column: auto;
}
.student-filter-actions {
grid-column: 1 / -1;
justify-content: flex-end;
}
+}
+
+@media (max-width: 1180px) {
+ .student-class-overview {
+ grid-template-columns: 1fr;
+ }
.student-metric-strip {
- grid-template-columns: repeat(3, 1fr);
+ grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
+ .lesson-summary-strip {
+ grid-template-columns: 1fr;
+ }
+
+ .lesson-summary-strip .attendance-rate,
+ .lesson-summary-strip .attendance-summary-cell {
+ justify-content: flex-start;
+ }
+
.student-attendance-center {
- margin: -16px;
- padding: 0 16px 22px;
+ margin: -12px;
+ padding: 0 12px 20px;
+ }
+
+ .student-center-topbar {
+ margin-inline: -12px;
+ padding: 12px;
}
.student-center-topbar,
.student-workspace-header,
.student-center-actions,
.student-legend {
- align-items: flex-start;
+ align-items: stretch;
flex-direction: column;
}
+ .student-center-title {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 2px;
+ }
+
.student-filter-panel,
+ .attendance-period-row {
+ grid-template-columns: 1fr;
+ }
+
.student-class-overview {
grid-template-columns: 1fr;
}
- .student-workspace-tools,
- .student-workspace-tools .ant-input-search {
+ .student-filter-field--date {
+ grid-column: auto;
+ }
+
+ .student-filter-actions > *,
+ .student-center-actions .ant-btn {
width: 100%;
}
+
+ .student-workspace-tools {
+ align-self: stretch;
+ width: 100%;
+ }
+
+ .student-workspace-tools .ant-input-search {
+ flex: 1 1 auto;
+ min-width: 0;
+ width: auto;
+ }
+
+ .student-filter-actions > * {
+ flex: 1;
+ }
+
+ .student-metric-strip {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ .student-metric-card {
+ min-height: 72px;
+ }
+
+ .student-workspace {
+ padding-inline: 12px;
+ }
+
+ .student-card-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .student-teacher-list {
+ grid-template-columns: 1fr;
+ }
+
+ .student-detail-profile {
+ grid-template-columns: auto minmax(0, 1fr);
+ }
+
+ .student-detail-rate {
+ grid-column: 1 / -1;
+ text-align: left;
+ }
+
+ .student-detail-timeline > div {
+ grid-template-columns: 56px minmax(0, 1fr);
+ }
+}
+
+@media (max-width: 430px) {
+ .student-metric-strip {
+ grid-template-columns: 1fr;
+ }
+
+ .student-attendance-head {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ .student-attendance-head small {
+ max-width: 100%;
+ }
}
diff --git a/apps/admin/src/pages/Attendance/index.tsx b/apps/admin/src/pages/Attendance/index.tsx
index a047553..82d5f7b 100644
--- a/apps/admin/src/pages/Attendance/index.tsx
+++ b/apps/admin/src/pages/Attendance/index.tsx
@@ -7,14 +7,15 @@ import {
Col,
DatePicker,
Drawer,
+ Grid,
Empty,
Form,
Input,
Modal,
Progress,
+ Segmented,
Row,
Select,
- Space,
Spin,
Table,
Tag,
@@ -25,11 +26,8 @@ import {
ArrowRightOutlined,
CheckCircleFilled,
ClockCircleOutlined,
- EditOutlined,
ExportOutlined,
- CalendarOutlined,
FileSearchOutlined,
- FilterOutlined,
ReloadOutlined,
ScheduleOutlined,
TeamOutlined,
@@ -53,34 +51,28 @@ import {
} from './attendance-workspace';
import './attendance.css';
-const { RangePicker } = DatePicker;
-
-const SESSION_OPTIONS = [
- { value: 'morning_reading', label: '早自习' },
- { value: 'morning', label: '上午' },
- { value: 'afternoon', label: '下午' },
- { value: 'evening_study', label: '晚自习' },
- { value: 'night_check', label: '晚寝' },
+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 SESSION_MAP: Record
= Object.fromEntries(
- SESSION_OPTIONS.map((item) => [item.value, item.label]),
-);
-
const STATUS_META: Record<
string,
{ label: string; color: string; className: string; short: string }
> = {
present: { label: '出勤', color: 'success', className: 'is-present', short: '勤' },
- late: { label: '迟到', color: 'warning', className: 'is-late', 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 STATUS_OPTIONS = Object.entries(STATUS_META)
- .filter(([value]) => value !== 'pending')
- .map(([value, item]) => ({ value, label: item.label }));
+const ADMIN_CORRECTION_OPTIONS = [
+ { value: 'present', label: '正常' },
+ { value: 'leave', label: '请假' },
+ { value: 'absent', label: '缺勤' },
+];
interface ClassOption {
classId: number;
@@ -97,7 +89,7 @@ interface AttendanceRecordItem {
source?: string;
remark: string | null;
createdAt: string;
- student: { id: number; name: string };
+ student: { id: number; name: string; studentNo?: string | null };
class: { id: number; name: string } | null;
scheduleId?: number | null;
attendanceSessionId?: number | null;
@@ -124,6 +116,19 @@ interface TodaySchedule {
subject: string;
}
+interface HistoryScheduleOption {
+ id: number;
+ classId: number;
+ weekDay: number;
+ startTime: string;
+ endTime: string;
+ startDate: string;
+ endDate: string;
+ subject: string;
+ teacherId: number | null;
+ status: string;
+}
+
interface LessonAttendanceSession {
id: number;
scheduleId: number;
@@ -146,12 +151,30 @@ interface TeacherWorkspaceData {
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,
@@ -170,11 +193,16 @@ function readCurrentRoles(): string[] {
}
}
+function displayAttendanceStatus(status?: string | null): string {
+ return status === 'pending' || !status ? 'absent' : status;
+}
+
function AttendanceStatusTag({ status }: { status: string }) {
- const meta = STATUS_META[status] ?? {
- label: status,
+ const displayStatus = displayAttendanceStatus(status);
+ const meta = STATUS_META[displayStatus] ?? {
+ label: displayStatus,
color: 'default',
- className: 'is-pending',
+ className: 'is-absent',
short: '?',
};
return (
@@ -189,18 +217,27 @@ function LessonCheckinSummaryStrip({ records }: { records: readonly AttendanceRe
const summary = summarizeLessonCheckins(records);
const rate = summary.total > 0 ? Math.round((summary.checkedIn / summary.total) * 100) : 0;
return (
-
+
-
打卡率{summary.total} 人
+
+ 打卡率
+ {summary.total} 人
+
勤
-
{summary.checkedIn}已打卡
+
+ {summary.checkedIn}
+ 已打卡
+
缺
-
{summary.notCheckedIn}未打卡
+
+ {summary.notCheckedIn}
+ 未打卡
+
);
@@ -228,8 +265,6 @@ const TeacherAttendanceWorkspace: React.FC = () => {
const [drawerOpen, setDrawerOpen] = useState(false);
const [studentKeyword, setStudentKeyword] = useState('');
const [checkinFilter, setCheckinFilter] = useState
('all');
- const [selectedClassId, setSelectedClassId] = useState('all');
- const [phaseFilter, setPhaseFilter] = useState('all');
const loadWorkspace = useCallback(async () => {
setLoading(true);
@@ -251,17 +286,6 @@ const TeacherAttendanceWorkspace: React.FC = () => {
[workspace],
);
- const classFilterOptions = useMemo(
- () => [
- { value: 'all' as const, label: '全部教学班' },
- ...(workspace?.assignedClasses.map((item) => ({
- value: item.classId,
- label: item.className,
- })) ?? []),
- ],
- [workspace],
- );
-
const openAttendance = useCallback(async (schedule: TodaySchedule) => {
setStudentKeyword('');
@@ -300,7 +324,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
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 || '更新考勤失败');
}
},
[],
@@ -315,16 +339,6 @@ const TeacherAttendanceWorkspace: React.FC = () => {
const nextSchedule = schedules.find(
(item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended',
);
- const filteredSchedules = schedules.filter((item) => {
- const phase = getSchedulePhase(item.startTime, item.endTime, now);
- const matchesClass = selectedClassId === 'all' || item.classId === selectedClassId;
- const matchesPhase = phaseFilter === 'all' || phase === phaseFilter;
- return matchesClass && matchesPhase;
- });
- const currentFocusSchedule = nextSchedule ?? schedules.at(-1) ?? null;
- const currentFocusClassName = currentFocusSchedule
- ? classNameById.get(currentFocusSchedule.classId) || `班级 ${currentFocusSchedule.classId}`
- : '暂无教学班';
const isAttendanceCompleted = lessonSession?.status === 'completed';
const filteredLessonRecords = useMemo(
() => filterLessonAttendanceRecords(lessonRecords, studentKeyword, checkinFilter),
@@ -333,135 +347,72 @@ const TeacherAttendanceWorkspace: React.FC = () => {
return (
-
-
- }>{dayjs().format('YYYY-MM-DD')}
-
- } onClick={() => void loadWorkspace()}>
- 刷新课程
-
-
-
-
LESSON ATTENDANCE · {dayjs().format('MM月DD日 dddd')}
-
任课老师课堂考勤
-
围绕“我的课程、本节课、教学班级”查看学生签到,课程开始后可拉取最新结果并处理未签到学生。
+
TEACHING DAY · {dayjs().format('MM月DD日 dddd')}
+
今天,从课程开始
+
课程开始后可查看最新打卡结果;课程截止时系统自动拉取并结算缺勤。
- } onClick={() => currentFocusSchedule && void openAttendance(currentFocusSchedule)} disabled={!currentFocusSchedule || getSchedulePhase(currentFocusSchedule.startTime, currentFocusSchedule.endTime, now) === 'upcoming'}>
- 查看本节考勤
+ } onClick={() => void loadWorkspace()}>
+ 刷新
- 今日课程{schedules.length}节待查看
+ 今日课程{schedules.length}节
- 已开始课程{startedCount}节,可处理课堂考勤
+ 已开始{startedCount}节,可查看考勤
- 当前/下一节{nextSchedule ? nextSchedule.startTime : '—'}{nextSchedule?.subject || '今天没有更多课程'}
+ 下一节{nextSchedule ? nextSchedule.startTime : '—'}{nextSchedule?.subject || '今天没有更多课程'}
-
-
-
-
-
-
-
主页主功能区
-
{currentFocusSchedule?.subject || '暂无待处理课程'}
-
- {currentFocusSchedule
- ? `${currentFocusClassName} · ${currentFocusSchedule.startTime}-${currentFocusSchedule.endTime} · 教室 ${currentFocusSchedule.classroomId}`
- : '今天没有课程,可切换日期查看其他课堂考勤。'}
-
-
-
-
- } disabled>导出本节课记录
-
-
-
-
-
课堂考勤列表
我的课程
-
课程开始后可查看本节课学生签到和异常
-
-
-
- {schedules.length === 0 ? (
-
- 今天还没有课程请联系教务管理员安排课程。
}
- />
-
- ) : filteredSchedules.length === 0 ? (
-
- 没有符合筛选条件的课程请调整左侧教学班级或课程状态。
}
- />
-
- ) : (
-
- {filteredSchedules.map((schedule, index) => {
- const phase = getSchedulePhase(schedule.startTime, schedule.endTime, now);
- return (
- void openAttendance(schedule)}
- />
- );
- })}
-
- )}
-
-
+
+
今日教学节奏
我的课程
+
课程开始后可拉取钉钉考勤记录
+
+ {schedules.length === 0 ? (
+
+ 今天还没有课程请联系教务管理员安排课程。
}
+ />
+
+ ) : (
+
+ {schedules.map((schedule, index) => {
+ const phase = getSchedulePhase(schedule.startTime, schedule.endTime, now);
+ return (
+ void openAttendance(schedule)}
+ />
+ );
+ })}
+
+ )}
+
+
setDrawerOpen(false)} width={960} title={null} className="attendance-drawer">
LESSON ATTENDANCE
-
{selectedSchedule?.subject || '本节课考勤'}
-
{selectedSchedule ? classNameById.get(selectedSchedule.classId) : ''} · 本节课 {selectedSchedule?.startTime}–{selectedSchedule?.endTime} · {dayjs().format('YYYY-MM-DD')}
+
{selectedSchedule?.subject || '课程考勤'}
+
{selectedSchedule ? classNameById.get(selectedSchedule.classId) : ''} · {selectedSchedule?.startTime}–{selectedSchedule?.endTime} · {dayjs().format('YYYY-MM-DD')}
{lessonSession && (
)}
@@ -479,8 +430,8 @@ const TeacherAttendanceWorkspace: React.FC = () => {
onChange={setCheckinFilter}
options={[
{ value: 'all', label: '全部学生' },
- { value: 'checked_in', label: '已到学生' },
- { value: 'not_checked_in', label: '未签到/旷课' },
+ { value: 'checked_in', label: '已打卡' },
+ { value: 'not_checked_in', label: '未打卡' },
]}
className="lesson-record-filter-select"
/>
@@ -496,7 +447,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
locale={{
emptyText: ,
}}
columns={[
@@ -505,7 +456,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
render: (name: string) => {name?.slice(0, 1)}{name || '-'} ,
},
{
- title: '课堂考勤操作', dataIndex: 'status', width: 250,
+ title: '考勤结果', dataIndex: 'status', width: 230,
render: (value: string, record: AttendanceRecordItem) => {
const checkedIn = value === 'present' || value === 'late';
return (
@@ -515,7 +466,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
type={checkedIn ? 'primary' : 'default'}
onClick={() => void updateLessonRecord(record, 'present')}
>
- 标记已到
+ 已打卡
);
},
},
- { title: '当前状态', dataIndex: 'status', width: 118, render: (value: string) =>