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 {
|
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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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) · 任课老师 · 数学/物理',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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[]) =>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user