{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..dd834d6 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 {
@@ -376,11 +379,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 +677,6 @@
min-width: 54px;
}
-
.punch-device-cell {
display: flex;
flex-direction: column;
@@ -692,9 +709,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 +774,10 @@
.student-filter-panel {
display: grid;
- grid-template-columns: minmax(260px, 1.4fr) repeat(4, minmax(150px, 1fr)) auto;
+ grid-template-columns: minmax(230px, 1.4fr) repeat(4, minmax(120px, 1fr)) auto;
gap: 12px;
align-items: end;
+ min-width: 0;
margin-bottom: 16px;
padding: 16px;
border: 1px solid var(--student-line);
@@ -783,13 +803,15 @@
.student-filter-actions {
display: flex;
+ flex-wrap: wrap;
gap: 8px;
}
.student-class-overview {
display: grid;
- grid-template-columns: minmax(320px, .9fr) minmax(0, 1.1fr);
+ grid-template-columns: minmax(280px, 0.9fr) minmax(0, 1.1fr);
gap: 16px;
+ min-width: 0;
margin-bottom: 16px;
}
@@ -802,6 +824,12 @@
background: var(--student-surface);
}
+.student-class-overview > *,
+.student-workspace,
+.student-record-card {
+ min-width: 0;
+}
+
.student-class-identity {
display: flex;
flex-direction: column;
@@ -854,7 +882,8 @@
.student-metric-strip {
display: grid;
- grid-template-columns: repeat(6, minmax(82px, 1fr));
+ grid-template-columns: repeat(6, minmax(70px, 1fr));
+ min-width: 0;
overflow: hidden;
}
@@ -930,18 +959,20 @@
.student-workspace-tools {
display: flex;
align-items: center;
+ flex-wrap: wrap;
gap: 8px;
+ min-width: 0;
}
.student-workspace-tools .ant-input-search {
- width: 240px;
+ width: min(240px, 100%);
}
.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 +991,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);
@@ -989,7 +1030,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,
@@ -1139,34 +1183,53 @@
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: span 2;
}
.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(6, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.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: stretch;
+ flex-direction: column;
+ }
+
+ .student-center-title {
align-items: flex-start;
flex-direction: column;
+ gap: 2px;
}
.student-filter-panel,
@@ -1174,8 +1237,48 @@
grid-template-columns: 1fr;
}
+ .student-filter-field--date {
+ grid-column: auto;
+ }
+
+ .student-filter-actions > *,
+ .student-center-actions .ant-btn,
.student-workspace-tools,
.student-workspace-tools .ant-input-search {
width: 100%;
}
+
+ .student-filter-actions > * {
+ flex: 1;
+ }
+
+ .student-metric-strip {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ .student-metric-card {
+ min-height: 112px;
+ border-bottom: 1px solid var(--student-line);
+ }
+
+ .student-workspace {
+ padding-inline: 12px;
+ }
+
+ .student-card-grid {
+ 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);
+ }
}
diff --git a/apps/admin/src/pages/Attendance/index.tsx b/apps/admin/src/pages/Attendance/index.tsx
index a047553..fcbc8a1 100644
--- a/apps/admin/src/pages/Attendance/index.tsx
+++ b/apps/admin/src/pages/Attendance/index.tsx
@@ -7,6 +7,7 @@ import {
Col,
DatePicker,
Drawer,
+ Grid,
Empty,
Form,
Input,
@@ -192,15 +193,24 @@ function LessonCheckinSummaryStrip({ records }: { records: readonly AttendanceRe
-
打卡率{summary.total} 人
+
+ 打卡率
+ {summary.total} 人
+
勤
-
{summary.checkedIn}已打卡
+
+ {summary.checkedIn}
+ 已打卡
+
缺
-
{summary.notCheckedIn}未打卡
+
+ {summary.notCheckedIn}
+ 未打卡
+
);
@@ -262,7 +272,6 @@ const TeacherAttendanceWorkspace: React.FC = () => {
[workspace],
);
-
const openAttendance = useCallback(async (schedule: TodaySchedule) => {
setStudentKeyword('');
setCheckinFilter('all');
@@ -288,29 +297,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',
@@ -354,41 +359,98 @@ const TeacherAttendanceWorkspace: React.FC = () => {
-
LESSON ATTENDANCE · {dayjs().format('MM月DD日 dddd')}
+
+ LESSON ATTENDANCE · {dayjs().format('MM月DD日 dddd')}
+
任课老师课堂考勤
-
围绕“我的课程、本节课、教学班级”查看学生签到,课程开始后可拉取最新结果并处理未签到学生。
+
+ 围绕“我的课程、本节课、教学班级”查看学生签到,课程开始后可拉取最新结果并处理未签到学生。
+
- } onClick={() => currentFocusSchedule && void openAttendance(currentFocusSchedule)} disabled={!currentFocusSchedule || getSchedulePhase(currentFocusSchedule.startTime, currentFocusSchedule.endTime, now) === 'upcoming'}>
+ }
+ onClick={() => currentFocusSchedule && void openAttendance(currentFocusSchedule)}
+ disabled={
+ !currentFocusSchedule ||
+ getSchedulePhase(currentFocusSchedule.startTime, currentFocusSchedule.endTime, now) ===
+ 'upcoming'
+ }
+ >
查看本节考勤
- 今日课程{schedules.length}节待查看
+
+ 今日课程
+ {schedules.length}
+ 节待查看
+
- 已开始课程{startedCount}节,可处理课堂考勤
+
+ 已开始课程
+ {startedCount}
+ 节,可处理课堂考勤
+
- 当前/下一节{nextSchedule ? nextSchedule.startTime : '—'}{nextSchedule?.subject || '今天没有更多课程'}
+
+ 当前/下一节
+ {nextSchedule ? nextSchedule.startTime : '—'}
+ {nextSchedule?.subject || '今天没有更多课程'}
+
@@ -403,15 +465,31 @@ const TeacherAttendanceWorkspace: React.FC = () => {
-
+ } disabled>
+ 导出本节课记录
+
-
课堂考勤列表
我的课程
+
+ 课堂考勤列表
+
我的课程
+
课程开始后可查看本节课学生签到和异常
@@ -420,14 +498,24 @@ const TeacherAttendanceWorkspace: React.FC = () => {
今天还没有课程请联系教务管理员安排课程。
}
+ description={
+