From 48d06b29c12cf3b957e473c5d4f95abe0b797cd4 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Tue, 11 Aug 2026 11:54:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E7=8F=AD=E7=BA=A7=E8=8A=B1?= =?UTF-8?q?=E5=90=8D=E5=86=8C=E5=AF=BC=E5=85=A5=E7=A1=AE=E8=AE=A4=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=E4=B8=8E=E6=95=99=E5=B8=88=E5=80=99=E9=80=89=E4=BA=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 提交体条件化:空数组不发送,修复全匹配/全新建两种场景被后端 @ArrayMinSize 拒 400 - 无论是否新建学生都先确认,避免已匹配学生被一键加入无提示 - 学员下拉保留学号/ID 兜底区分同名;科目去重键小写对齐后端唯一索引 - 任课老师空科目前端拦截;教师候选人相关调整 --- .../src/pages/Classes/ClassDetailTabs.test.ts | 37 ++ .../src/pages/Classes/ClassDetailTabs.tsx | 422 +++++++++++++++++- apps/admin/src/pages/Classes/detail.tsx | 45 +- .../teacher-candidate.integration.test.ts | 7 +- .../src/pages/Classes/teacher-candidate.ts | 12 +- apps/admin/src/pages/Teachers/index.tsx | 29 +- 6 files changed, 486 insertions(+), 66 deletions(-) create mode 100644 apps/admin/src/pages/Classes/ClassDetailTabs.test.ts diff --git a/apps/admin/src/pages/Classes/ClassDetailTabs.test.ts b/apps/admin/src/pages/Classes/ClassDetailTabs.test.ts new file mode 100644 index 00000000..49322aa9 --- /dev/null +++ b/apps/admin/src/pages/Classes/ClassDetailTabs.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { formatStudentOptionLabel, type StudentItem } from './ClassDetailTabs'; + +const student = (overrides: Partial): 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)'); + }); +}); diff --git a/apps/admin/src/pages/Classes/ClassDetailTabs.tsx b/apps/admin/src/pages/Classes/ClassDetailTabs.tsx index 74d77b47..a067f513 100644 --- a/apps/admin/src/pages/Classes/ClassDetailTabs.tsx +++ b/apps/admin/src/pages/Classes/ClassDetailTabs.tsx @@ -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 = { @@ -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, +): 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 ( + + {row.student?.name} + {row.student?.studentNo ? `(${row.student.studentNo})` : ''} + + ); + } + if (row.reason) return {row.reason}; + return null; +} + +const ROSTER_STATUS_META: Record = { + 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(null); + const [createRowNumbers, setCreateRowNumbers] = useState>(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( + `/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( + `/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>( + () => [ + { 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 ( + + {meta.text} + {renderMatchResult(row)} + + ); + }, + }, + { + title: '创建新学生', + width: 100, + render: (_: unknown, row) => { + if (row.status !== 'unmatched') return null; + const canCreate = !!row.name?.trim(); + return ( + { + const next = new Set(createRowNumbers); + if (e.target.checked) next.add(row.rowNumber); + else next.delete(row.rowNumber); + setCreateRowNumbers(next); + }} + /> + ); + }, + }, + ], + [createRowNumbers], + ); + + return ( + + + + + + ) + } + > + {step === 1 ? ( +
+ { + 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; + }} + > +

+ +

+

点击或拖拽 Excel 文件到此处上传

+

支持 .xlsx,列:姓名*、手机号、学号、身份证号

+
+
+ +
+
+ ) : preview ? ( +
+ 0 ? `,并新建 ${createCount} 名学生` : '' + };未匹配行默认勾选创建(缺少姓名的行无法创建),冲突行不会导入。`} + /> + + columns={columns} + dataSource={preview.rows} + rowKey="rowNumber" + size="small" + pagination={{ + defaultPageSize: 50, + showSizeChanger: true, + pageSizeOptions: [20, 50, 100], + }} + scroll={{ y: 360 }} + /> +
+ ) : null} +
+ ); +}; + 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 = [ { title: '姓名', dataIndex: 'studentName' }, { title: '学号', dataIndex: 'studentNo' }, @@ -300,6 +668,14 @@ export const ClassStudentsTab: React.FC<{ > 添加学员 + } + onClick={() => setImportOpen(true)} + style={{ marginBottom: 16, marginRight: 8 }} + > + 批量导入 + } @@ -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()) } /> + setImportOpen(false)} + onImported={onImported} + /> ); }; @@ -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 ? {v} : '-'), }, { title: '操作', render: (_: unknown, r: ClassTeacher) => ( - onRemove(r.userId)}> + onRemove(r.id)}> 移除 @@ -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' && ( - onSubjectChange(e.target.value)} + -