Files
gongxue-base/apps/admin/src/components/StudentProfileContent/EnrollmentsTab.tsx
wangziqi 0a685c8ea6 feat(admin): 报读记录班级选择、已报读禁用与班级跳转
- 班级名称列改为班级下拉(选项禁用已报读班级),保存时映射 className+classId 双字段
- 已有关联的班级名渲染为可点击链接跳转班级详情
2026-08-11 15:18:25 +08:00

399 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useMemo, useState } from 'react';
import { App, Button, DatePicker, Form, Input, Modal, Select, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { PlusOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import dayjs from 'dayjs';
import api from '../../api';
import { queryKeys } from '../../api/queryKeys';
import { message } from '../../ui/app-message';
import { useApiMutation } from '../../hooks/useApiMutation';
import { usePermission } from '../../hooks/usePermission';
import PermissionButton from '../PermissionButton';
import { EditableArchiveCell } from './EditableArchiveCell';
import { CLASS_TYPE_OPTIONS, COURSE_CATEGORY_OPTIONS, ENROLLMENT_STATUS_MAP, formatEnrollmentDisplayName, getClassTypeLabel, getCourseCategoryLabel, getEnrollmentStatus } from './shared';
import type { EnrollmentRecord, TabProps } from './shared';
const ENROLLMENT_FIELDS = {
courseCategory: 'courseCategory',
classType: 'classType',
className: 'className',
headTeacher: 'headTeacher',
subjectTeacher: 'subjectTeacher',
startDate: 'startDate',
endDate: 'endDate',
status: 'status',
} as const;
interface ClassOption {
id: number;
name: string;
classType: string;
startDate?: string | null;
endDate?: string | null;
isArchived: boolean;
}
interface ClassTeacher {
id: number;
userId: number;
name?: string | null;
roleType: string;
subject?: string | null;
}
export const EnrollmentsTab: React.FC<TabProps & { data: EnrollmentRecord[] }> = ({
data,
studentId,
}) => {
const { modal } = App.useApp();
const navigate = useNavigate();
const { hasPermission } = usePermission();
const canPurgeArchive = hasPermission('archive:purge');
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
const [selectedClassId, setSelectedClassId] = useState<number>();
const addEnrollmentMutation = useApiMutation(
async (payload: Record<string, unknown>) =>
api.post(`/archive/${studentId}/enrollments`, payload),
{ invalidate: [['archive', studentId]] },
);
const saveEnrollmentCellMutation = useApiMutation(
async ({ id, payload }: { id: number; payload: Record<string, unknown> }) =>
api.put(`/archive/enrollments/${id}`, payload),
{ invalidate: [['archive', studentId]] },
);
const purgeEnrollmentMutation = useApiMutation(
async (id: number) => api.delete(`/archive/enrollments/${id}/permanent`),
{ invalidate: [['archive', studentId]] },
);
const {
data: classes = [],
} = useQuery<ClassOption[]>({
queryKey: queryKeys.classes.list({ archived: false }),
queryFn: async () => {
try {
return await api.get<ClassOption[]>('/classes', { params: { isArchived: false } });
} catch {
return [];
}
},
});
const enrolledClassNames = useMemo(
() => new Set(data.map((e) => e.className).filter((name): name is string => Boolean(name))),
[data],
);
const enrolledClassIds = useMemo(
() => new Set(data.map((e) => e.classId).filter((id): id is number => id != null)),
[data],
);
const {
data: teachers = [],
} = useQuery<ClassTeacher[]>({
queryKey: ['classes', 'detail', 'teachers', selectedClassId],
enabled: modalOpen && !!selectedClassId,
queryFn: async () => {
try {
return await api.get<ClassTeacher[]>(`/classes/${selectedClassId}/teachers`);
} catch {
return [];
}
},
});
useEffect(() => {
const headTeacher = teachers.find((t) => t.roleType === 'head_teacher')?.name || '';
const subjectNames = Array.from(
new Set(
teachers
.filter((t) => t.roleType === 'subject_teacher')
.map((t) => t.name)
.filter((name): name is string => Boolean(name)),
),
);
form.setFieldsValue({ headTeacher, subjectTeacher: subjectNames.join('、') });
}, [modalOpen, selectedClassId, teachers, form]);
const handleClassChange = (classId: number) => {
setSelectedClassId(classId);
const cls = classes.find((c) => c.id === classId);
if (!cls) return;
const categoryOption = COURSE_CATEGORY_OPTIONS.find((o) => o.value === cls.classType);
form.setFieldsValue({
...(categoryOption ? { courseCategory: categoryOption.value } : {}),
// 切班时始终重置日期:新班级无起止日期时清空上一班级自动带出的值
startDate: cls.startDate ? dayjs(cls.startDate) : undefined,
endDate: cls.endDate ? dayjs(cls.endDate) : undefined,
headTeacher: '',
subjectTeacher: '',
});
};
const handleAdd = async () => {
try {
const values = await form.validateFields();
setSaving(true);
const { classId, ...rest } = values;
const selectedClass = classes.find((c) => c.id === classId);
await addEnrollmentMutation.mutateAsync({
...rest,
classId: classId ?? undefined,
className: selectedClass?.name,
startDate: values.startDate?.format('YYYY-MM-DD'),
endDate: values.endDate?.format('YYYY-MM-DD'),
});
message.success('报读记录已添加');
setModalOpen(false);
form.resetFields();
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
};
const saveCell = async (record: EnrollmentRecord, field: string, value: unknown) => {
try {
if (field === ENROLLMENT_FIELDS.className) {
// 单元格编辑提交的是选中班级 id含撤销回退的空值映射回 className+classId 双字段
const classId = value === '' || value === null || value === undefined ? null : Number(value);
const cls = classId != null ? classes.find((c) => c.id === classId) : undefined;
await saveEnrollmentCellMutation.mutateAsync({
id: record.id,
payload: { className: cls?.name ?? '', classId },
});
} else {
await saveEnrollmentCellMutation.mutateAsync({
id: record.id,
payload: { [field]: value },
});
}
message.success('报读记录已保存');
} catch {
// 错误提示由 useApiMutation 统一处理
}
};
const handlePurge = (record: EnrollmentRecord) => {
modal.confirm({
title: `永久删除报读记录(${formatEnrollmentDisplayName(record)}`,
content: '删除后不可恢复,被考试成绩引用时将无法删除。确定继续?',
okText: '永久删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await purgeEnrollmentMutation.mutateAsync(record.id);
message.success('已永久删除(不可恢复)');
} catch {
// 错误提示由 useApiMutation 统一处理
}
},
});
};
const columns: ColumnsType<EnrollmentRecord> = [
{
title: '课程类别',
dataIndex: 'courseCategory',
render: (v: string, r) => (
<EditableArchiveCell value={v} field={ENROLLMENT_FIELDS.courseCategory} record={r} editor="select" options={COURSE_CATEGORY_OPTIONS} onSave={saveCell}>
{getCourseCategoryLabel(v)}
</EditableArchiveCell>
),
},
{
title: '班型',
dataIndex: 'classType',
render: (v: string, r) => (
<EditableArchiveCell value={v} field={ENROLLMENT_FIELDS.classType} record={r} editor="select" options={CLASS_TYPE_OPTIONS} onSave={saveCell}>
{getClassTypeLabel(v)}
</EditableArchiveCell>
),
},
{
title: '班级名称',
dataIndex: 'className',
render: (v: string, r) => (
<EditableArchiveCell
value={r.classId ?? null}
field={ENROLLMENT_FIELDS.className}
record={r}
editor="select"
options={classes.map((c) => ({
value: c.id,
label: c.name,
disabled: enrolledClassIds.has(c.id) && c.id !== r.classId,
}))}
onSave={saveCell}
>
{r.classId != null ? (
<a onClick={() => navigate(`/classes/${r.classId}`)}>{v || '-'}</a>
) : (
v || '-'
)}
</EditableArchiveCell>
),
},
{
title: '班主任',
dataIndex: 'headTeacher',
render: (v: string, r) => (
<EditableArchiveCell value={v} field={ENROLLMENT_FIELDS.headTeacher} record={r} onSave={saveCell}>
{v || '-'}
</EditableArchiveCell>
),
},
{
title: '任课教师',
dataIndex: 'subjectTeacher',
render: (v: string, r) => (
<EditableArchiveCell value={v} field={ENROLLMENT_FIELDS.subjectTeacher} record={r} onSave={saveCell}>
{v || '-'}
</EditableArchiveCell>
),
},
{
title: '开始日期',
dataIndex: 'startDate',
render: (v: string, r) => (
<EditableArchiveCell value={v} field={ENROLLMENT_FIELDS.startDate} record={r} editor="date" onSave={saveCell}>
{v || '-'}
</EditableArchiveCell>
),
},
{
title: '结束日期',
dataIndex: 'endDate',
render: (v: string, r) => (
<EditableArchiveCell value={v} field={ENROLLMENT_FIELDS.endDate} record={r} editor="date" onSave={saveCell}>
{v || '-'}
</EditableArchiveCell>
),
},
{
title: '状态',
dataIndex: 'status',
render: (v: string, r) => {
const status = getEnrollmentStatus(v);
return (
<EditableArchiveCell
value={v}
field={ENROLLMENT_FIELDS.status}
record={r}
editor="select"
options={Object.entries(ENROLLMENT_STATUS_MAP).map(([value, item]) => ({
value,
label: item.text,
}))}
onSave={saveCell}
>
<Tag color={status.color}>{status.text}</Tag>
</EditableArchiveCell>
);
},
},
{
title: '操作',
render: (_: unknown, r: EnrollmentRecord) =>
r.status === 'archived' && canPurgeArchive ? (
<Button size="small" danger type="link" onClick={() => handlePurge(r)}>
</Button>
) : null,
},
];
return (
<div>
<PermissionButton
permission="student:edit"
icon={<PlusOutlined />}
type="primary"
onClick={() => {
form.resetFields();
setSelectedClassId(undefined);
setModalOpen(true);
}}
style={{ marginBottom: 16 }}
>
</PermissionButton>
<Table<EnrollmentRecord> scroll={{ x: 'max-content' }}
columns={columns}
dataSource={data}
rowKey="id"
rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50],
}}
/>
<Modal
title="添加报读记录"
open={modalOpen && hasPermission('student:edit')}
onOk={hasPermission('student:edit') ? handleAdd : undefined}
onCancel={() => {
setSelectedClassId(undefined);
setModalOpen(false);
}}
confirmLoading={saving}
>
<Form form={form} layout="vertical">
<Form.Item
name="courseCategory"
label="课程类别"
rules={[{ required: true, message: '请选择课程类别' }]}
>
<Select options={COURSE_CATEGORY_OPTIONS} placeholder="请选择" />
</Form.Item>
<Form.Item
name="classType"
label="班型"
rules={[{ required: true, message: '请选择班型' }]}
>
<Select options={CLASS_TYPE_OPTIONS} placeholder="请选择" />
</Form.Item>
<Form.Item
name="classId"
label="班级名称"
extra="选择班级后自动带出课程类别、日期与教师信息"
>
<Select
showSearch
optionFilterProp="label"
placeholder="搜索并选择班级"
onChange={handleClassChange}
options={classes.map((c) => ({
value: c.id,
label: enrolledClassNames.has(c.name) ? `${c.name}(已报读)` : c.name,
disabled: enrolledClassNames.has(c.name) || enrolledClassIds.has(c.id),
}))}
/>
</Form.Item>
<Form.Item name="headTeacher" label="班主任">
<Input placeholder="班主任姓名" />
</Form.Item>
<Form.Item name="subjectTeacher" label="任课教师">
<Input placeholder="任课教师姓名" />
</Form.Item>
<Form.Item name="startDate" label="开始日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="endDate" label="结束日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
</div>
);
};