feat: 重构各业务模块管理页面与服务
This commit is contained in:
557
apps/admin/src/pages/Classes/ClassDetailTabs.tsx
Normal file
557
apps/admin/src/pages/Classes/ClassDetailTabs.tsx
Normal file
@@ -0,0 +1,557 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { DownloadOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { useUserStore } from '../../store/user/userStore';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { buildTeacherCandidateOptions, type TeacherCandidateUser } from './teacher-candidate';
|
||||
|
||||
export interface ClassStudent {
|
||||
id: number;
|
||||
studentId: number;
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
joinDate: string;
|
||||
leaveDate: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ClassTeacher {
|
||||
id: number;
|
||||
userId: number;
|
||||
username: string;
|
||||
roleType: string;
|
||||
subject: string | null;
|
||||
}
|
||||
|
||||
export interface ClassScheduleItem {
|
||||
id: number;
|
||||
classId: number;
|
||||
classroomId: number;
|
||||
classroomName: string;
|
||||
weekDay: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
attendanceAdvanceMinutes: number;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
subject: string;
|
||||
teacherId: number | null;
|
||||
scheduleType: string;
|
||||
status: string;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface AttendanceSummary {
|
||||
total: number;
|
||||
present: number;
|
||||
late: number;
|
||||
absent: number;
|
||||
leave: number;
|
||||
presentRate: number;
|
||||
absentRate: number;
|
||||
lateRate: number;
|
||||
leaveRate: number;
|
||||
}
|
||||
|
||||
export interface ClassDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
classType: string;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
status: string;
|
||||
maxStudents: number;
|
||||
notes: string | null;
|
||||
studentCount: number;
|
||||
students?: ClassStudent[];
|
||||
teachers?: ClassTeacher[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface StudentItem {
|
||||
id: number;
|
||||
name: string;
|
||||
studentNo?: string;
|
||||
}
|
||||
|
||||
export const STATUS_MAP: Record<string, { color: string; text: string }> = {
|
||||
enrolling: { color: 'blue', text: '招生中' },
|
||||
active: { color: 'green', text: '在读' },
|
||||
ended: { color: 'default', text: '结课' },
|
||||
suspended: { color: 'orange', text: '停课' },
|
||||
};
|
||||
|
||||
export const TYPE_MAP: Record<string, string> = {
|
||||
culture: '文化课',
|
||||
professional: '专业课',
|
||||
bootcamp: '集训营',
|
||||
sprint: '冲刺营',
|
||||
};
|
||||
|
||||
export const ROLE_MAP: Record<string, string> = {
|
||||
subject_teacher: '任课老师',
|
||||
head_teacher: '班主任',
|
||||
life_teacher: '生活老师',
|
||||
academic_teacher: '学服老师',
|
||||
};
|
||||
|
||||
export const WEEK_DAY_MAP: Record<number, string> = {
|
||||
1: '周一',
|
||||
2: '周二',
|
||||
3: '周三',
|
||||
4: '周四',
|
||||
5: '周五',
|
||||
6: '周六',
|
||||
7: '周日',
|
||||
};
|
||||
|
||||
export const SCHEDULE_TYPE_MAP: Record<string, string> = {
|
||||
INTERNAL: '内部排课',
|
||||
RENTAL: '租赁',
|
||||
};
|
||||
|
||||
export const ClassInfoTab: React.FC<{
|
||||
detail: ClassDetail;
|
||||
teachers: ClassTeacher[];
|
||||
editingInfo: boolean;
|
||||
editForm: ReturnType<typeof Form.useForm>[0];
|
||||
onSave: () => void;
|
||||
onEdit: () => void;
|
||||
onCancel: () => void;
|
||||
getTeacherName: (teacher: ClassTeacher) => string;
|
||||
}> = ({ detail, teachers, editingInfo, editForm, onSave, onEdit, onCancel, getTeacherName }) => {
|
||||
return (
|
||||
<div>
|
||||
{editingInfo ? (
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
name: detail.name,
|
||||
code: detail.code,
|
||||
classType: detail.classType,
|
||||
startDate: detail.startDate ? dayjs(detail.startDate) : undefined,
|
||||
endDate: detail.endDate ? dayjs(detail.endDate) : undefined,
|
||||
maxStudents: detail.maxStudents,
|
||||
status: detail.status,
|
||||
notes: detail.notes,
|
||||
}}
|
||||
>
|
||||
<Space wrap>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="code" label="编码">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="classType" label="班型">
|
||||
<Select
|
||||
options={Object.entries(TYPE_MAP).map(([k, v]) => ({
|
||||
value: k,
|
||||
label: v,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="startDate" label="开班">
|
||||
<DatePicker />
|
||||
</Form.Item>
|
||||
<Form.Item name="endDate" label="结课">
|
||||
<DatePicker />
|
||||
</Form.Item>
|
||||
<Form.Item name="maxStudents" label="人数上限">
|
||||
<InputNumber min={1} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
options={Object.entries(STATUS_MAP).map(([k, v]) => ({
|
||||
value: k,
|
||||
label: v.text,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<PermissionButton permission="class:edit" type="primary" onClick={onSave}>
|
||||
保存
|
||||
</PermissionButton>
|
||||
<Button onClick={onCancel}>取消</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
) : (
|
||||
<div>
|
||||
<Descriptions column={3} bordered size="small">
|
||||
<Descriptions.Item label="班型">{TYPE_MAP[detail.classType]}</Descriptions.Item>
|
||||
<Descriptions.Item label="开班日期">
|
||||
{detail.startDate ? dayjs(detail.startDate).format('YYYY-MM-DD') : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="结课日期">
|
||||
{detail.endDate ? dayjs(detail.endDate).format('YYYY-MM-DD') : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="学员">
|
||||
{detail.studentCount}/{detail.maxStudents || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="班主任">
|
||||
{(() => {
|
||||
const headTeacher = teachers.find(
|
||||
(teacher) => teacher.roleType === 'head_teacher',
|
||||
);
|
||||
return headTeacher ? getTeacherName(headTeacher) : '-';
|
||||
})()}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">{detail.notes || '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<PermissionButton permission="class:edit" style={{ marginTop: 16 }} onClick={onEdit}>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ClassStudentsTab: React.FC<{
|
||||
id?: string;
|
||||
detail?: ClassDetail | null;
|
||||
students: ClassStudent[];
|
||||
allStudents: StudentItem[];
|
||||
selectedStudentIds: number[];
|
||||
modalOpen: boolean;
|
||||
onOpen: () => void;
|
||||
onAdd: () => void;
|
||||
onClose: () => void;
|
||||
onRemove: (studentId: number) => void;
|
||||
onSelect: (ids: number[]) => void;
|
||||
}> = ({
|
||||
id,
|
||||
detail,
|
||||
students,
|
||||
allStudents,
|
||||
selectedStudentIds,
|
||||
modalOpen,
|
||||
onOpen,
|
||||
onAdd,
|
||||
onClose,
|
||||
onRemove,
|
||||
onSelect,
|
||||
}) => {
|
||||
const studentColumns: ColumnsType<ClassStudent> = [
|
||||
{ title: '姓名', dataIndex: 'studentName' },
|
||||
{ title: '学号', dataIndex: 'studentNo' },
|
||||
{ title: '加入日期', dataIndex: 'joinDate' },
|
||||
{ title: '离班日期', dataIndex: 'leaveDate', render: (v: string | null) => v || '-' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (v: string) => (
|
||||
<Tag color={v === 'active' ? 'green' : 'default'}>{v === 'active' ? '在读' : '已离班'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
render: (_: unknown, r: ClassStudent) =>
|
||||
r.status === 'active' ? (
|
||||
<Popconfirm title="确认移除?" onConfirm={() => onRemove(r.studentId)}>
|
||||
<PermissionButton permission="class:edit" size="small" danger>
|
||||
移除
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div>
|
||||
<PermissionButton
|
||||
permission="class:edit"
|
||||
icon={<PlusOutlined />}
|
||||
type="primary"
|
||||
onClick={onOpen}
|
||||
style={{ marginBottom: 16, marginRight: 8 }}
|
||||
>
|
||||
添加学员
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="class:view"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => {
|
||||
const token = useUserStore.getState().token;
|
||||
fetch(`/api/classes/${id}/roster/export`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('导出失败');
|
||||
return res.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `班级花名册-${detail?.name || id}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
message.success('花名册导出成功');
|
||||
})
|
||||
.catch(() => message.error('花名册导出失败'));
|
||||
}}
|
||||
>
|
||||
导出花名册
|
||||
</PermissionButton>
|
||||
<Table<ClassStudent>
|
||||
columns={studentColumns}
|
||||
dataSource={students}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
defaultPageSize: 20,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
}}
|
||||
/>
|
||||
<Modal title="添加学员" open={modalOpen} onOk={onAdd} onCancel={onClose}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ width: '100%' }}
|
||||
placeholder="选择学员"
|
||||
value={selectedStudentIds}
|
||||
onChange={onSelect}
|
||||
options={allStudents.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.name} (${s.studentNo || s.id})`,
|
||||
}))}
|
||||
filterOption={(input, option) =>
|
||||
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ClassTeachersTab: React.FC<{
|
||||
teachers: ClassTeacher[];
|
||||
allUsers: TeacherCandidateUser[];
|
||||
teacherRole: string;
|
||||
teacherSubject: string;
|
||||
teacherUserId?: number;
|
||||
modalOpen: boolean;
|
||||
onOpen: () => void;
|
||||
onAdd: () => void;
|
||||
onClose: () => void;
|
||||
onRemove: (userId: number) => void;
|
||||
onRoleChange: (role: string) => void;
|
||||
onSubjectChange: (subject: string) => void;
|
||||
onUserChange: (userId?: number) => void;
|
||||
getTeacherName: (teacher: ClassTeacher) => string;
|
||||
}> = ({
|
||||
teachers,
|
||||
allUsers,
|
||||
teacherRole,
|
||||
teacherSubject,
|
||||
teacherUserId,
|
||||
modalOpen,
|
||||
onOpen,
|
||||
onAdd,
|
||||
onClose,
|
||||
onRemove,
|
||||
onRoleChange,
|
||||
onSubjectChange,
|
||||
onUserChange,
|
||||
getTeacherName,
|
||||
}) => {
|
||||
const teacherColumns: ColumnsType<ClassTeacher> = [
|
||||
{ title: '姓名', render: (_: unknown, teacher) => getTeacherName(teacher) },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'roleType',
|
||||
render: (v: string) => <Tag>{ROLE_MAP[v] || v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '科目',
|
||||
dataIndex: 'subject',
|
||||
render: (v: string | null) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
render: (_: unknown, r: ClassTeacher) => (
|
||||
<Popconfirm title="确认移除?" onConfirm={() => onRemove(r.userId)}>
|
||||
<PermissionButton permission="class:edit" size="small" danger>
|
||||
移除
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div>
|
||||
<PermissionButton
|
||||
permission="class:edit"
|
||||
icon={<PlusOutlined />}
|
||||
type="primary"
|
||||
onClick={onOpen}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
添加教师
|
||||
</PermissionButton>
|
||||
<Table<ClassTeacher>
|
||||
columns={teacherColumns}
|
||||
dataSource={teachers}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
defaultPageSize: 20,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
}}
|
||||
/>
|
||||
<Modal title="添加教师" open={modalOpen} onOk={onAdd} onCancel={onClose}>
|
||||
<Space orientation="vertical" style={{ width: '100%' }}>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="搜索姓名、用户名、角色或学科"
|
||||
value={teacherUserId}
|
||||
onChange={onUserChange}
|
||||
options={buildTeacherCandidateOptions(allUsers)}
|
||||
notFoundContent="没有可分配的工作人员账号"
|
||||
/>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={teacherRole}
|
||||
onChange={onRoleChange}
|
||||
options={Object.entries(ROLE_MAP).map(([k, v]) => ({
|
||||
value: k,
|
||||
label: v,
|
||||
}))}
|
||||
/>
|
||||
{teacherRole === 'subject_teacher' && (
|
||||
<Input
|
||||
placeholder="任教科目"
|
||||
value={teacherSubject}
|
||||
onChange={(e) => onSubjectChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ClassScheduleTab: React.FC<{
|
||||
schedules: ClassScheduleItem[];
|
||||
scheduleDateRange: [dayjs.Dayjs | null, dayjs.Dayjs | null];
|
||||
onRangeChange: (dates: [dayjs.Dayjs | null, dayjs.Dayjs | null]) => void;
|
||||
}> = ({ schedules, scheduleDateRange, onRangeChange }) => {
|
||||
const scheduleColumns: ColumnsType<ClassScheduleItem> = [
|
||||
{ title: '教室', dataIndex: 'classroomName', render: (v: string | null) => v || '-' },
|
||||
{ title: '星期', dataIndex: 'weekDay', render: (v: number) => WEEK_DAY_MAP[v] || v },
|
||||
{
|
||||
title: '时间',
|
||||
render: (_: unknown, r: ClassScheduleItem) => `${r.startTime} - ${r.endTime}`,
|
||||
},
|
||||
{
|
||||
title: '签到窗口',
|
||||
render: (_: unknown, r: ClassScheduleItem) =>
|
||||
`课前 ${r.attendanceAdvanceMinutes ?? 30} 分钟至下课`,
|
||||
},
|
||||
{
|
||||
title: '日期范围',
|
||||
render: (_: unknown, r: ClassScheduleItem) => `${r.startDate} ~ ${r.endDate}`,
|
||||
},
|
||||
{ title: '科目', dataIndex: 'subject' },
|
||||
{ title: '类型', dataIndex: 'scheduleType', render: (v: string) => SCHEDULE_TYPE_MAP[v] || v },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (v: string) => (
|
||||
<Tag color={v === 'active' ? 'green' : 'default'}>{v === 'active' ? '启用' : v}</Tag>
|
||||
),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, maxWidth: '100%' }} wrap>
|
||||
<DatePicker.RangePicker
|
||||
value={scheduleDateRange}
|
||||
onChange={(dates) => onRangeChange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null])}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
/>
|
||||
</Space>
|
||||
<Table<ClassScheduleItem>
|
||||
columns={scheduleColumns}
|
||||
dataSource={schedules}
|
||||
rowKey="id"
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
defaultPageSize: 20,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ClassAttendanceTab: React.FC<{
|
||||
attendanceSummary: AttendanceSummary | null;
|
||||
attendanceDateRange: [dayjs.Dayjs | null, dayjs.Dayjs | null];
|
||||
onRangeChange: (dates: [dayjs.Dayjs | null, dayjs.Dayjs | null]) => void;
|
||||
}> = ({ attendanceSummary, attendanceDateRange, onRangeChange }) => {
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, maxWidth: '100%' }} wrap>
|
||||
<DatePicker.RangePicker
|
||||
value={attendanceDateRange}
|
||||
onChange={(dates) => onRangeChange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null])}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
/>
|
||||
</Space>
|
||||
{attendanceSummary && (
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} md={6}>
|
||||
<Card bordered={false}>
|
||||
<Statistic title="总记录" value={attendanceSummary.total} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Card bordered={false}>
|
||||
<Statistic title="出勤率" value={attendanceSummary.presentRate} suffix="%" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Card bordered={false}>
|
||||
<Statistic title="缺勤率" value={attendanceSummary.absentRate} suffix="%" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Card bordered={false}>
|
||||
<Statistic title="迟到率" value={attendanceSummary.lateRate} suffix="%" />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user