Files
gongxue-base/apps/admin/src/pages/Classes/ClassDetailTabs.tsx

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>
);
};