From 1776b1b50cc0e5cc01f8d65142c5e03b65bada2b Mon Sep 17 00:00:00 2001 From: aerospaceboy <790286734@qq.com> Date: Wed, 14 Oct 2026 14:57:02 +0800 Subject: [PATCH] fix: explain import blockers and recompute selected identities --- frontend/src/App.tsx | 79 ++++++++++++++++++++++++---- frontend/src/importReview.ts | 66 +++++++++++++++++++++++ frontend/tests/import.spec.ts | 98 +++++++++++++++++++++++++++++++++++ 3 files changed, 233 insertions(+), 10 deletions(-) create mode 100644 frontend/src/importReview.ts create mode 100644 frontend/tests/import.spec.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0bc2a96..aa99e50 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { DashboardOutlined, BookOutlined, FileSearchOutlined, FolderOpenOutlined import { api, post, put } from './api'; import { Batches } from './Batches'; import { projectLabel, projectsInBatch } from './batchProjects'; +import { fillImportIdentity, importErrors, reviewImport } from './importReview'; const Chat = lazy(() => import('./Chat')); const { Header, Sider, Content } = Layout; @@ -103,14 +104,26 @@ function Import({ openImported, scoreImported }: { openImported: (ids: number[]) useEffect(() => { load('/courses', setCourses); }, []); useEffect(() => { setScanData(undefined); setImported(undefined); load(`/storage/directories?relative=${encodeURIComponent(path)}`, setDirs); }, [path]); useEffect(() => { if (courseId) load(`/courses/${courseId}/versions`, setVersions); }, [courseId]); - const editProject = (index: number, key: string, value: any) => { const next = structuredClone(scanData); next.projects[index][key] = value; setScanData(next); }; - const editFile = (projectIndex: number, fileIndex: number, key: string, value: any) => { const next = structuredClone(scanData); next.projects[projectIndex].files[fileIndex][key] = value; setScanData(next); }; + const errors = importErrors(scanData?.projects, versionId); + const editProject = (index: number, key: string, value: any) => { + const next = structuredClone(scanData), project = next.projects[index]; + project[key] = value; + if (['student_no', 'name', 'topic'].includes(key)) project.identity_edits = { ...project.identity_edits, [key]: true }; + setScanData(next); + }; + const editFile = (projectIndex: number, fileIndex: number, key: string, value: any) => { + const next = structuredClone(scanData), project = next.projects[projectIndex]; + project.files[fileIndex][key] = value; fillImportIdentity(project); setScanData(next); + }; + const useCover = (index: number) => { + const next = structuredClone(scanData); fillImportIdentity(next.projects[index], true); setScanData(next); + }; const confirm = async () => { - if (!versionId || !scanData?.projects?.length) return; + if (errors.length || importing) { message.error(errors[0] || '正在导入,请稍候'); return; } setImporting(true); try { - const projects = scanData.projects.map((p: any) => ({ relative_dir: p.relative_dir, student_no: p.student_no, - name: p.name, topic: p.topic, direction: p.direction || '', + const projects = scanData.projects.map((p: any) => ({ relative_dir: p.relative_dir, student_no: p.student_no.trim(), + name: p.name.trim(), topic: p.topic.trim(), direction: (p.direction || '').trim(), files: p.files.map((f: any) => ({ relative_path: f.relative_path, category: f.category, status: f.status === 'ignored' ? 'ignored' : 'recognized', sha256: f.sha256 })) })); const result = await post('/import/confirm', { course_version_id: versionId, projects }); @@ -119,14 +132,60 @@ function Import({ openImported, scoreImported }: { openImported: (ids: number[]) } catch (error) { fail(error); } finally { setImporting(false); } }; - return <>挂载目录扫描与导入确认浏览 /data/inbox 下的目录,可选择包含学生子文件夹的总目录,也可直接进入放有 PDF、DOCX、PPTX 的资料目录扫描。封面信息会预填,请核对学号、姓名和选题后确认导入。{path}
{dirs.map(dir => )}
+ const projectIndex = (project: any) => scanData.projects.findIndex((item: any) => item.relative_dir === project.relative_dir); + const identityInput = (project: any, key: string, label: string) => editProject(projectIndex(project), key, event.target.value)} />; + return <>挂载目录扫描与导入确认 + 浏览 /data/inbox 下的目录,可选择学生总目录或直接存放资料的目录。请核对封面身份;不属于当前项目的文件可设为“忽略”。 + {path} + +
{dirs.map(dir => )}
{imported && imported.projectIds.length > 0 && } />} - {scanData && <>{!scanData.projects.length && } v.published), v => `v${v.version} ${v.title}`)} value={versionId} onChange={setVersionId} /> editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'student_no', e.target.value)} /> }, { title: '姓名', render: (_, p, index) => editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'name', e.target.value)} /> }, { title: '选题', render: (_, p, index) => editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'topic', e.target.value)} /> }, { title: '选题方向', render: (_, p) => editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'direction', e.target.value)} /> }, { title: '待处理', render: (_, p) => {p.status !== 'recognized' && 身份信息需确认}{p.missing.map((c: string) => 缺{categories[c]})}{p.warnings?.map((w: string) => {w})} }]} - expandable={{ defaultExpandAllRows: true, expandedRowRender: (p: any) => { const pi = scanData.projects.findIndex((item: any) => item.relative_dir === p.relative_dir); return size="small" rowKey="relative_path" pagination={false} dataSource={p.files} columns={[{ title: '文件', dataIndex: 'relative_path' }, { title: '类型', render: (_, f, fi) => editFile(pi, fi, 'status', value)} /> }, { title: '扫描结果', render: (_, f) => {f.status} }, { title: '异常', dataIndex: 'error' }]} />; } }} />} + {scanData && <>{errors.length > 0 && {errors.map((error, index) =>
  • {error}
  • )}} />} + + v.published), v => `v${v.version} ${v.title}`)} value={versionId} onChange={setVersionId} /> + + +
    identityInput(project, 'student_no', '学号') }, + { title: '姓名', render: (_, project) => identityInput(project, 'name', '姓名') }, + { title: '选题', render: (_, project) => identityInput(project, 'topic', '选题') }, + { title: '选题方向', render: (_, project) => editProject(projectIndex(project), 'direction', event.target.value)} /> }, + { title: '待处理', render: (_, project) => { const review = reviewImport(project); return + {review.errors.length > 0 ? 请修正导入信息 : 导入信息已填写} + {review.missing.map(category => 缺{categories[category]})} + {review.warnings.map(warning => {warning})} + {review.metadata && Object.values(review.identity).some(Boolean) && } + ; } }, + ]} expandable={{ defaultExpandAllRows: true, expandedRowRender: (project: any) => { + const pi = projectIndex(project); return size="small" rowKey="relative_path" pagination={false} dataSource={project.files} columns={[ + { title: '文件', dataIndex: 'relative_path' }, + { title: '封面身份', render: (_, file) => + {file.detected_identity?.name ? `${file.detected_identity.name}(${file.detected_identity.student_no || '未识别学号'})` : '首页未识别身份'} + {file.detected_identity?.topic || ''} }, + { title: '类型', render: (_, file, fi) => editFile(pi, fi, 'status', value)} /> }, + { title: '扫描结果', render: (_, file) => + {({ recognized: '已识别', needs_confirmation: '需确认类型', duplicate: '同类多份', unparseable: '无法读取', ignored: '已忽略' } as Record)[file.status] || file.status} }, + { title: '异常', dataIndex: 'error' }, + ]} />; + } }} /> + 封面冲突请核对上表来源。允许缺少某类材料,相关评分项会标记为待处理。人工填写的信息会保留;点击“使用所选资料的封面信息”可重新采用识别值。 + } ; } diff --git a/frontend/src/importReview.ts b/frontend/src/importReview.ts new file mode 100644 index 0000000..5ef134d --- /dev/null +++ b/frontend/src/importReview.ts @@ -0,0 +1,66 @@ +const fields = ['student_no', 'name', 'topic'] as const; +type IdentityField = typeof fields[number]; +const labels = { student_no: '学号', name: '姓名', topic: '选题' }; +const categories: Record = { stage1: '阶段报告1', stage2: '阶段报告2', stage3: '阶段报告3', + final_report: '最终报告', presentation: '汇报PPT/PDF' }; +const text = (value: unknown) => String(value ?? '').trim(); +const fileName = (file: any) => text(file.relative_path).split('/').pop() || '文件'; + +export function reviewImport(project: any) { + const selected = (project.files || []).filter((file: any) => file.status !== 'ignored'); + const metadata = (project.files || []).some((file: any) => file.detected_identity != null); + const identity: Record = { student_no: '', name: '', topic: '' }; + const warnings: string[] = []; + for (const field of fields) { + const sources = new Map(); + for (const file of selected) { + const value = text(file.detected_identity?.[field]); + if (value) sources.set(value, [...(sources.get(value) || []), fileName(file)]); + } + const folderValue = text(project.folder_identity?.[field]); + const values = [...sources.keys()]; + identity[field] = folderValue || (values.length === 1 ? values[0] : ''); + if (values.length > 1) { + warnings.push(`${labels[field]}冲突:${values.map(value => `${value}(${sources.get(value)!.join('、')})`).join(';')}`); + } else if (folderValue && values.length === 1 && folderValue !== values[0]) { + warnings.push(`${labels[field]}与目录不一致:封面为${values[0]}(${sources.get(values[0])!.join('、')}),目录为${folderValue}`); + } + } + if (!metadata) warnings.push(...(project.warnings || [])); + + const errors: string[] = []; + if (!/^[0-9]{5,20}$/.test(text(project.student_no))) errors.push('学号必须为5–20位数字'); + if (!text(project.name)) errors.push('请填写姓名'); + if (!text(project.topic)) errors.push('请填写选题'); + if (!selected.length) errors.push('至少选择1份资料'); + const counts = new Map(); + for (const file of selected) { + if (!categories[file.category]) errors.push(`${fileName(file)}:请选择材料类型`); + else counts.set(file.category, (counts.get(file.category) || 0) + 1); + if (file.error) errors.push(`${fileName(file)}:无法读取,请修复文件或设为忽略`); + } + for (const [category, count] of counts) { + if (count > 1) errors.push(`${categories[category]}选了${count}份,请只保留1份,其余设为忽略`); + } + const missing = Object.keys(categories).filter(category => !selected.some((file: any) => file.category === category)); + return { selected, metadata, identity, warnings, errors, missing }; +} + +export function fillImportIdentity(project: any, force = false) { + const review = reviewImport(project); + if (!review.metadata) return project; + for (const field of fields) { + if (force || !project.identity_edits?.[field]) project[field] = review.identity[field]; + } + if (force) project.identity_edits = {}; + return project; +} + +export function importErrors(projects: any[] | undefined, versionId: number | undefined) { + const errors = versionId ? [] : ['请选择已发布课程版本']; + if (!projects?.length) return [...errors, '当前目录没有PDF、DOCX或PPTX资料,请重新扫描资料目录']; + for (const project of projects) { + errors.push(...reviewImport(project).errors.map(error => `${project.relative_dir}:${error}`)); + } + return errors; +} diff --git a/frontend/tests/import.spec.ts b/frontend/tests/import.spec.ts new file mode 100644 index 0000000..4b83550 --- /dev/null +++ b/frontend/tests/import.spec.ts @@ -0,0 +1,98 @@ +import { expect, test, type Page } from '@playwright/test'; + +const identity = { student_no: '9000000001', name: '学生甲', topic: '机器人项目' }; +const mixed = () => ({ relative_dir: 'test/student', student_no: '', name: '', topic: '', + status: 'needs_confirmation', folder_identity: {}, detected_identity: {}, missing: [], + warnings: ['不同材料封面的学号不一致,请核对后填写'], files: [ + { relative_path: 'test/student/最终报告.pdf', category: 'final_report', status: 'recognized', + sha256: 'a'.repeat(64), detected_identity: identity }, + { relative_path: 'test/student/汇报PPT.pdf', category: 'presentation', status: 'needs_confirmation', + sha256: 'b'.repeat(64), detected_identity: { student_no: '9000000002', name: '学生乙', topic: '另一项目' } }, + ] }); + +async function setup(page: Page, project: any) { + const imports: any[] = []; + await page.route('**/api/**', async route => { + const request = route.request(); + const path = new URL(request.url()).pathname.replace(/^\/api/, ''); + let data: any = []; + if (path === '/courses') data = [{ id: 1, name: '课程甲' }]; + if (path === '/courses/1/versions') data = [{ id: 1, published: true, version: 1, title: '初始版本' }]; + if (path === '/import/scan') data = { projects: [project] }; + if (path === '/import/confirm') { imports.push(request.postDataJSON()); data = { project_ids: [12] }; } + await route.fulfill({ json: data }); + }); + await page.goto('/?page=import'); + await page.getByRole('button', { name: '扫描此目录', exact: true }).click(); + await choose(page, '导入课程', '课程甲'); + await choose(page, '导入课程版本', /初始版本/); + return imports; +} + +async function choose(page: Page, label: string, option: string | RegExp) { + await page.keyboard.press('Escape'); + await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(0); + const input = page.getByRole('combobox', { name: label, exact: true }); + await input.focus(); await input.press('ArrowDown'); + const listId = await input.getAttribute('aria-controls'); + const dropdown = page.locator('.ant-select-dropdown').filter({ has: page.locator(`[id="${listId}"]`) }); + await expect(dropdown).toBeVisible(); + await dropdown.getByText(option, { exact: typeof option === 'string' }).click(); +} + +test('invalid student number explains why confirm is disabled and recovers after editing', async ({ page }) => { + const project = { ...mixed(), student_no: '1', name: '1', topic: '1' }; + delete (project as any).folder_identity; + for (const file of project.files) delete (file as any).detected_identity; + await setup(page, project); + await expect(page.getByText(/学号必须为5–20位数字/)).toBeVisible(); + await expect(page.getByRole('button', { name: '确认导入', exact: true })).toBeDisabled(); + await page.getByRole('textbox', { name: '学号 test/student', exact: true }).fill('9000000001'); + await expect(page.getByRole('button', { name: '确认导入', exact: true })).toBeEnabled(); +}); + +test('ignoring a foreign PPT recalculates identity and submits the intended project', async ({ page }) => { + const imports = await setup(page, mixed()); + await expect(page.getByText('学生乙(9000000002)', { exact: true })).toBeVisible(); + await expect(page.getByText(/学号冲突:/)).toBeVisible(); + await choose(page, '处理 test/student/汇报PPT.pdf', '忽略'); + await expect(page.getByRole('textbox', { name: '学号 test/student', exact: true })).toHaveValue(identity.student_no); + await expect(page.getByRole('textbox', { name: '姓名 test/student', exact: true })).toHaveValue(identity.name); + await expect(page.getByText(/学号冲突:/)).toHaveCount(0); + await expect(page.getByRole('button', { name: '确认导入', exact: true })).toBeEnabled(); + await page.getByRole('button', { name: '确认导入', exact: true }).click(); + await expect.poll(() => imports.length).toBe(1); + expect(imports[0].projects[0].student_no).toBe(identity.student_no); + expect(imports[0].projects[0].files[1].status).toBe('ignored'); +}); + +test('file selection preserves manual corrections until the user chooses cover values', async ({ page }) => { + await setup(page, mixed()); + const name = page.getByRole('textbox', { name: '姓名 test/student', exact: true }); + await name.fill('人工修正姓名'); + await choose(page, '处理 test/student/汇报PPT.pdf', '忽略'); + await expect(name).toHaveValue('人工修正姓名'); + await page.getByRole('button', { name: '使用所选资料的封面信息', exact: true }).click(); + await expect(name).toHaveValue(identity.name); +}); + +test('ignoring all files gives an explicit selected-material error', async ({ page }) => { + await setup(page, mixed()); + await choose(page, '处理 test/student/汇报PPT.pdf', '忽略'); + await choose(page, '处理 test/student/最终报告.pdf', '忽略'); + await expect(page.getByText(/至少选择1份资料/)).toBeVisible(); + await expect(page.getByRole('button', { name: '确认导入', exact: true })).toBeDisabled(); +}); + +test('unknown and duplicate material types explain the block before sending a request', async ({ page }) => { + const project = mixed(); + project.files[1].category = 'unknown'; + project.files[1].detected_identity = identity; + await setup(page, project); + await expect(page.getByText(/请选择材料类型/)).toBeVisible(); + await expect(page.getByRole('button', { name: '确认导入', exact: true })).toBeDisabled(); + await choose(page, '材料类型 test/student/汇报PPT.pdf', '最终报告'); + await expect(page.getByText(/最终报告选了2份/)).toBeVisible(); + await choose(page, '处理 test/student/汇报PPT.pdf', '忽略'); + await expect(page.getByRole('button', { name: '确认导入', exact: true })).toBeEnabled(); +});