feat: P2-12 Teacher profile/workspace + P2-13 Multi-class comparison

P2-12 Teacher Management:
- Add profile JSON field (subjects, joinedAt, qualifications) to User entity
- Add GET/PUT /rbac/users/:id/profile endpoints with UpdateProfileDto
- Add GET /rbac/teacher-workspace endpoint returning assignedClasses,
  todaySchedules, and myStudents
- Create /teacher-workspace page with tabs (My Classes, Today's Schedule,
  My Students)
- Add route with PermissionRoute and menu item in MainLayout

P2-13 Student Archive Multi-class:
- Add GET /students/:id/compare-classes endpoint returning side-by-side
  enrollment data with per-class attendance statistics
- Students module now includes ClassStudent and AttendanceRecord entities
- No 2-enrollment cap existed in the codebase; student enrollments
  already return all records via class-student table
This commit is contained in:
2026-07-05 20:42:27 +08:00
parent 80ce911b88
commit 5df70a8af0
11 changed files with 438 additions and 6 deletions

View File

@@ -24,6 +24,7 @@ import SchedulesPage from './pages/Schedules';
import RolesPage from './pages/Roles';
import PermissionsPage from './pages/Permissions';
import AttendancePage from './pages/Attendance';
import TeacherWorkspacePage from './pages/TeacherWorkspace';
import PermissionRoute from './components/PermissionRoute';
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
@@ -220,6 +221,15 @@ const App: React.FC = () => {
</PermissionRoute>
}
/>
<Route
path="teacher-workspace"
element={
<PermissionRoute permission="class:view">
<TeacherWorkspacePage />
</PermissionRoute>
}
/>
</Route>
</Routes>
</BrowserRouter>

View File

@@ -23,6 +23,7 @@ import {
SafetyOutlined,
KeyOutlined,
CheckCircleOutlined,
LaptopOutlined,
} from '@ant-design/icons';
import { usePermission } from '../hooks/usePermission';
@@ -51,6 +52,7 @@ const allMenuItems: MenuItemType[] = [
{ key: '/deposits', icon: <WalletOutlined />, label: '押金管理', permission: 'deposit:view' },
{ key: '/bills', icon: <FileTextOutlined />, label: '账单管理', permission: 'bill:view' },
{ key: '/classes', icon: <TeamOutlined />, label: '班级管理', permission: 'class:view' },
{ key: '/teacher-workspace', icon: <LaptopOutlined />, label: '教师工作台', permission: 'class:view' },
{
key: 'classroom-group',
icon: <ReadOutlined />,

View File

@@ -0,0 +1,187 @@
import React, { useEffect, useState } from 'react';
import { Card, Tabs, Table, Tag, Empty, Spin } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import api from '../../api';
interface AssignedClass {
classId: number;
className: string;
classCode: string;
roleType: string;
subject: string;
}
interface ScheduleItem {
id: number;
classId: number;
weekDay: number;
startTime: string;
endTime: string;
subject: string;
scheduleType: string;
}
interface StudentItem {
studentId: number;
studentName: string;
studentNo: string;
className: string;
classId: number;
joinDate: string;
}
interface WorkspaceData {
assignedClasses: AssignedClass[];
todaySchedules: ScheduleItem[];
myStudents: StudentItem[];
}
const ROLE_LABELS: Record<string, string> = {
head_teacher: '班主任',
life_teacher: '生活老师',
academic_teacher: '教务老师',
subject_teacher: '任课教师',
};
const WEEKDAY_LABELS: Record<string, string> = {
'1': '周一',
'2': '周二',
'3': '周三',
'4': '周四',
'5': '周五',
'6': '周六',
'7': '周日',
};
const TeacherWorkspacePage: React.FC = () => {
const [data, setData] = useState<WorkspaceData | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const res = (await api.get('/rbac/teacher-workspace')) as WorkspaceData;
setData(res);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const classColumns: ColumnsType<AssignedClass> = [
{
title: '班级名称',
dataIndex: 'className',
render: (v: string, r: AssignedClass) => `${v} (${r.classCode})`,
},
{
title: '角色',
dataIndex: 'roleType',
render: (v: string) => <Tag>{ROLE_LABELS[v] || v}</Tag>,
},
{
title: '科目',
dataIndex: 'subject',
render: (v: string | null) => v || '-',
},
];
const scheduleColumns: ColumnsType<ScheduleItem> = [
{
title: '时间',
key: 'time',
render: (_: unknown, r: ScheduleItem) => `${r.startTime} - ${r.endTime}`,
},
{
title: '星期',
dataIndex: 'weekDay',
render: (v: number) => <Tag>{WEEKDAY_LABELS[String(v)] || v}</Tag>,
},
{
title: '科目',
dataIndex: 'subject',
},
{
title: '类型',
dataIndex: 'scheduleType',
render: (v: string) => (
<Tag color={v === 'INTERNAL' ? 'blue' : 'orange'}>
{v === 'INTERNAL' ? '内部课程' : '租赁'}
</Tag>
),
},
];
const studentColumns: ColumnsType<StudentItem> = [
{ title: '姓名', dataIndex: 'studentName' },
{ title: '学号', dataIndex: 'studentNo', render: (v: string) => v || '-' },
{ title: '班级', dataIndex: 'className' },
{ title: '加入日期', dataIndex: 'joinDate', render: (v: string) => v || '-' },
];
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
return (
<Card title="教师工作台">
<Tabs
defaultActiveKey="classes"
items={[
{
key: 'classes',
label: `我的班级 (${data?.assignedClasses.length || 0})`,
children: data?.assignedClasses.length ? (
<Table<AssignedClass>
columns={classColumns}
dataSource={data.assignedClasses}
rowKey="classId"
pagination={{ pageSize: 20, showTotal: (total) => `${total} 个班级` }}
/>
) : (
<Empty description="暂无分配的班级" />
),
},
{
key: 'schedule',
label: `今日课程 (${data?.todaySchedules.length || 0})`,
children: data?.todaySchedules.length ? (
<Table<ScheduleItem>
columns={scheduleColumns}
dataSource={data.todaySchedules}
rowKey="id"
pagination={{ pageSize: 20, showTotal: (total) => `${total}` }}
/>
) : (
<Empty description="今日无排课" />
),
},
{
key: 'students',
label: `我的学生 (${data?.myStudents.length || 0})`,
children: data?.myStudents.length ? (
<Table<StudentItem>
columns={studentColumns}
dataSource={data.myStudents}
rowKey="studentId"
pagination={{ pageSize: 20, showTotal: (total) => `${total}` }}
/>
) : (
<Empty description="暂无学生" />
),
},
]}
/>
</Card>
);
};
export default TeacherWorkspacePage;