feat(admin): 班级花名册导入确认流程与教师候选人

- 提交体条件化:空数组不发送,修复全匹配/全新建两种场景被后端 @ArrayMinSize 拒 400
- 无论是否新建学生都先确认,避免已匹配学生被一键加入无提示
- 学员下拉保留学号/ID 兜底区分同名;科目去重键小写对齐后端唯一索引
- 任课老师空科目前端拦截;教师候选人相关调整
This commit is contained in:
2026-08-11 11:54:09 +08:00
parent f19e72ca40
commit 48d06b29c1
6 changed files with 486 additions and 66 deletions

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

View File

@@ -1,7 +1,10 @@
import React, { useState } from 'react'; import React, { useMemo, useState } from 'react';
import { import {
Alert,
App,
Button, Button,
Card, Card,
Checkbox,
Col, Col,
DatePicker, DatePicker,
Descriptions, Descriptions,
@@ -16,15 +19,20 @@ import {
Statistic, Statistic,
Table, Table,
Tag, Tag,
Upload,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; 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 dayjs from 'dayjs';
import { useUserStore } from '../../store/user/userStore'; import { useUserStore } from '../../store/user/userStore';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { QueryEmpty } from '../../components/QueryState'; import { QueryEmpty } from '../../components/QueryState';
import { useSubmitShortcut } from '../../hooks/useSubmitShortcut'; import { useSubmitShortcut } from '../../hooks/useSubmitShortcut';
import { useDownload } from '../../hooks/useDownload';
import { message } from '../../ui/app-message'; 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 { buildTeacherCandidateOptions, type TeacherCandidateUser } from './teacher-candidate';
import { saveAs } from 'file-saver'; import { saveAs } from 'file-saver';
@@ -44,6 +52,7 @@ export interface ClassTeacher {
username: string; username: string;
roleType: string; roleType: string;
subject: string | null; subject: string | null;
subjects: string[];
} }
export interface ClassScheduleItem { export interface ClassScheduleItem {
@@ -97,6 +106,21 @@ export interface StudentItem {
id: number; id: number;
name: string; name: string;
studentNo?: 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 }> = { 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<{ export const ClassStudentsTab: React.FC<{
id?: string; id?: string;
detail?: ClassDetail | null; detail?: ClassDetail | null;
@@ -250,6 +615,7 @@ export const ClassStudentsTab: React.FC<{
onRemove: (studentId: number) => void; onRemove: (studentId: number) => void;
onSelect: (ids: number[]) => void; onSelect: (ids: number[]) => void;
adding?: boolean; adding?: boolean;
onImported: () => void;
}> = ({ }> = ({
id, id,
detail, detail,
@@ -263,8 +629,10 @@ export const ClassStudentsTab: React.FC<{
onRemove, onRemove,
onSelect, onSelect,
adding, adding,
onImported,
}) => { }) => {
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const studentColumns: ColumnsType<ClassStudent> = [ const studentColumns: ColumnsType<ClassStudent> = [
{ title: '姓名', dataIndex: 'studentName' }, { title: '姓名', dataIndex: 'studentName' },
{ title: '学号', dataIndex: 'studentNo' }, { title: '学号', dataIndex: 'studentNo' },
@@ -300,6 +668,14 @@ export const ClassStudentsTab: React.FC<{
> >
</PermissionButton> </PermissionButton>
<PermissionButton
permission="class:edit"
icon={<UploadOutlined />}
onClick={() => setImportOpen(true)}
style={{ marginBottom: 16, marginRight: 8 }}
>
</PermissionButton>
<PermissionButton <PermissionButton
permission="class:view" permission="class:view"
icon={<DownloadOutlined />} icon={<DownloadOutlined />}
@@ -357,13 +733,23 @@ export const ClassStudentsTab: React.FC<{
onChange={onSelect} onChange={onSelect}
options={allStudents.map((s) => ({ options={allStudents.map((s) => ({
value: s.id, 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) => filterOption={(input, option) =>
(option?.label as string)?.toLowerCase().includes(input.toLowerCase()) ((option?.searchText as string) || (option?.label as string) || '')
.toLowerCase()
.includes(input.toLowerCase())
} }
/> />
</Modal> </Modal>
<ClassRosterImportModal
id={id}
open={importOpen}
onClose={() => setImportOpen(false)}
onImported={onImported}
/>
</div> </div>
); );
}; };
@@ -372,15 +758,15 @@ export const ClassTeachersTab: React.FC<{
teachers: ClassTeacher[]; teachers: ClassTeacher[];
allUsers: TeacherCandidateUser[]; allUsers: TeacherCandidateUser[];
teacherRole: string; teacherRole: string;
teacherSubject: string; teacherSubjects: string[];
teacherUserId?: number; teacherUserId?: number;
modalOpen: boolean; modalOpen: boolean;
onOpen: () => void; onOpen: () => void;
onAdd: () => void; onAdd: () => void;
onClose: () => void; onClose: () => void;
onRemove: (userId: number) => void; onRemove: (assignmentId: number) => void;
onRoleChange: (role: string) => void; onRoleChange: (role: string) => void;
onSubjectChange: (subject: string) => void; onSubjectsChange: (subjects: string[]) => void;
onUserChange: (userId?: number) => void; onUserChange: (userId?: number) => void;
getTeacherName: (teacher: ClassTeacher) => string; getTeacherName: (teacher: ClassTeacher) => string;
adding?: boolean; adding?: boolean;
@@ -388,7 +774,7 @@ export const ClassTeachersTab: React.FC<{
teachers, teachers,
allUsers, allUsers,
teacherRole, teacherRole,
teacherSubject, teacherSubjects,
teacherUserId, teacherUserId,
modalOpen, modalOpen,
onOpen, onOpen,
@@ -396,7 +782,7 @@ export const ClassTeachersTab: React.FC<{
onClose, onClose,
onRemove, onRemove,
onRoleChange, onRoleChange,
onSubjectChange, onSubjectsChange,
onUserChange, onUserChange,
getTeacherName, getTeacherName,
adding, adding,
@@ -412,12 +798,12 @@ export const ClassTeachersTab: React.FC<{
{ {
title: '科目', title: '科目',
dataIndex: 'subject', dataIndex: 'subject',
render: (v: string | null) => v || '-', render: (v?: string | null) => (v ? <Tag>{v}</Tag> : '-'),
}, },
{ {
title: '操作', title: '操作',
render: (_: unknown, r: ClassTeacher) => ( render: (_: unknown, r: ClassTeacher) => (
<Popconfirm title="确认移除?" onConfirm={() => onRemove(r.userId)}> <Popconfirm title="确认移除该科目" onConfirm={() => onRemove(r.id)}>
<PermissionButton permission="class:edit" size="small" danger> <PermissionButton permission="class:edit" size="small" danger>
</PermissionButton> </PermissionButton>
@@ -452,7 +838,7 @@ export const ClassTeachersTab: React.FC<{
style={{ width: '100%' }} style={{ width: '100%' }}
showSearch showSearch
optionFilterProp="label" optionFilterProp="label"
placeholder="搜索姓名、用户名角色或学科" placeholder="搜索姓名、用户名角色"
value={teacherUserId} value={teacherUserId}
onChange={onUserChange} onChange={onUserChange}
options={buildTeacherCandidateOptions(allUsers)} options={buildTeacherCandidateOptions(allUsers)}
@@ -468,10 +854,14 @@ export const ClassTeachersTab: React.FC<{
}))} }))}
/> />
{teacherRole === 'subject_teacher' && ( {teacherRole === 'subject_teacher' && (
<Input <Select
placeholder="任教科目" mode="tags"
value={teacherSubject} style={{ width: '100%' }}
onChange={(e) => onSubjectChange(e.target.value)} placeholder="输入科目后回车添加,如:数学、英语"
value={teacherSubjects}
onChange={onSubjectsChange}
tokenSeparators={[',', '']}
suffixIcon={null}
/> />
)} )}
</Space> </Space>

View File

@@ -39,7 +39,7 @@ const ClassDetailPage: React.FC = () => {
// Teacher modal state // Teacher modal state
const [teacherModalOpen, setTeacherModalOpen] = useState(false); const [teacherModalOpen, setTeacherModalOpen] = useState(false);
const [teacherRole, setTeacherRole] = useState('subject_teacher'); const [teacherRole, setTeacherRole] = useState('subject_teacher');
const [teacherSubject, setTeacherSubject] = useState(''); const [teacherSubjects, setTeacherSubjects] = useState<string[]>([]);
const [teacherUserId, setTeacherUserId] = useState<number>(); const [teacherUserId, setTeacherUserId] = useState<number>();
// Schedule & attendance state // Schedule & attendance state
@@ -169,12 +169,17 @@ const ClassDetailPage: React.FC = () => {
const handleAddTeacher = async () => { const handleAddTeacher = async () => {
if (!teacherUserId) return; if (!teacherUserId) return;
// 与后端 DTO 校验一致:任课老师至少需要一个科目,前端先拦截避免无效往返
if (teacherRole === 'subject_teacher' && teacherSubjects.length === 0) {
message.warning('任课老师至少需要一个科目');
return;
}
setAddingTeacher(true); setAddingTeacher(true);
try { try {
await api.post(`/classes/${id}/teachers`, { await api.post(`/classes/${id}/teachers`, {
userId: teacherUserId, userId: teacherUserId,
roleType: teacherRole, roleType: teacherRole,
subject: teacherSubject || undefined, subjects: teacherSubjects,
}); });
setTeacherModalOpen(false); setTeacherModalOpen(false);
fetchDetail(); 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 { try {
await api.delete(`/classes/${id}/teachers/${userId}`); await api.delete(`/classes/${id}/teacher-assignments/${assignmentId}`);
fetchDetail(); fetchDetail();
message.success('已移除'); message.success('已移除');
} catch (e: unknown) { } catch (e: unknown) {
@@ -214,7 +239,7 @@ const ClassDetailPage: React.FC = () => {
await fetchUsers(); await fetchUsers();
setTeacherUserId(undefined); setTeacherUserId(undefined);
setTeacherRole('subject_teacher'); setTeacherRole('subject_teacher');
setTeacherSubject(''); setTeacherSubjects([]);
setTeacherModalOpen(true); setTeacherModalOpen(true);
} catch (e: unknown) { } catch (e: unknown) {
message.error(getErrorMessage(e, '加载用户列表失败')); message.error(getErrorMessage(e, '加载用户列表失败'));
@@ -305,6 +330,7 @@ const ClassDetailPage: React.FC = () => {
onRemove={handleRemoveStudent} onRemove={handleRemoveStudent}
onSelect={setSelectedStudentIds} onSelect={setSelectedStudentIds}
adding={addingStudents} adding={addingStudents}
onImported={fetchDetail}
/> />
), ),
}, },
@@ -322,15 +348,18 @@ const ClassDetailPage: React.FC = () => {
teachers={teachers} teachers={teachers}
allUsers={allUsers} allUsers={allUsers}
teacherRole={teacherRole} teacherRole={teacherRole}
teacherSubject={teacherSubject} teacherSubjects={teacherSubjects}
teacherUserId={teacherUserId} teacherUserId={teacherUserId}
modalOpen={teacherModalOpen} modalOpen={teacherModalOpen}
onOpen={openTeacherModal} onOpen={openTeacherModal}
onAdd={handleAddTeacher} onAdd={handleAddTeacher}
onClose={() => setTeacherModalOpen(false)} onClose={() => setTeacherModalOpen(false)}
onRemove={handleRemoveTeacher} onRemove={handleRemoveTeacher}
onRoleChange={setTeacherRole} onRoleChange={(role) => {
onSubjectChange={setTeacherSubject} setTeacherRole(role);
if (role !== 'subject_teacher') setTeacherSubjects([]);
}}
onSubjectsChange={handleSubjectsChange}
onUserChange={setTeacherUserId} onUserChange={setTeacherUserId}
getTeacherName={getTeacherName} getTeacherName={getTeacherName}
adding={addingTeacher} adding={addingTeacher}

View File

@@ -13,7 +13,6 @@ const baseUser = (overrides: Partial<TeacherCandidateUser> = {}): TeacherCandida
isArchived: false, isArchived: false,
studentStatus: null, studentStatus: null,
roles: [{ code: 'teacher', name: '任课老师' }], roles: [{ code: 'teacher', name: '任课老师' }],
profile: { subjects: ['数学', '物理'] },
...overrides, ...overrides,
}); });
@@ -41,9 +40,7 @@ describe('class teacher candidates', () => {
expect(buildTeacherCandidateOptions(users)).toEqual([]); expect(buildTeacherCandidateOptions(users)).toEqual([]);
}); });
it('shows real name, username, system role, and teaching subjects', () => { it('shows real name, username, and system role', () => {
expect(buildTeacherCandidateLabel(baseUser())).toBe( expect(buildTeacherCandidateLabel(baseUser())).toBe('测试老师teacher · 任课老师');
'测试老师teacher · 任课老师 · 数学/物理',
);
}); });
}); });

View File

@@ -10,7 +10,6 @@ export interface TeacherCandidateUser {
isArchived: boolean; isArchived: boolean;
studentStatus?: string | null; studentStatus?: string | null;
roles?: TeacherCandidateRole[]; roles?: TeacherCandidateRole[];
profile?: { subjects?: string[] } | null;
} }
const isSuperAdminRole = (role: TeacherCandidateRole) => const isSuperAdminRole = (role: TeacherCandidateRole) =>
@@ -31,16 +30,7 @@ export const buildTeacherCandidateLabel = (user: TeacherCandidateUser) => {
const roleNames = [ const roleNames = [
...new Set((user.roles || []).flatMap((role) => (role.name ? [role.name] : []))), ...new Set((user.roles || []).flatMap((role) => (role.name ? [role.name] : []))),
]; ];
const subjects = [ return [identity, roleNames.join('/')].filter(Boolean).join(' · ');
...new Set(
(user.profile?.subjects || []).flatMap((subject) => {
const trimmed = subject.trim();
return trimmed ? [trimmed] : [];
}),
),
];
return [identity, roleNames.join('/'), subjects.join('/')].filter(Boolean).join(' · ');
}; };
export const buildTeacherCandidateOptions = (users: TeacherCandidateUser[]) => export const buildTeacherCandidateOptions = (users: TeacherCandidateUser[]) =>

View File

@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { useApiMutation } from '../../hooks/useApiMutation'; import { useApiMutation } from '../../hooks/useApiMutation';
import { validateResponse } from '../../utils/validate'; import { validateResponse } from '../../utils/validate';
import { teacherListSchema } from '../../api/schemas'; 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 { EditOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
@@ -20,7 +20,7 @@ interface TeacherRow {
id: number; id: number;
username: string; username: string;
name: string; name: string;
profile: { subjects?: string[]; joinedAt?: string; qualifications?: string } | null; profile: { joinedAt?: string; qualifications?: string } | null;
lastLoginAt: string; lastLoginAt: string;
roles: { code: string; name: string }[]; roles: { code: string; name: string }[];
classAssignments: { roleType: string; subject: string; className: string | null }[]; classAssignments: { roleType: string; subject: string; className: string | null }[];
@@ -32,7 +32,6 @@ interface TeacherListResponse {
} }
interface ProfileFormValues { interface ProfileFormValues {
subjects: string[];
joinedAt: dayjs.Dayjs | null; joinedAt: dayjs.Dayjs | null;
qualifications: string; qualifications: string;
} }
@@ -98,7 +97,7 @@ const TeachersPage: React.FC = () => {
values, values,
}: { }: {
id: number; id: number;
values: { subjects: string[]; joinedAt?: string; qualifications?: string }; values: { joinedAt?: string; qualifications?: string };
}) => api.put(`/rbac/teachers/${id}/profile`, values), }) => api.put(`/rbac/teachers/${id}/profile`, values),
{ invalidate: [['rbac', 'teachers']] }, { invalidate: [['rbac', 'teachers']] },
); );
@@ -116,7 +115,6 @@ const TeachersPage: React.FC = () => {
await saveProfileMutation.mutateAsync({ await saveProfileMutation.mutateAsync({
id: profileModal.id, id: profileModal.id,
values: { values: {
subjects: values.subjects || [],
joinedAt: values.joinedAt?.format('YYYY-MM-DD'), joinedAt: values.joinedAt?.format('YYYY-MM-DD'),
qualifications: values.qualifications, 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: '入职日期', title: '入职日期',
dataIndex: 'profile', dataIndex: 'profile',
@@ -222,7 +203,6 @@ const TeachersPage: React.FC = () => {
onClick={() => { onClick={() => {
setProfileModal(r); setProfileModal(r);
form.setFieldsValue({ form.setFieldsValue({
subjects: r.profile?.subjects || [],
joinedAt: r.profile?.joinedAt ? dayjs(r.profile.joinedAt) : null, joinedAt: r.profile?.joinedAt ? dayjs(r.profile.joinedAt) : null,
qualifications: r.profile?.qualifications || '', qualifications: r.profile?.qualifications || '',
}); });
@@ -307,9 +287,6 @@ const TeachersPage: React.FC = () => {
confirmLoading={saving} confirmLoading={saving}
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Form.Item name="subjects" label="任教学科">
<Select mode="tags" placeholder="输入学科后回车添加" />
</Form.Item>
<Form.Item name="joinedAt" label="入职日期"> <Form.Item name="joinedAt" label="入职日期">
<DatePicker style={{ width: '100%' }} /> <DatePicker style={{ width: '100%' }} />
</Form.Item> </Form.Item>