修复确认导入的校验提示及身份重算 #2

Open
aerospaceboy wants to merge 4 commits from aerospaceboy/WiseGrade:fix/import-confirmation into main
8 changed files with 331 additions and 16 deletions
@@ -24,6 +24,8 @@ def test_scan_selected_student_folder_prefills_cover_identity(tmp_path, monkeypa
assert project["status"] == "needs_confirmation" assert project["status"] == "needs_confirmation"
assert len(project["files"]) == 5 assert len(project["files"]) == 5
assert project["missing"] == [] assert project["missing"] == []
assert project["folder_identity"] == {"student_no": "", "name": "", "topic": ""}
assert all(file["detected_identity"]["student_no"] == "9000000001" for file in project["files"])
assert scan("test", root=tmp_path)["projects"] == result["projects"] assert scan("test", root=tmp_path)["projects"] == result["projects"]
@@ -39,6 +41,26 @@ def test_scan_conflicting_covers_need_confirmation(tmp_path, monkeypatch):
assert project["student_no"] == "" assert project["student_no"] == ""
assert project["warnings"] assert project["warnings"]
assert project["status"] == "needs_confirmation" assert project["status"] == "needs_confirmation"
assert "学号" in project["warnings"][0]
assert "student_no" not in project["warnings"][0]
assert {file["detected_identity"]["student_no"] for file in project["files"]} == {"9000000001", "9000000002"}
def test_scan_unreadable_cover_has_no_stale_identity(tmp_path, monkeypatch):
from wisegrade import storage
folder = tmp_path / "9000000001_学生甲_机器人系统"
folder.mkdir()
(folder / "阶段报告1.pdf").write_bytes(b"fixture")
(folder / "阶段报告2.pdf").write_bytes(b"broken")
def read(path):
if path.name.endswith("2.pdf"):
raise ValueError("损坏PDF")
return "学号:9000000001\n姓名:学生甲\n题目:机器人系统"
monkeypatch.setattr(storage, "first_text", read)
project = scan(".", root=tmp_path)["projects"][0]
assert project["folder_identity"]["student_no"] == "9000000001"
assert project["files"][0]["detected_identity"]["student_no"] == "9000000001"
assert project["files"][1]["detected_identity"] == {}
def test_scan_parent_skips_empty_folders(tmp_path, monkeypatch): def test_scan_parent_skips_empty_folders(tmp_path, monkeypatch):
+36
View File
@@ -0,0 +1,36 @@
import pytest
from pydantic import ValidationError
from wisegrade.api import ImportConfirm, ProjectConfirm
def project(**changes):
return {"relative_dir": "test/student", "student_no": "9000000001", "name": "学生甲", "topic": "机器人项目",
"files": [{"relative_path": "test/student/最终报告.pdf", "category": "final_report", "sha256": "a" * 64}], **changes}
def test_confirm_normalizes_identity_before_validating():
result = ProjectConfirm.model_validate(project(student_no=" 9000000001 ", name=" 学生甲 ", topic=" 机器人项目 "))
assert (result.student_no, result.name, result.topic) == ("9000000001", "学生甲", "机器人项目")
@pytest.mark.parametrize("changes", [
{"name": " "}, {"topic": " "}, {"student_no": "9000000001"}, {"files": []},
{"files": [{"relative_path": "test/student/a.pdf", "category": "unknown", "sha256": "a" * 64}]},
{"files": [{"relative_path": "test/student/a.pdf", "category": "final_report", "sha256": "a" * 64, "status": "ignored"}]},
{"files": [{"relative_path": f"test/student/{i}.pdf", "category": "final_report", "sha256": "a" * 64} for i in range(2)]},
])
def test_confirm_rejects_invalid_identity_or_selection(changes):
with pytest.raises(ValidationError):
ProjectConfirm.model_validate(project(**changes))
def test_empty_import_is_rejected():
with pytest.raises(ValidationError):
ImportConfirm(course_version_id=1, projects=[])
def test_ignored_unknown_material_does_not_block_valid_selection():
data = project()
data["files"].append({"relative_path": "test/student/foreign.pdf", "category": "unknown", "sha256": "b" * 64, "status": "ignored"})
assert len(ProjectConfirm.model_validate(data).files) == 2
+21 -4
View File
@@ -3,7 +3,7 @@ from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, field_validator, model_validator
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -67,16 +67,33 @@ class FileConfirm(BaseModel):
class ProjectConfirm(BaseModel): class ProjectConfirm(BaseModel):
relative_dir: str relative_dir: str
student_no: str = Field(pattern=r"^\d{5,20}$") student_no: str = Field(pattern=r"^[0-9]{5,20}$")
name: str = Field(min_length=1) name: str = Field(min_length=1)
topic: str = Field(min_length=1) topic: str = Field(min_length=1)
direction: str = "" direction: str = ""
files: list[FileConfirm] files: list[FileConfirm] = Field(min_length=1)
@field_validator("student_no", "name", "topic", "direction", mode="before")
@classmethod
def trim_identity(cls, value):
return value.strip() if isinstance(value, str) else value
@model_validator(mode="after")
def validate_selection(self):
selected = [file for file in self.files if file.status != "ignored"]
if not selected:
raise ValueError("至少选择1份资料")
categories = [file.category for file in selected]
if any(category not in CATEGORIES for category in categories):
raise ValueError("请选择有效材料类型")
if len(set(categories)) != len(categories):
raise ValueError("每类材料只允许选择1份,其余请设为忽略")
return self
class ImportConfirm(BaseModel): class ImportConfirm(BaseModel):
course_version_id: int course_version_id: int
projects: list[ProjectConfirm] projects: list[ProjectConfirm] = Field(min_length=1)
class BatchInput(BaseModel): class BatchInput(BaseModel):
+7 -2
View File
@@ -11,6 +11,7 @@ from .config import get_settings
CATEGORIES = ("stage1", "stage2", "stage3", "final_report", "presentation") CATEGORIES = ("stage1", "stage2", "stage3", "final_report", "presentation")
SUPPORTED = {".pdf", ".docx", ".pptx"} SUPPORTED = {".pdf", ".docx", ".pptx"}
NAMES = {"stage1": "阶段报告1", "stage2": "阶段报告2", "stage3": "阶段报告3", "final_report": "最终报告", "presentation": "汇报PPT"} NAMES = {"stage1": "阶段报告1", "stage2": "阶段报告2", "stage3": "阶段报告3", "final_report": "最终报告", "presentation": "汇报PPT"}
IDENTITY_LABELS = {"student_no": "学号", "name": "姓名", "topic": "选题"}
def secure_path(relative: str, *, root: Path | None = None, must_exist=True) -> Path: def secure_path(relative: str, *, root: Path | None = None, must_exist=True) -> Path:
@@ -114,6 +115,7 @@ def scan(relative: str, *, root: Path | None = None):
if item.is_dir() and not item.is_symlink() and has_materials(item)] if item.is_dir() and not item.is_symlink() and has_materials(item)]
for folder in folders: for folder in folders:
info = parse_student_folder(folder.name) info = parse_student_folder(folder.name)
folder_identity = {key: info[key] for key in IDENTITY_LABELS}
files = [] files = []
extracted = {} extracted = {}
identity_values = {} identity_values = {}
@@ -124,6 +126,7 @@ def scan(relative: str, *, root: Path | None = None):
if candidate.suffix.lower() not in SUPPORTED: if candidate.suffix.lower() not in SUPPORTED:
continue continue
error = "" error = ""
identity = {}
try: try:
text = first_text(candidate) text = first_text(candidate)
category, status = classify(candidate, text) category, status = classify(candidate, text)
@@ -137,6 +140,7 @@ def scan(relative: str, *, root: Path | None = None):
files.append({"relative_path": candidate.relative_to(base).as_posix(), "category": category, "status": status, files.append({"relative_path": candidate.relative_to(base).as_posix(), "category": category, "status": status,
"size": stat.st_size, "mtime": stat.st_mtime, "sha256": sha256(candidate), "size": stat.st_size, "mtime": stat.st_mtime, "sha256": sha256(candidate),
"mime_type": mimetypes.guess_type(candidate.name)[0] or "application/octet-stream", "error": error, "mime_type": mimetypes.guess_type(candidate.name)[0] or "application/octet-stream", "error": error,
"detected_identity": identity,
"first_text": text[:500] if not error else ""}) "first_text": text[:500] if not error else ""})
for category in CATEGORIES: for category in CATEGORIES:
matches = [file for file in files if file["category"] == category] matches = [file for file in files if file["category"] == category]
@@ -145,17 +149,18 @@ def scan(relative: str, *, root: Path | None = None):
file["status"] = "duplicate" file["status"] = "duplicate"
for key, values in identity_values.items(): for key, values in identity_values.items():
if len(values) > 1: if len(values) > 1:
warnings.append(f"不同材料封面的{key}不一致,请核对后填写") warnings.append(f"不同材料封面的{IDENTITY_LABELS[key]}不一致,请核对后填写")
else: else:
extracted[key] = next(iter(values)) extracted[key] = next(iter(values))
for key, value in extracted.items(): for key, value in extracted.items():
if info.get(key) and value != info[key]: if info.get(key) and value != info[key]:
warnings.append(f"封面{key}“{value}”与目录名“{info[key]}”不一致") warnings.append(f"封面{IDENTITY_LABELS[key]}“{value}”与目录名“{info[key]}”不一致")
elif not info.get(key): elif not info.get(key):
info[key] = value info[key] = value
if warnings: if warnings:
info["status"] = "needs_confirmation" info["status"] = "needs_confirmation"
info.update({"relative_dir": folder.relative_to(base).as_posix(), "files": files, info.update({"relative_dir": folder.relative_to(base).as_posix(), "files": files,
"folder_identity": folder_identity,
"detected_identity": extracted, "warnings": warnings, "detected_identity": extracted, "warnings": warnings,
"missing": [category for category in CATEGORIES if not any(file["category"] == category for file in files)]}) "missing": [category for category in CATEGORIES if not any(file["category"] == category for file in files)]})
projects.append(info) projects.append(info)
+12
View File
@@ -25,3 +25,15 @@
`test/示例/汇报PPT.pdf` 首页实际显示“课程导学/自动化专业综合实践”。应在评分前确认它是否属于学生汇报稿,而不是课程说明。本次只导入和索引该示例资料包,未创建或启动它的新评分批次。 `test/示例/汇报PPT.pdf` 首页实际显示“课程导学/自动化专业综合实践”。应在评分前确认它是否属于学生汇报稿,而不是课程说明。本次只导入和索引该示例资料包,未创建或启动它的新评分批次。
运行证据保存在工作区 `.wisegrade-testing/docker-preview/`,包括 `import-model-live.json`、`import-test-sample-2026-10-14.json`、浏览器截图和构建/部署日志。实际入库与批次重试的最终状态见该目录 `import-model-final-2026-10-14.json`。 运行证据保存在工作区 `.wisegrade-testing/docker-preview/`,包括 `import-model-live.json`、`import-test-sample-2026-10-14.json`、浏览器截图和构建/部署日志。实际入库与批次重试的最终状态见该目录 `import-model-final-2026-10-14.json`。
## 后续:确认导入的校验与封面来源
按钮被禁用的原因原来隐藏在网页布尔表达式中;扫描只返回合并后的身份,冲突时清空字段,但未提供逐文件来源,忽略文件后也不重新计算。这使用户无法从页面判断如何完成合法导入。
- `d7bd152`(2 个文件):扫描返回每份文件的封面身份和原目录身份,警告使用中文字段名;读取失败的文件不沿用上一份身份。
- `b24930b`(3 个文件):显示具体阻碍原因和逐文件身份;只根据当前选中文件重算身份和冲突,忽略异项目文件后可重新预填。保留人工修改,提供“使用所选资料的封面信息”入口。材料类别未知、同类多份或没有选中材料时直接提示。
- `36bf473`(2 个文件):API 在写入前统一去除身份字段首尾空白,要求 5–20 位 ASCII 数字学号,拒绝空白姓名/选题、空项目、全部忽略及无效/重复材料类型。忽略的未知类型文件不阻碍合法选择。
- 后端隔离测试 **74 passed**,网页测试 **17 passed**,TypeScript/Vite 构建通过;新增用例在修复前失败。API、网页和空闲 worker 已更新,本轮没有新增数据库迁移。
- 实际网页无接口模拟验证:输入 `1` 显示学号规则;忽略异项目 PPT 并采用剩余报告封面信息后,身份冲突清除且确认按钮可用。没有提交真实导入或调用评分模型。空项目 API 请求返回 422。证据见工作区 `docker-preview/import-root-live.json` 和对应截图。
本轮修复不放宽学号格式要求,也不把不同学生的材料自动改成同一身份。已有项目若先前被人工填写为测试身份,需要核对所选资料并重新确认导入才能更新数据库中的身份。
+69 -10
View File
@@ -5,6 +5,7 @@ import { DashboardOutlined, BookOutlined, FileSearchOutlined, FolderOpenOutlined
import { api, post, put } from './api'; import { api, post, put } from './api';
import { Batches } from './Batches'; import { Batches } from './Batches';
import { projectLabel, projectsInBatch } from './batchProjects'; import { projectLabel, projectsInBatch } from './batchProjects';
import { fillImportIdentity, importErrors, reviewImport } from './importReview';
const Chat = lazy(() => import('./Chat')); const Chat = lazy(() => import('./Chat'));
const { Header, Sider, Content } = Layout; const { Header, Sider, Content } = Layout;
@@ -103,14 +104,26 @@ function Import({ openImported, scoreImported }: { openImported: (ids: number[])
useEffect(() => { load('/courses', setCourses); }, []); useEffect(() => { load('/courses', setCourses); }, []);
useEffect(() => { setScanData(undefined); setImported(undefined); load(`/storage/directories?relative=${encodeURIComponent(path)}`, setDirs); }, [path]); useEffect(() => { setScanData(undefined); setImported(undefined); load(`/storage/directories?relative=${encodeURIComponent(path)}`, setDirs); }, [path]);
useEffect(() => { if (courseId) load(`/courses/${courseId}/versions`, setVersions); }, [courseId]); 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 errors = importErrors(scanData?.projects, versionId);
const editFile = (projectIndex: number, fileIndex: number, key: string, value: any) => { const next = structuredClone(scanData); next.projects[projectIndex].files[fileIndex][key] = value; setScanData(next); }; 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 () => { const confirm = async () => {
if (!versionId || !scanData?.projects?.length) return; if (errors.length || importing) { message.error(errors[0] || '正在导入,请稍候'); return; }
setImporting(true); setImporting(true);
try { try {
const projects = scanData.projects.map((p: any) => ({ relative_dir: p.relative_dir, student_no: p.student_no, const projects = scanData.projects.map((p: any) => ({ relative_dir: p.relative_dir, student_no: p.student_no.trim(),
name: p.name, topic: p.topic, direction: p.direction || '', 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, files: p.files.map((f: any) => ({ relative_path: f.relative_path, category: f.category,
status: f.status === 'ignored' ? 'ignored' : 'recognized', sha256: f.sha256 })) })); status: f.status === 'ignored' ? 'ignored' : 'recognized', sha256: f.sha256 })) }));
const result = await post('/import/confirm', { course_version_id: versionId, projects }); 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); } } catch (error) { fail(error); }
finally { setImporting(false); } finally { setImporting(false); }
}; };
return <><Title level={2}>挂载目录扫描与导入确认</Title><Paragraph>浏览 <code>/data/inbox</code> 下的目录,可选择包含学生子文件夹的总目录,也可直接进入放有 PDF、DOCX、PPTX 的资料目录扫描。封面信息会预填,请核对学号、姓名和选题后确认导入。</Paragraph><Space><Button disabled={path === '.'} onClick={() => setPath(path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.')}>上一级</Button><Text code>{path}</Text><Button type="primary" onClick={async () => { try { setImported(undefined); setScanData(await post('/import/scan', { relative_dir: path })); } catch (e) { fail(e); } }}>扫描此目录</Button></Space><div className="folder-list">{dirs.map(dir => <Button key={dir.relative_path} onClick={() => setPath(dir.relative_path)}>📁 {dir.name}</Button>)}</div> const projectIndex = (project: any) => scanData.projects.findIndex((item: any) => item.relative_dir === project.relative_dir);
const identityInput = (project: any, key: string, label: string) => <Input aria-label={`${label} ${project.relative_dir}`}
status={key === 'student_no' ? (!/^[0-9]{5,20}$/.test((project[key] || '').trim()) ? 'error' : undefined) : (!project[key]?.trim() ? 'error' : undefined)}
placeholder={key === 'student_no' ? '5–20位数字' : `请填写${label}`} value={project[key]}
onChange={event => editProject(projectIndex(project), key, event.target.value)} />;
return <><Title level={2}>挂载目录扫描与导入确认</Title>
<Paragraph>浏览 <code>/data/inbox</code> 下的目录,可选择学生总目录或直接存放资料的目录。请核对封面身份;不属于当前项目的文件可设为“忽略”。</Paragraph>
<Space><Button disabled={path === '.'} onClick={() => setPath(path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.')}>上一级</Button><Text code>{path}</Text>
<Button type="primary" onClick={async () => { try {
setImported(undefined); const data = await post('/import/scan', { relative_dir: path });
data.projects = data.projects.map((project: any) => fillImportIdentity(project)); setScanData(data);
} catch (error) { fail(error); } }}>扫描此目录</Button></Space>
<div className="folder-list">{dirs.map(dir => <Button key={dir.relative_path} onClick={() => setPath(dir.relative_path)}>📁 {dir.name}</Button>)}</div>
{imported && imported.projectIds.length > 0 && <Alert className="top-gap" type="success" showIcon {imported && imported.projectIds.length > 0 && <Alert className="top-gap" type="success" showIcon
message={`已导入 ${imported.projectIds.length} 个学生资料包`} message={`已导入 ${imported.projectIds.length} 个学生资料包`} description="查看已导入资料核对解析状态,或去评分选择这次导入的资料。"
description="点击查看已导入资料核对文件与解析状态,或去评分选择这次导入的资料。"
action={<Space wrap><Button onClick={() => openImported(imported.projectIds)}>查看已导入资料</Button> action={<Space wrap><Button onClick={() => openImported(imported.projectIds)}>查看已导入资料</Button>
<Button type="primary" onClick={() => scoreImported(imported.courseVersionId, imported.projectIds)}>去评分</Button></Space>} />} <Button type="primary" onClick={() => scoreImported(imported.courseVersionId, imported.projectIds)}>去评分</Button></Space>} />}
{scanData && <><Divider />{!scanData.projects.length && <Alert className="top-gap" type="warning" showIcon message="未找到可导入资料" description="当前目录及其下一级学生目录中没有 PDF、DOCX 或 PPTX;请进入放有这些文件的目录再扫描。" />}<Space><Select aria-label="导入课程" style={{ width: 260 }} placeholder="课程" options={options(courses, c => c.name)} value={courseId} onChange={id => { setCourseId(id); setVersionId(undefined); setVersions([]); setImported(undefined); }} /><Select aria-label="导入课程版本" style={{ width: 270 }} placeholder="已发布课程版本" options={options(versions.filter(v => v.published), v => `v${v.version} ${v.title}`)} value={versionId} onChange={setVersionId} /><Button type="primary" loading={importing} disabled={!versionId || importing || !scanData.projects.length || scanData.projects.some((p: any) => !/^\d{5,20}$/.test(p.student_no) || !p.name?.trim() || !p.topic?.trim() || !p.files.some((f: any) => f.status !== 'ignored'))} onClick={confirm}>确认导入</Button></Space><Table className="top-gap" rowKey="relative_dir" dataSource={scanData.projects} pagination={{ pageSize: 10 }} columns={[{ title: '学号', render: (_, p, index) => <Input value={p.student_no} onChange={e => editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'student_no', e.target.value)} /> }, { title: '姓名', render: (_, p, index) => <Input value={p.name} onChange={e => editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'name', e.target.value)} /> }, { title: '选题', render: (_, p, index) => <Input value={p.topic} onChange={e => editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'topic', e.target.value)} /> }, { title: '选题方向', render: (_, p) => <Input value={p.direction || ''} placeholder="可修正" onChange={e => editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'direction', e.target.value)} /> }, { title: '待处理', render: (_, p) => <Space wrap>{p.status !== 'recognized' && <Tag color="orange">身份信息需确认</Tag>}{p.missing.map((c: string) => <Tag color="red" key={c}>缺{categories[c]}</Tag>)}{p.warnings?.map((w: string) => <Tag color="orange" key={w}>{w}</Tag>)}</Space> }]} {scanData && <><Divider />{errors.length > 0 && <Alert className="top-gap" type="warning" showIcon
expandable={{ defaultExpandAllRows: true, expandedRowRender: (p: any) => { const pi = scanData.projects.findIndex((item: any) => item.relative_dir === p.relative_dir); return <Table<any> size="small" rowKey="relative_path" pagination={false} dataSource={p.files} columns={[{ title: '文件', dataIndex: 'relative_path' }, { title: '类型', render: (_, f, fi) => <Select style={{ width: 170 }} value={f.category} options={[{ value: 'unknown', label: '未识别' }, ...Object.entries(categories).map(([value, label]) => ({ value, label }))]} onChange={value => editFile(pi, fi, 'category', value)} /> }, { title: '处理', render: (_, f, fi) => <Select style={{ width: 120 }} value={f.status === 'ignored' ? 'ignored' : 'recognized'} options={[{ value: 'recognized', label: '导入' }, { value: 'ignored', label: '忽略' }]} onChange={value => editFile(pi, fi, 'status', value)} /> }, { title: '扫描结果', render: (_, f) => <Tag color={f.status === 'recognized' ? 'green' : 'orange'}>{f.status}</Tag> }, { title: '异常', dataIndex: 'error' }]} />; } }} /></>} message={scanData.projects.length ? '暂不能确认导入' : '未找到可导入资料'}
description={<ul>{errors.map((error, index) => <li key={index}>{error}</li>)}</ul>} />}
<Space className="top-gap" wrap>
<Select aria-label="导入课程" style={{ width: 260 }} placeholder="课程" options={options(courses, c => c.name)} value={courseId}
onChange={id => { setCourseId(id); setVersionId(undefined); setVersions([]); setImported(undefined); }} />
<Select aria-label="导入课程版本" style={{ width: 270 }} placeholder="已发布课程版本" options={options(versions.filter(v => v.published), v => `v${v.version} ${v.title}`)} value={versionId} onChange={setVersionId} />
<Button type="primary" loading={importing} disabled={errors.length > 0 || importing} onClick={confirm}>确认导入</Button>
</Space>
<Table className="top-gap" rowKey="relative_dir" dataSource={scanData.projects} pagination={{ pageSize: 10 }} columns={[
{ title: '学号', render: (_, project) => identityInput(project, 'student_no', '学号') },
{ title: '姓名', render: (_, project) => identityInput(project, 'name', '姓名') },
{ title: '选题', render: (_, project) => identityInput(project, 'topic', '选题') },
{ title: '选题方向', render: (_, project) => <Input value={project.direction || ''} placeholder="可修正" onChange={event => editProject(projectIndex(project), 'direction', event.target.value)} /> },
{ title: '待处理', render: (_, project) => { const review = reviewImport(project); return <Space direction="vertical">
<Space wrap>{review.errors.length > 0 ? <Tag color="red">请修正导入信息</Tag> : <Tag color="green">导入信息已填写</Tag>}
{review.missing.map(category => <Tag key={category}>缺{categories[category]}</Tag>)}</Space>
{review.warnings.map(warning => <Text key={warning} type="warning">{warning}</Text>)}
{review.metadata && Object.values(review.identity).some(Boolean) && <Button size="small" onClick={() => useCover(projectIndex(project))}>使用所选资料的封面信息</Button>}
</Space>; } },
]} expandable={{ defaultExpandAllRows: true, expandedRowRender: (project: any) => {
const pi = projectIndex(project); return <Table<any> size="small" rowKey="relative_path" pagination={false} dataSource={project.files} columns={[
{ title: '文件', dataIndex: 'relative_path' },
{ title: '封面身份', render: (_, file) => <Space direction="vertical">
<Text>{file.detected_identity?.name ? `${file.detected_identity.name}(${file.detected_identity.student_no || '未识别学号'})` : '首页未识别身份'}</Text>
<Text type="secondary">{file.detected_identity?.topic || ''}</Text></Space> },
{ title: '类型', render: (_, file, fi) => <Select aria-label={`材料类型 ${file.relative_path}`} style={{ width: 170 }} value={file.category}
options={[{ value: 'unknown', label: '未识别' }, ...Object.entries(categories).map(([value, label]) => ({ value, label }))]} onChange={value => editFile(pi, fi, 'category', value)} /> },
{ title: '处理', render: (_, file, fi) => <Select aria-label={`处理 ${file.relative_path}`} style={{ width: 120 }} value={file.status === 'ignored' ? 'ignored' : 'recognized'}
options={[{ value: 'recognized', label: '导入' }, { value: 'ignored', label: '忽略' }]} onChange={value => editFile(pi, fi, 'status', value)} /> },
{ title: '扫描结果', render: (_, file) => <Tag color={file.status === 'recognized' ? 'green' : 'orange'}>
{({ recognized: '已识别', needs_confirmation: '需确认类型', duplicate: '同类多份', unparseable: '无法读取', ignored: '已忽略' } as Record<string, string>)[file.status] || file.status}</Tag> },
{ title: '异常', dataIndex: 'error' },
]} />;
} }} />
<Paragraph type="secondary">封面冲突请核对上表来源。允许缺少某类材料,相关评分项会标记为待处理。人工填写的信息会保留;点击“使用所选资料的封面信息”可重新采用识别值。</Paragraph>
</>}
</>; </>;
} }
+66
View File
@@ -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<string, string> = { 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<IdentityField, string> = { student_no: '', name: '', topic: '' };
const warnings: string[] = [];
for (const field of fields) {
const sources = new Map<string, string[]>();
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<string, number>();
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;
}
+98
View File
@@ -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();
});