修复确认导入的校验提示及身份重算 #2
@@ -24,6 +24,8 @@ def test_scan_selected_student_folder_prefills_cover_identity(tmp_path, monkeypa
|
||||
assert project["status"] == "needs_confirmation"
|
||||
assert len(project["files"]) == 5
|
||||
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"]
|
||||
|
||||
|
||||
@@ -39,6 +41,26 @@ def test_scan_conflicting_covers_need_confirmation(tmp_path, monkeypatch):
|
||||
assert project["student_no"] == ""
|
||||
assert project["warnings"]
|
||||
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):
|
||||
|
||||
@@ -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
|
||||
@@ -3,7 +3,7 @@ from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
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.orm import Session
|
||||
|
||||
@@ -67,16 +67,33 @@ class FileConfirm(BaseModel):
|
||||
|
||||
class ProjectConfirm(BaseModel):
|
||||
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)
|
||||
topic: str = Field(min_length=1)
|
||||
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):
|
||||
course_version_id: int
|
||||
projects: list[ProjectConfirm]
|
||||
projects: list[ProjectConfirm] = Field(min_length=1)
|
||||
|
||||
|
||||
class BatchInput(BaseModel):
|
||||
|
||||
@@ -11,6 +11,7 @@ from .config import get_settings
|
||||
CATEGORIES = ("stage1", "stage2", "stage3", "final_report", "presentation")
|
||||
SUPPORTED = {".pdf", ".docx", ".pptx"}
|
||||
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:
|
||||
@@ -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)]
|
||||
for folder in folders:
|
||||
info = parse_student_folder(folder.name)
|
||||
folder_identity = {key: info[key] for key in IDENTITY_LABELS}
|
||||
files = []
|
||||
extracted = {}
|
||||
identity_values = {}
|
||||
@@ -124,6 +126,7 @@ def scan(relative: str, *, root: Path | None = None):
|
||||
if candidate.suffix.lower() not in SUPPORTED:
|
||||
continue
|
||||
error = ""
|
||||
identity = {}
|
||||
try:
|
||||
text = first_text(candidate)
|
||||
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,
|
||||
"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,
|
||||
"detected_identity": identity,
|
||||
"first_text": text[:500] if not error else ""})
|
||||
for category in CATEGORIES:
|
||||
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"
|
||||
for key, values in identity_values.items():
|
||||
if len(values) > 1:
|
||||
warnings.append(f"不同材料封面的{key}不一致,请核对后填写")
|
||||
warnings.append(f"不同材料封面的{IDENTITY_LABELS[key]}不一致,请核对后填写")
|
||||
else:
|
||||
extracted[key] = next(iter(values))
|
||||
for key, value in extracted.items():
|
||||
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):
|
||||
info[key] = value
|
||||
if warnings:
|
||||
info["status"] = "needs_confirmation"
|
||||
info.update({"relative_dir": folder.relative_to(base).as_posix(), "files": files,
|
||||
"folder_identity": folder_identity,
|
||||
"detected_identity": extracted, "warnings": warnings,
|
||||
"missing": [category for category in CATEGORIES if not any(file["category"] == category for file in files)]})
|
||||
projects.append(info)
|
||||
|
||||
@@ -25,3 +25,15 @@
|
||||
`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`。
|
||||
|
||||
## 后续:确认导入的校验与封面来源
|
||||
|
||||
按钮被禁用的原因原来隐藏在网页布尔表达式中;扫描只返回合并后的身份,冲突时清空字段,但未提供逐文件来源,忽略文件后也不重新计算。这使用户无法从页面判断如何完成合法导入。
|
||||
|
||||
- `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
@@ -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 <><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
|
||||
message={`已导入 ${imported.projectIds.length} 个学生资料包`}
|
||||
description="点击查看已导入资料核对文件与解析状态,或去评分选择这次导入的资料。"
|
||||
message={`已导入 ${imported.projectIds.length} 个学生资料包`} description="查看已导入资料核对解析状态,或去评分选择这次导入的资料。"
|
||||
action={<Space wrap><Button onClick={() => openImported(imported.projectIds)}>查看已导入资料</Button>
|
||||
<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> }]}
|
||||
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' }]} />; } }} /></>}
|
||||
{scanData && <><Divider />{errors.length > 0 && <Alert className="top-gap" type="warning" showIcon
|
||||
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>
|
||||
</>}
|
||||
</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user