558 lines
16 KiB
TypeScript
558 lines
16 KiB
TypeScript
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>
|
|
);
|
|
};
|