feat(admin): 班级花名册导入确认流程与教师候选人
- 提交体条件化:空数组不发送,修复全匹配/全新建两种场景被后端 @ArrayMinSize 拒 400 - 无论是否新建学生都先确认,避免已匹配学生被一键加入无提示 - 学员下拉保留学号/ID 兜底区分同名;科目去重键小写对齐后端唯一索引 - 任课老师空科目前端拦截;教师候选人相关调整
This commit is contained in:
37
apps/admin/src/pages/Classes/ClassDetailTabs.test.ts
Normal file
37
apps/admin/src/pages/Classes/ClassDetailTabs.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatStudentOptionLabel, type StudentItem } from './ClassDetailTabs';
|
||||
|
||||
const student = (overrides: Partial<StudentItem>): StudentItem => ({
|
||||
id: 1,
|
||||
name: '张三',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('formatStudentOptionLabel', () => {
|
||||
it('masks a full phone number in the dropdown label', () => {
|
||||
expect(formatStudentOptionLabel(student({ phone: '13800138000' }))).toBe('张三(138****8000)');
|
||||
});
|
||||
|
||||
it('masks phone numbers with a country code prefix', () => {
|
||||
expect(formatStudentOptionLabel(student({ phone: '+8613800138000' }))).toBe(
|
||||
'张三(+86****8000)',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows 无手机号 with id disambiguator when phone is missing or blank', () => {
|
||||
expect(formatStudentOptionLabel(student({}))).toBe('张三(无手机号 #1)');
|
||||
expect(formatStudentOptionLabel(student({ phone: '' }))).toBe('张三(无手机号 #1)');
|
||||
expect(formatStudentOptionLabel(student({ phone: ' ' }))).toBe('张三(无手机号 #1)');
|
||||
});
|
||||
|
||||
it('keeps studentNo to disambiguate same-name students without phone', () => {
|
||||
expect(formatStudentOptionLabel(student({ studentNo: 'S001' }))).toBe('张三(S001)');
|
||||
expect(formatStudentOptionLabel(student({ studentNo: 'S001', phone: '13800138000' }))).toBe(
|
||||
'张三(S001 138****8000)',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to a dash name when name is missing', () => {
|
||||
expect(formatStudentOptionLabel(student({ name: '' }))).toBe('-(无手机号 #1)');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Col,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
@@ -16,15 +19,20 @@ import {
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
Upload,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { DownloadOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { DownloadOutlined, PlusOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { useUserStore } from '../../store/user/userStore';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { QueryEmpty } from '../../components/QueryState';
|
||||
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
|
||||
import { useDownload } from '../../hooks/useDownload';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { getErrorMessage } from '../../utils/error';
|
||||
import { maskIdNumber, maskPhone } from '../../utils/sensitive';
|
||||
import api from '../../api';
|
||||
import { buildTeacherCandidateOptions, type TeacherCandidateUser } from './teacher-candidate';
|
||||
import { saveAs } from 'file-saver';
|
||||
|
||||
@@ -44,6 +52,7 @@ export interface ClassTeacher {
|
||||
username: string;
|
||||
roleType: string;
|
||||
subject: string | null;
|
||||
subjects: string[];
|
||||
}
|
||||
|
||||
export interface ClassScheduleItem {
|
||||
@@ -97,6 +106,21 @@ export interface StudentItem {
|
||||
id: number;
|
||||
name: string;
|
||||
studentNo?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
/** 添加学员下拉选项文案:姓名(学号 脱敏手机号);无任何标识时显示「无手机号」。
|
||||
* 保留非敏感学号用于区分同名且无手机号的学生,避免下拉出现多个相同的「姓名(无手机号)」。 */
|
||||
export function formatStudentOptionLabel(student: StudentItem): string {
|
||||
const name = student.name?.trim() || '-';
|
||||
const studentNo = student.studentNo?.trim();
|
||||
const phone = student.phone?.trim();
|
||||
const parts: string[] = [];
|
||||
if (studentNo) parts.push(studentNo);
|
||||
if (phone) parts.push(maskPhone(phone));
|
||||
// 无任何标识时用 id 兜底区分同名学生(学号/手机号均缺失的情况)
|
||||
if (parts.length === 0) parts.push(student.id ? `无手机号 #${student.id}` : '无手机号');
|
||||
return `${name}(${parts.join(' ')})`;
|
||||
}
|
||||
|
||||
export const STATUS_MAP: Record<string, { color: string; text: string }> = {
|
||||
@@ -237,6 +261,347 @@ export const ClassInfoTab: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
export interface RosterPreviewStudent {
|
||||
id: number;
|
||||
name: string;
|
||||
studentNo: string | null;
|
||||
}
|
||||
|
||||
export interface RosterPreviewRow {
|
||||
rowNumber: number;
|
||||
name: string;
|
||||
phone?: string;
|
||||
idNumber?: string;
|
||||
studentNo?: string;
|
||||
status: 'matched' | 'unmatched' | 'in-class' | 'conflict';
|
||||
student?: RosterPreviewStudent;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface RosterPreviewResult {
|
||||
rows: RosterPreviewRow[];
|
||||
summary: {
|
||||
total: number;
|
||||
matched: number;
|
||||
unmatched: number;
|
||||
inClass: number;
|
||||
conflict: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RosterCommitResult {
|
||||
added: number;
|
||||
created: number;
|
||||
skipped: number;
|
||||
conflicts: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
function isCreateRowSelected(
|
||||
row: RosterPreviewRow,
|
||||
createRowNumbers: Set<number>,
|
||||
): boolean {
|
||||
return (
|
||||
row.status === 'unmatched' &&
|
||||
createRowNumbers.has(row.rowNumber) &&
|
||||
!!row.name?.trim()
|
||||
);
|
||||
}
|
||||
|
||||
function renderMatchResult(row: RosterPreviewRow) {
|
||||
if (row.status === 'matched' || row.status === 'in-class') {
|
||||
return (
|
||||
<span>
|
||||
{row.student?.name}
|
||||
{row.student?.studentNo ? `(${row.student.studentNo})` : ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (row.reason) return <span>{row.reason}</span>;
|
||||
return null;
|
||||
}
|
||||
|
||||
const ROSTER_STATUS_META: Record<RosterPreviewRow['status'], { color: string; text: string }> = {
|
||||
matched: { color: 'green', text: '匹配成功' },
|
||||
unmatched: { color: 'orange', text: '未匹配' },
|
||||
'in-class': { color: 'default', text: '已在本班' },
|
||||
conflict: { color: 'red', text: '冲突' },
|
||||
};
|
||||
|
||||
export const ClassRosterImportModal: React.FC<{
|
||||
id?: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onImported: () => void;
|
||||
}> = ({ id, open, onClose, onImported }) => {
|
||||
const [step, setStep] = useState<1 | 2>(1);
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [preview, setPreview] = useState<RosterPreviewResult | null>(null);
|
||||
const [createRowNumbers, setCreateRowNumbers] = useState<Set<number>>(new Set());
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [committing, setCommitting] = useState(false);
|
||||
const { downloading: templateDownloading, run: runTemplateDownload } = useDownload();
|
||||
|
||||
const reset = () => {
|
||||
setStep(1);
|
||||
setPreview(null);
|
||||
setCreateRowNumbers(new Set());
|
||||
setFileName('');
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (committing || previewLoading) return;
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
if (previewLoading) return;
|
||||
if (!id) {
|
||||
message.error('缺少班级信息,无法导入');
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
const res = await api.post<RosterPreviewResult>(
|
||||
`/classes/${id}/students/import-preview`,
|
||||
formData,
|
||||
);
|
||||
setPreview(res);
|
||||
setCreateRowNumbers(
|
||||
new Set(
|
||||
res.rows
|
||||
.filter((row) => row.status === 'unmatched' && !!row.name?.trim())
|
||||
.map((row) => row.rowNumber),
|
||||
),
|
||||
);
|
||||
setFileName(file.name);
|
||||
setStep(2);
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error, '导入文件解析失败'));
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const { modal } = App.useApp();
|
||||
|
||||
const createCount = preview
|
||||
? preview.rows.filter((row) => isCreateRowSelected(row, createRowNumbers)).length
|
||||
: 0;
|
||||
const canCommit =
|
||||
!!preview && (preview.rows.some((row) => row.status === 'matched') || createCount > 0);
|
||||
|
||||
const doCommit = async () => {
|
||||
if (!id || !preview) return;
|
||||
setCommitting(true);
|
||||
try {
|
||||
const matchedIds = preview.rows
|
||||
.filter((row) => row.status === 'matched')
|
||||
.map((row) => row.student?.id)
|
||||
.filter((id): id is number => typeof id === 'number');
|
||||
const createRows = preview.rows
|
||||
.filter((row) => isCreateRowSelected(row, createRowNumbers))
|
||||
.map((row) => ({
|
||||
name: row.name,
|
||||
phone: row.phone,
|
||||
idNumber: row.idNumber,
|
||||
studentNo: row.studentNo,
|
||||
}));
|
||||
// 后端 CommitRosterImportDto 对两个字段均为 @ArrayMinSize(1),
|
||||
// 空数组会 400(全匹配/全新建两种常见场景必有一方为空),空数组不发送
|
||||
const body = {
|
||||
...(matchedIds.length ? { addStudentIds: matchedIds } : {}),
|
||||
...(createRows.length ? { createRows } : {}),
|
||||
};
|
||||
const res = await api.post<RosterCommitResult>(
|
||||
`/classes/${id}/students/import-commit`,
|
||||
body,
|
||||
);
|
||||
message.success(res.message || '导入成功');
|
||||
reset();
|
||||
onClose();
|
||||
onImported();
|
||||
} catch (error) {
|
||||
message.error(getErrorMessage(error, '导入失败'));
|
||||
} finally {
|
||||
setCommitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommit = () => {
|
||||
const matchedCount = preview?.rows.filter((row) => row.status === 'matched').length ?? 0;
|
||||
if (createCount === 0 && matchedCount === 0) return;
|
||||
const matchedText = matchedCount > 0 ? `,同时加入 ${matchedCount} 名已匹配学生` : '';
|
||||
const content =
|
||||
createCount > 0
|
||||
? `将新建 ${createCount} 名学生并加入本班${matchedText},该操作会生成新的学生档案。`
|
||||
: `将 ${matchedCount} 名已匹配学生加入本班。`;
|
||||
// 无论是否新建学生都先确认,避免误上传文件时已匹配学生被一键加入无提示
|
||||
modal.confirm({
|
||||
title: createCount > 0 ? '确认导入?' : '确认加入学生?',
|
||||
content,
|
||||
okText: '确认导入',
|
||||
cancelText: '取消',
|
||||
onOk: () => doCommit(),
|
||||
});
|
||||
};
|
||||
|
||||
const columns = useMemo<ColumnsType<RosterPreviewRow>>(
|
||||
() => [
|
||||
{ title: '行号', dataIndex: 'rowNumber', width: 60 },
|
||||
{ title: '姓名', dataIndex: 'name', render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '手机号',
|
||||
dataIndex: 'phone',
|
||||
render: (v?: string) => (v ? maskPhone(v) : '-'),
|
||||
},
|
||||
{ title: '学号', dataIndex: 'studentNo', render: (v?: string) => v || '-' },
|
||||
{
|
||||
title: '身份证号',
|
||||
dataIndex: 'idNumber',
|
||||
render: (v?: string) => (v ? maskIdNumber(v) : '-'),
|
||||
},
|
||||
{
|
||||
title: '匹配结果',
|
||||
dataIndex: 'status',
|
||||
width: 240,
|
||||
render: (_: unknown, row) => {
|
||||
const meta = ROSTER_STATUS_META[row.status] ?? { color: 'default', text: '未知' };
|
||||
return (
|
||||
<Space size={4} wrap>
|
||||
<Tag color={meta.color}>{meta.text}</Tag>
|
||||
{renderMatchResult(row)}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '创建新学生',
|
||||
width: 100,
|
||||
render: (_: unknown, row) => {
|
||||
if (row.status !== 'unmatched') return null;
|
||||
const canCreate = !!row.name?.trim();
|
||||
return (
|
||||
<Checkbox
|
||||
checked={createRowNumbers.has(row.rowNumber)}
|
||||
disabled={!canCreate}
|
||||
title={canCreate ? undefined : '缺少姓名,无法创建新学生'}
|
||||
onChange={(e) => {
|
||||
const next = new Set(createRowNumbers);
|
||||
if (e.target.checked) next.add(row.rowNumber);
|
||||
else next.delete(row.rowNumber);
|
||||
setCreateRowNumbers(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[createRowNumbers],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="批量导入学员"
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
width={900}
|
||||
footer={
|
||||
step === 1 ? null : (
|
||||
<Space>
|
||||
<Button disabled={committing || previewLoading} onClick={() => setStep(1)}>
|
||||
重新上传
|
||||
</Button>
|
||||
<Button disabled={committing || previewLoading} onClick={handleClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={committing}
|
||||
disabled={!canCommit}
|
||||
onClick={() => void handleCommit()}
|
||||
>
|
||||
确认导入
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
>
|
||||
{step === 1 ? (
|
||||
<div>
|
||||
<Upload.Dragger
|
||||
accept=".xlsx"
|
||||
showUploadList={false}
|
||||
disabled={previewLoading}
|
||||
beforeUpload={(file) => {
|
||||
const target = file as File;
|
||||
if (!target.name.toLowerCase().endsWith('.xlsx')) {
|
||||
message.error('仅支持 .xlsx 文件');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
if (target.size > 2 * 1024 * 1024) {
|
||||
message.error('文件大小不能超过 2MB');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
void handleUpload(target);
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<UploadOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">点击或拖拽 Excel 文件到此处上传</p>
|
||||
<p className="ant-upload-hint">支持 .xlsx,列:姓名*、手机号、学号、身份证号</p>
|
||||
</Upload.Dragger>
|
||||
<div style={{ marginTop: 12, textAlign: 'center' }}>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<DownloadOutlined />}
|
||||
loading={templateDownloading}
|
||||
onClick={() =>
|
||||
void runTemplateDownload(
|
||||
'/classes/roster/import-template',
|
||||
'班级花名册导入模板.xlsx',
|
||||
{ successMsg: '模板已下载', errorMsg: '模板下载失败' },
|
||||
)
|
||||
}
|
||||
>
|
||||
下载导入模板
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : preview ? (
|
||||
<div>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message={`${fileName} · 共 ${preview.summary.total} 行:匹配 ${preview.summary.matched} · 未匹配 ${preview.summary.unmatched} · 已在本班 ${preview.summary.inClass} · 冲突 ${preview.summary.conflict}`}
|
||||
description={`将加入 ${preview.summary.matched} 名已匹配学生${
|
||||
createCount > 0 ? `,并新建 ${createCount} 名学生` : ''
|
||||
};未匹配行默认勾选创建(缺少姓名的行无法创建),冲突行不会导入。`}
|
||||
/>
|
||||
<Table<RosterPreviewRow>
|
||||
columns={columns}
|
||||
dataSource={preview.rows}
|
||||
rowKey="rowNumber"
|
||||
size="small"
|
||||
pagination={{
|
||||
defaultPageSize: 50,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [20, 50, 100],
|
||||
}}
|
||||
scroll={{ y: 360 }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export const ClassStudentsTab: React.FC<{
|
||||
id?: string;
|
||||
detail?: ClassDetail | null;
|
||||
@@ -250,6 +615,7 @@ export const ClassStudentsTab: React.FC<{
|
||||
onRemove: (studentId: number) => void;
|
||||
onSelect: (ids: number[]) => void;
|
||||
adding?: boolean;
|
||||
onImported: () => void;
|
||||
}> = ({
|
||||
id,
|
||||
detail,
|
||||
@@ -263,8 +629,10 @@ export const ClassStudentsTab: React.FC<{
|
||||
onRemove,
|
||||
onSelect,
|
||||
adding,
|
||||
onImported,
|
||||
}) => {
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const studentColumns: ColumnsType<ClassStudent> = [
|
||||
{ title: '姓名', dataIndex: 'studentName' },
|
||||
{ title: '学号', dataIndex: 'studentNo' },
|
||||
@@ -300,6 +668,14 @@ export const ClassStudentsTab: React.FC<{
|
||||
>
|
||||
添加学员
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="class:edit"
|
||||
icon={<UploadOutlined />}
|
||||
onClick={() => setImportOpen(true)}
|
||||
style={{ marginBottom: 16, marginRight: 8 }}
|
||||
>
|
||||
批量导入
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="class:view"
|
||||
icon={<DownloadOutlined />}
|
||||
@@ -357,13 +733,23 @@ export const ClassStudentsTab: React.FC<{
|
||||
onChange={onSelect}
|
||||
options={allStudents.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.name} (${s.studentNo || s.id})`,
|
||||
label: formatStudentOptionLabel(s),
|
||||
// 保留学号/ID/脱敏手机号可搜索(仅用于过滤,不参与展示,避免学号搜索回归;不把完整手机号放入组件状态)
|
||||
searchText: `${s.name} ${s.studentNo || ''} ${s.id} ${s.phone ? maskPhone(s.phone) : ''}`,
|
||||
}))}
|
||||
filterOption={(input, option) =>
|
||||
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
|
||||
((option?.searchText as string) || (option?.label as string) || '')
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
<ClassRosterImportModal
|
||||
id={id}
|
||||
open={importOpen}
|
||||
onClose={() => setImportOpen(false)}
|
||||
onImported={onImported}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -372,15 +758,15 @@ export const ClassTeachersTab: React.FC<{
|
||||
teachers: ClassTeacher[];
|
||||
allUsers: TeacherCandidateUser[];
|
||||
teacherRole: string;
|
||||
teacherSubject: string;
|
||||
teacherSubjects: string[];
|
||||
teacherUserId?: number;
|
||||
modalOpen: boolean;
|
||||
onOpen: () => void;
|
||||
onAdd: () => void;
|
||||
onClose: () => void;
|
||||
onRemove: (userId: number) => void;
|
||||
onRemove: (assignmentId: number) => void;
|
||||
onRoleChange: (role: string) => void;
|
||||
onSubjectChange: (subject: string) => void;
|
||||
onSubjectsChange: (subjects: string[]) => void;
|
||||
onUserChange: (userId?: number) => void;
|
||||
getTeacherName: (teacher: ClassTeacher) => string;
|
||||
adding?: boolean;
|
||||
@@ -388,7 +774,7 @@ export const ClassTeachersTab: React.FC<{
|
||||
teachers,
|
||||
allUsers,
|
||||
teacherRole,
|
||||
teacherSubject,
|
||||
teacherSubjects,
|
||||
teacherUserId,
|
||||
modalOpen,
|
||||
onOpen,
|
||||
@@ -396,7 +782,7 @@ export const ClassTeachersTab: React.FC<{
|
||||
onClose,
|
||||
onRemove,
|
||||
onRoleChange,
|
||||
onSubjectChange,
|
||||
onSubjectsChange,
|
||||
onUserChange,
|
||||
getTeacherName,
|
||||
adding,
|
||||
@@ -412,12 +798,12 @@ export const ClassTeachersTab: React.FC<{
|
||||
{
|
||||
title: '科目',
|
||||
dataIndex: 'subject',
|
||||
render: (v: string | null) => v || '-',
|
||||
render: (v?: string | null) => (v ? <Tag>{v}</Tag> : '-'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
render: (_: unknown, r: ClassTeacher) => (
|
||||
<Popconfirm title="确认移除?" onConfirm={() => onRemove(r.userId)}>
|
||||
<Popconfirm title="确认移除该科目?" onConfirm={() => onRemove(r.id)}>
|
||||
<PermissionButton permission="class:edit" size="small" danger>
|
||||
移除
|
||||
</PermissionButton>
|
||||
@@ -452,7 +838,7 @@ export const ClassTeachersTab: React.FC<{
|
||||
style={{ width: '100%' }}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="搜索姓名、用户名、角色或学科"
|
||||
placeholder="搜索姓名、用户名或角色"
|
||||
value={teacherUserId}
|
||||
onChange={onUserChange}
|
||||
options={buildTeacherCandidateOptions(allUsers)}
|
||||
@@ -468,10 +854,14 @@ export const ClassTeachersTab: React.FC<{
|
||||
}))}
|
||||
/>
|
||||
{teacherRole === 'subject_teacher' && (
|
||||
<Input
|
||||
placeholder="任教科目"
|
||||
value={teacherSubject}
|
||||
onChange={(e) => onSubjectChange(e.target.value)}
|
||||
<Select
|
||||
mode="tags"
|
||||
style={{ width: '100%' }}
|
||||
placeholder="输入科目后回车添加,如:数学、英语"
|
||||
value={teacherSubjects}
|
||||
onChange={onSubjectsChange}
|
||||
tokenSeparators={[',', ',']}
|
||||
suffixIcon={null}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
@@ -39,7 +39,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
// Teacher modal state
|
||||
const [teacherModalOpen, setTeacherModalOpen] = useState(false);
|
||||
const [teacherRole, setTeacherRole] = useState('subject_teacher');
|
||||
const [teacherSubject, setTeacherSubject] = useState('');
|
||||
const [teacherSubjects, setTeacherSubjects] = useState<string[]>([]);
|
||||
const [teacherUserId, setTeacherUserId] = useState<number>();
|
||||
|
||||
// Schedule & attendance state
|
||||
@@ -169,12 +169,17 @@ const ClassDetailPage: React.FC = () => {
|
||||
|
||||
const handleAddTeacher = async () => {
|
||||
if (!teacherUserId) return;
|
||||
// 与后端 DTO 校验一致:任课老师至少需要一个科目,前端先拦截避免无效往返
|
||||
if (teacherRole === 'subject_teacher' && teacherSubjects.length === 0) {
|
||||
message.warning('任课老师至少需要一个科目');
|
||||
return;
|
||||
}
|
||||
setAddingTeacher(true);
|
||||
try {
|
||||
await api.post(`/classes/${id}/teachers`, {
|
||||
userId: teacherUserId,
|
||||
roleType: teacherRole,
|
||||
subject: teacherSubject || undefined,
|
||||
subjects: teacherSubjects,
|
||||
});
|
||||
setTeacherModalOpen(false);
|
||||
fetchDetail();
|
||||
@@ -186,9 +191,29 @@ const ClassDetailPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTeacher = async (userId: number) => {
|
||||
const handleSubjectsChange = (subjects: string[]) => {
|
||||
// 与后端 normalizeTeacherSubjects 对齐:去空白、去重、过滤空值、超长丢弃、上限 20,保持标签与落库一致。
|
||||
// 去重键统一小写(后端唯一索引 collation 大小写不敏感,'Math'+'math' 会被后端合并)。
|
||||
// 上限与后端 dto MAX_SUBJECT_LENGTH / MAX_TEACHER_SUBJECTS 保持一致
|
||||
const MAX_SUBJECT_LENGTH = 30;
|
||||
const MAX_TEACHER_SUBJECTS = 20;
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
for (const item of subjects) {
|
||||
const trimmed = item.trim();
|
||||
if (!trimmed || trimmed.length > MAX_SUBJECT_LENGTH) continue;
|
||||
const key = trimmed.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
normalized.push(trimmed);
|
||||
if (normalized.length >= MAX_TEACHER_SUBJECTS) break;
|
||||
}
|
||||
setTeacherSubjects(normalized);
|
||||
};
|
||||
|
||||
const handleRemoveTeacher = async (assignmentId: number) => {
|
||||
try {
|
||||
await api.delete(`/classes/${id}/teachers/${userId}`);
|
||||
await api.delete(`/classes/${id}/teacher-assignments/${assignmentId}`);
|
||||
fetchDetail();
|
||||
message.success('已移除');
|
||||
} catch (e: unknown) {
|
||||
@@ -214,7 +239,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
await fetchUsers();
|
||||
setTeacherUserId(undefined);
|
||||
setTeacherRole('subject_teacher');
|
||||
setTeacherSubject('');
|
||||
setTeacherSubjects([]);
|
||||
setTeacherModalOpen(true);
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e, '加载用户列表失败'));
|
||||
@@ -305,6 +330,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
onRemove={handleRemoveStudent}
|
||||
onSelect={setSelectedStudentIds}
|
||||
adding={addingStudents}
|
||||
onImported={fetchDetail}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -322,15 +348,18 @@ const ClassDetailPage: React.FC = () => {
|
||||
teachers={teachers}
|
||||
allUsers={allUsers}
|
||||
teacherRole={teacherRole}
|
||||
teacherSubject={teacherSubject}
|
||||
teacherSubjects={teacherSubjects}
|
||||
teacherUserId={teacherUserId}
|
||||
modalOpen={teacherModalOpen}
|
||||
onOpen={openTeacherModal}
|
||||
onAdd={handleAddTeacher}
|
||||
onClose={() => setTeacherModalOpen(false)}
|
||||
onRemove={handleRemoveTeacher}
|
||||
onRoleChange={setTeacherRole}
|
||||
onSubjectChange={setTeacherSubject}
|
||||
onRoleChange={(role) => {
|
||||
setTeacherRole(role);
|
||||
if (role !== 'subject_teacher') setTeacherSubjects([]);
|
||||
}}
|
||||
onSubjectsChange={handleSubjectsChange}
|
||||
onUserChange={setTeacherUserId}
|
||||
getTeacherName={getTeacherName}
|
||||
adding={addingTeacher}
|
||||
|
||||
@@ -13,7 +13,6 @@ const baseUser = (overrides: Partial<TeacherCandidateUser> = {}): TeacherCandida
|
||||
isArchived: false,
|
||||
studentStatus: null,
|
||||
roles: [{ code: 'teacher', name: '任课老师' }],
|
||||
profile: { subjects: ['数学', '物理'] },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -41,9 +40,7 @@ describe('class teacher candidates', () => {
|
||||
expect(buildTeacherCandidateOptions(users)).toEqual([]);
|
||||
});
|
||||
|
||||
it('shows real name, username, system role, and teaching subjects', () => {
|
||||
expect(buildTeacherCandidateLabel(baseUser())).toBe(
|
||||
'测试老师(teacher) · 任课老师 · 数学/物理',
|
||||
);
|
||||
it('shows real name, username, and system role', () => {
|
||||
expect(buildTeacherCandidateLabel(baseUser())).toBe('测试老师(teacher) · 任课老师');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ export interface TeacherCandidateUser {
|
||||
isArchived: boolean;
|
||||
studentStatus?: string | null;
|
||||
roles?: TeacherCandidateRole[];
|
||||
profile?: { subjects?: string[] } | null;
|
||||
}
|
||||
|
||||
const isSuperAdminRole = (role: TeacherCandidateRole) =>
|
||||
@@ -31,16 +30,7 @@ export const buildTeacherCandidateLabel = (user: TeacherCandidateUser) => {
|
||||
const roleNames = [
|
||||
...new Set((user.roles || []).flatMap((role) => (role.name ? [role.name] : []))),
|
||||
];
|
||||
const subjects = [
|
||||
...new Set(
|
||||
(user.profile?.subjects || []).flatMap((subject) => {
|
||||
const trimmed = subject.trim();
|
||||
return trimmed ? [trimmed] : [];
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
return [identity, roleNames.join('/'), subjects.join('/')].filter(Boolean).join(' · ');
|
||||
return [identity, roleNames.join('/')].filter(Boolean).join(' · ');
|
||||
};
|
||||
|
||||
export const buildTeacherCandidateOptions = (users: TeacherCandidateUser[]) =>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||
import { validateResponse } from '../../utils/validate';
|
||||
import { teacherListSchema } from '../../api/schemas';
|
||||
import { Table, Input, Modal, Form, Select, DatePicker, Tag, Space } from 'antd';
|
||||
import { Table, Input, Modal, Form, DatePicker, Tag, Space } from 'antd';
|
||||
import { EditOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
@@ -20,7 +20,7 @@ interface TeacherRow {
|
||||
id: number;
|
||||
username: string;
|
||||
name: string;
|
||||
profile: { subjects?: string[]; joinedAt?: string; qualifications?: string } | null;
|
||||
profile: { joinedAt?: string; qualifications?: string } | null;
|
||||
lastLoginAt: string;
|
||||
roles: { code: string; name: string }[];
|
||||
classAssignments: { roleType: string; subject: string; className: string | null }[];
|
||||
@@ -32,7 +32,6 @@ interface TeacherListResponse {
|
||||
}
|
||||
|
||||
interface ProfileFormValues {
|
||||
subjects: string[];
|
||||
joinedAt: dayjs.Dayjs | null;
|
||||
qualifications: string;
|
||||
}
|
||||
@@ -98,7 +97,7 @@ const TeachersPage: React.FC = () => {
|
||||
values,
|
||||
}: {
|
||||
id: number;
|
||||
values: { subjects: string[]; joinedAt?: string; qualifications?: string };
|
||||
values: { joinedAt?: string; qualifications?: string };
|
||||
}) => api.put(`/rbac/teachers/${id}/profile`, values),
|
||||
{ invalidate: [['rbac', 'teachers']] },
|
||||
);
|
||||
@@ -116,7 +115,6 @@ const TeachersPage: React.FC = () => {
|
||||
await saveProfileMutation.mutateAsync({
|
||||
id: profileModal.id,
|
||||
values: {
|
||||
subjects: values.subjects || [],
|
||||
joinedAt: values.joinedAt?.format('YYYY-MM-DD'),
|
||||
qualifications: values.qualifications,
|
||||
},
|
||||
@@ -169,23 +167,6 @@ const TeachersPage: React.FC = () => {
|
||||
))
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
title: '科目',
|
||||
dataIndex: 'profile',
|
||||
key: 'subjects',
|
||||
width: 130,
|
||||
render: (p: TeacherRow['profile'], r: TeacherRow) => (
|
||||
<EditableCell
|
||||
value={p?.subjects || []}
|
||||
editor="tags"
|
||||
options={(p?.subjects || []).map((value) => ({ value, label: value }))}
|
||||
permission="teacher:edit"
|
||||
onSave={(next) => saveProfileCell(r, 'subjects', next)}
|
||||
>
|
||||
{p?.subjects?.join('、') || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '入职日期',
|
||||
dataIndex: 'profile',
|
||||
@@ -222,7 +203,6 @@ const TeachersPage: React.FC = () => {
|
||||
onClick={() => {
|
||||
setProfileModal(r);
|
||||
form.setFieldsValue({
|
||||
subjects: r.profile?.subjects || [],
|
||||
joinedAt: r.profile?.joinedAt ? dayjs(r.profile.joinedAt) : null,
|
||||
qualifications: r.profile?.qualifications || '',
|
||||
});
|
||||
@@ -307,9 +287,6 @@ const TeachersPage: React.FC = () => {
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="subjects" label="任教学科">
|
||||
<Select mode="tags" placeholder="输入学科后回车添加" />
|
||||
</Form.Item>
|
||||
<Form.Item name="joinedAt" label="入职日期">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
Reference in New Issue
Block a user