fix: improve import, evidence indexing and material-scoped grading

Preserve document revisions and evidence, harden indexing and export behavior, add explicit material selection and direct-folder import, validate model JSON schemas with safe error messages, and retain student selection while loading scoring data.
This commit is contained in:
2026-10-14 13:52:56 +08:00
parent 29c95ce5ad
commit ebe33392ca
42 changed files with 2332 additions and 304 deletions
+74 -25
View File
@@ -1,9 +1,11 @@
import { useEffect, useState } from 'react';
import { lazy, Suspense, useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { Alert, Badge, Button, Card, Col, Collapse, Descriptions, Divider, Drawer, Form, Input, InputNumber, Layout, Menu, Modal, Progress, Row, Select, Space, Statistic, Table, Tag, Typography, message } from 'antd';
import { Alert, Badge, Button, Card, Col, Collapse, Descriptions, Divider, Drawer, Form, Input, InputNumber, Layout, Menu, Modal, Row, Select, Space, Statistic, Table, Tag, Typography, message } from 'antd';
import { DashboardOutlined, BookOutlined, FileSearchOutlined, FolderOpenOutlined, TeamOutlined, ThunderboltOutlined, AuditOutlined, ExportOutlined, SettingOutlined, MessageOutlined } from '@ant-design/icons';
import { api, post, put } from './api';
import Chat from './Chat';
import { Batches } from './Batches';
import { projectLabel, projectsInBatch } from './batchProjects';
const Chat = lazy(() => import('./Chat'));
const { Header, Sider, Content } = Layout;
const { Title, Text, Paragraph } = Typography;
@@ -14,7 +16,7 @@ const nav = [
{ key: 'courses', icon: <BookOutlined />, label: '课程与规则' },
{ key: 'knowledge', icon: <FileSearchOutlined />, label: '知识库资料' },
{ key: 'import', icon: <FolderOpenOutlined />, label: '目录扫描与导入' },
{ key: 'projects', icon: <TeamOutlined />, label: '学生项目' },
{ key: 'projects', icon: <TeamOutlined />, label: '学生资料' },
{ key: 'batches', icon: <ThunderboltOutlined />, label: '评分批次' },
{ key: 'scores', icon: <AuditOutlined />, label: '评分与复核' },
{ key: 'exports', icon: <ExportOutlined />, label: '导出任务' },
@@ -91,19 +93,40 @@ function Knowledge() {
useEffect(() => { const id = Number(new URLSearchParams(window.location.search).get('course_version_id')); if (id) api(`/course-versions/${id}`).then(v => { setCourseId(v.course_id); setVersionId(id); }).catch(fail); }, []);
useEffect(() => { if (courseId) load(`/courses/${courseId}/versions`, setVersions); }, [courseId]);
useEffect(() => { if (versionId) load(`/knowledge?course_version_id=${versionId}`, setDocs); }, [versionId]);
return <><Title level={2}>公共课程知识库</Title><Space><Select placeholder="课程" style={{ width: 260 }} options={options(courses, c => c.name)} onChange={setCourseId} /><Select placeholder="课程版本" style={{ width: 220 }} options={options(versions, v => `v${v.version} ${v.title}`)} onChange={setVersionId} /><Button type="primary" disabled={!versionId} onClick={async () => { try { const result = await post(`/knowledge/ingest-course/${versionId}`); message.info(`入库 ${result.completed.length} 份;失败 ${Object.keys(result.errors).length} 份`); load(`/knowledge?course_version_id=${versionId}`, setDocs); } catch (e) { fail(e); } }}>入库课程资料</Button></Space><Table className="top-gap" rowKey="id" dataSource={docs} columns={[{ title: '资料', dataIndex: 'title' }, { title: '类型', dataIndex: 'type' }, { title: '块数', dataIndex: 'chunks' }, { title: '状态', dataIndex: 'status' }]} /></>;
return <><Title level={2}>公共课程知识库</Title><Space><Select placeholder="课程" style={{ width: 260 }} options={options(courses, c => c.name)} onChange={setCourseId} /><Select placeholder="课程版本" style={{ width: 220 }} options={options(versions, v => `v${v.version} ${v.title}`)} onChange={setVersionId} /><Button type="primary" disabled={!versionId} onClick={async () => { try { const result = await post(`/knowledge/ingest-course/${versionId}`); message.info(`入库 ${result.completed.length} 份;失败 ${Object.keys(result.errors).length} 份`); load(`/knowledge?course_version_id=${versionId}`, setDocs); } catch (e) { fail(e); } }}>入库课程资料</Button></Space><Table className="top-gap" rowKey="id" dataSource={docs} columns={[{ title: '资料', dataIndex: 'title' }, { title: '类型', dataIndex: 'type' }, { title: '块数', dataIndex: 'chunks' }, { title: '状态', render: (_, doc) => <Tag color={doc.requires_reindex ? 'gold' : doc.status === 'ready' ? 'green' : 'default'}>{doc.requires_reindex ? '需重建索引' : ({ ready: '可检索', superseded: '历史版本', indexing: '索引处理中' }[doc.status as string] || doc.status)}</Tag> }]} /></>;
}
function Import() {
function Import({ openImported, scoreImported }: { openImported: (ids: number[]) => void; scoreImported: (versionId: number, ids: number[]) => void }) {
const [path, setPath] = useState('.'), [dirs, setDirs] = useState<any[]>([]), [scanData, setScanData] = useState<any>(), [courses, setCourses] = useState<any[]>([]), [versions, setVersions] = useState<any[]>([]), [courseId, setCourseId] = useState<number>(), [versionId, setVersionId] = useState<number>();
const [imported, setImported] = useState<{ courseVersionId: number; projectIds: number[] }>();
const [importing, setImporting] = useState(false);
useEffect(() => { load('/courses', setCourses); }, []);
useEffect(() => { 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]);
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); };
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 { 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>
{scanData && <><Divider /><Space><Select style={{ width: 260 }} placeholder="课程" options={options(courses, c => c.name)} onChange={setCourseId} /><Select style={{ width: 270 }} placeholder="已发布课程版本" options={options(versions.filter(v => v.published), v => `v${v.version} ${v.title}`)} value={versionId} onChange={setVersionId} /><Button type="primary" disabled={!versionId} onClick={async () => { 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 || '', 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 }); message.success(`已导入 ${result.project_ids.length} 个项目`); } catch (e) { fail(e); } }}>确认导入</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={{ 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' }]} />; } }} /></>}
const confirm = async () => {
if (!versionId || !scanData?.projects?.length) 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 || '',
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 });
setImported({ courseVersionId: versionId, projectIds: result.project_ids });
message.success(`已导入 ${result.project_ids.length} 个项目`);
} 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>
{imported && imported.projectIds.length > 0 && <Alert className="top-gap" type="success" showIcon
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' }]} />; } }} /></>}
</>;
}
@@ -112,25 +135,46 @@ function Projects({ openScores }: { openScores: (id: number) => void }) {
useEffect(() => { load('/projects', setProjects); }, []);
useEffect(() => { const id = Number(new URLSearchParams(window.location.search).get('project_id')); if (id) load(`/projects/${id}`, setSelected); }, []);
useEffect(() => { if (selected?.id) load(`/index-tasks?project_id=${selected.id}`, setIndexTasks); }, [selected?.id]);
return <><Title level={2}>学生项目</Title><Table rowKey="id" dataSource={projects} columns={[{ title: '学号', dataIndex: 'student_no' }, { title: '姓名', dataIndex: 'name' }, { title: '选题', dataIndex: 'topic' }, { title: '缺失材料', render: (_, p) => p.missing.length ? p.missing.map((c: string) => <Tag color="red" key={c}>{categories[c]}</Tag>) : <Tag color="green">齐全</Tag> }, { title: '解析异常', render: (_, p) => p.parse_errors.length ? <Badge status="error" text={p.parse_errors.length} /> : '—' }, { title: '操作', render: (_, p) => <Space><Button onClick={() => load(`/projects/${p.id}`, setSelected)}>材料详情</Button><Button type="link" onClick={() => openScores(p.id)}>查看评分</Button></Space> }]} /><Drawer open={!!selected} onClose={() => setSelected(undefined)} title={selected?.topic} width={700}>{selected && <><Descriptions column={1} items={[{ key: 'no', label: '学号', children: selected.student_no }, { key: 'name', label: '姓名', children: selected.name }, { key: 'direction', label: '选题方向', children: selected.direction || '未指定' }]} /><Table<any> rowKey="id" size="small" dataSource={selected.files} columns={[{ title: '材料', render: (_, f) => categories[f.category] }, { title: '文件', dataIndex: 'relative_path' }, { title: '解析', render: (_, f) => <Tag color={f.parse_status === 'ready' ? 'green' : 'orange'}>{f.parse_status}</Tag> }, { title: '错误', dataIndex: 'parse_error' }]} /><Title level={5}>解析与索引任务</Title><Table<any> rowKey="id" size="small" pagination={false} dataSource={indexTasks} columns={[{ title: '任务', dataIndex: 'id' }, { title: '材料 ID', dataIndex: 'submission_file_id' }, { title: '状态', render: (_, task) => <Tag color={task.status === 'completed' ? 'green' : task.status === 'failed' ? 'red' : 'orange'}>{task.status}</Tag> }, { title: '错误', dataIndex: 'error' }, { title: '操作', render: (_, task) => task.status === 'failed' && <Button size="small" onClick={async () => { try { await post(`/index-tasks/${task.id}/retry`); load(`/index-tasks?project_id=${selected.id}`, setIndexTasks); } catch (e) { fail(e); } }}>重试</Button> }]} /><Title level={5}>抽取事实与一致性</Title><pre className="json-view">{JSON.stringify(selected.facts, null, 2)}</pre></>}</Drawer></>;
}
function Batches({ openBatch }: { openBatch: (id: number) => void }) {
const [batches, setBatches] = useState<any[]>([]), [courses, setCourses] = useState<any[]>([]), [versions, setVersions] = useState<any[]>([]), [rubrics, setRubrics] = useState<any[]>([]), [courseId, setCourseId] = useState<number>(), [versionId, setVersionId] = useState<number>(), [rubricId, setRubricId] = useState<number>();
const refresh = () => load('/scoring-batches', setBatches);
useEffect(() => { refresh(); load('/courses', setCourses); const id = window.setInterval(refresh, 5000); return () => clearInterval(id); }, []);
useEffect(() => { if (courseId) load(`/courses/${courseId}/versions`, setVersions); }, [courseId]);
useEffect(() => { if (versionId) load(`/course-versions/${versionId}/rubrics`, setRubrics); }, [versionId]);
return <><Title level={2}>评分批次与进度</Title><Card title="创建独立批次"><Space wrap><Select style={{ width: 250 }} placeholder="课程" options={options(courses, c => c.name)} onChange={setCourseId} /><Select style={{ width: 250 }} placeholder="已发布课程版本" options={options(versions.filter(v => v.published), v => `v${v.version} ${v.title}`)} onChange={setVersionId} /><Select style={{ width: 230 }} placeholder="已发布规则版本" options={options(rubrics.filter(r => r.published), r => `v${r.version} · ${r.criteria.length}项`)} onChange={setRubricId} /><Button type="primary" disabled={!versionId || !rubricId} onClick={async () => { try { await post('/scoring-batches', { course_version_id: versionId, rubric_version_id: rubricId }); refresh(); message.success('批次已创建'); } catch (e) { fail(e); } }}>创建批次</Button></Space></Card><Table className="top-gap" rowKey="id" rowClassName={(b: any) => Number(new URLSearchParams(window.location.search).get("batch_id")) === b.id ? "linked-row" : ""} dataSource={batches} columns={[{ title: '批次', dataIndex: 'id' }, { title: '课程/规则版本', render: (_, b) => `${b.course_version_id} / ${b.rubric_version_id}` }, { title: '状态', render: (_, b) => <Space wrap><Tag color={b.status === 'completed' ? 'green' : b.status === 'review_required' ? 'gold' : b.status === 'partial' || b.status === 'failed' ? 'orange' : 'blue'}>{batchStatusLabel(b.status)}</Tag>{b.needs_review > 0 && <Tag color="gold">待复核 {b.needs_review}</Tag>}</Space> }, { title: '进度', render: (_, b) => <Progress percent={b.progress_total ? Math.round(b.progress_done / b.progress_total * 100) : 0} size="small" /> }, { title: '异常', dataIndex: 'error' }, { title: '操作', render: (_, b) => <Space><Button disabled={!['created', 'failed', 'partial', 'review_required'].includes(b.status)} onClick={async () => { try { await post(`/scoring-batches/${b.id}/start`); refresh(); } catch (e) { fail(e); } }}>启动/重试</Button><Button type="link" onClick={() => openBatch(b.id)}>查看评分</Button></Space> }]} /></>;
return <><Title level={2}>已导入学生资料</Title><Table rowKey="id" dataSource={projects} columns={[{ title: '学号', dataIndex: 'student_no' }, { title: '姓名', dataIndex: 'name' }, { title: '选题', dataIndex: 'topic' }, { title: '缺失材料', render: (_, p) => p.missing.length ? p.missing.map((c: string) => <Tag color="red" key={c}>{categories[c]}</Tag>) : <Tag color="green">齐全</Tag> }, { title: '解析异常', render: (_, p) => p.parse_errors.length ? <Badge status="error" text={p.parse_errors.length} /> : '—' }, { title: '操作', render: (_, p) => <Space><Button onClick={() => load(`/projects/${p.id}`, setSelected)}>材料详情</Button><Button type="link" onClick={() => openScores(p.id)}>查看评分</Button></Space> }]} /><Drawer open={!!selected} onClose={() => setSelected(undefined)} title={selected?.topic} width={700}>{selected && <><Descriptions column={1} items={[{ key: 'no', label: '学号', children: selected.student_no }, { key: 'name', label: '姓名', children: selected.name }, { key: 'direction', label: '选题方向', children: selected.direction || '未指定' }]} /><Table<any> rowKey="id" size="small" dataSource={selected.files} columns={[{ title: '材料', render: (_, f) => categories[f.category] }, { title: '文件', dataIndex: 'relative_path' }, { title: '解析', render: (_, f) => <Tag color={f.parse_status === 'ready' ? 'green' : 'orange'}>{f.parse_status}</Tag> }, { title: '错误', dataIndex: 'parse_error' }, { title: '解析提醒', render: (_, f) => <Space wrap>{f.warnings?.map((warning: any, index: number) => <Tag color="gold" key={index}>第{warning.page}页:{warning.message}</Tag>)}</Space> }]} /><Title level={5}>解析与索引任务</Title><Table<any> rowKey="id" size="small" pagination={false} dataSource={indexTasks} columns={[{ title: '任务', dataIndex: 'id' }, { title: '材料 ID', dataIndex: 'submission_file_id' }, { title: '状态', render: (_, task) => <Tag color={task.status === 'completed' ? 'green' : task.status === 'failed' ? 'red' : 'orange'}>{task.status}</Tag> }, { title: '错误', dataIndex: 'error' }, { title: '操作', render: (_, task) => task.status === 'failed' && <Button size="small" onClick={async () => { try { await post(`/index-tasks/${task.id}/retry`); load(`/index-tasks?project_id=${selected.id}`, setIndexTasks); } catch (e) { fail(e); } }}>重试</Button> }]} /><Title level={5}>抽取事实与一致性</Title><pre className="json-view">{JSON.stringify(selected.facts, null, 2)}</pre></>}</Drawer></>;
}
function Scores({ batchId, projectId }: { batchId?: number; projectId?: number }) {
const [batches, setBatches] = useState<any[]>([]), [projects, setProjects] = useState<any[]>([]), [batch, setBatch] = useState<number | undefined>(batchId), [project, setProject] = useState<number | undefined>(projectId), [result, setResult] = useState<any>(), [review, setReview] = useState<any>(), [revisions, setRevisions] = useState<any[]>([]);
const [form] = Form.useForm();
useEffect(() => { load('/scoring-batches', setBatches); load('/projects', setProjects); }, []);
useEffect(() => { if (batch && project) load(`/projects/${project}/scores?batch_id=${batch}`, setResult); }, [batch, project]);
const [sourcesReady, setSourcesReady] = useState(false);
useEffect(() => {
let cancelled = false;
Promise.all([api('/scoring-batches'), api('/projects')]).then(([batchRows, projectRows]) => {
if (cancelled) return;
setBatches(batchRows); setProjects(projectRows); setSourcesReady(true);
}).catch(error => { if (!cancelled) fail(error); });
return () => { cancelled = true; };
}, []);
const selectedBatch = batches.find(item => item.id === batch);
const scopedProjects = projectsInBatch(selectedBatch, projects);
useEffect(() => {
setResult(undefined); setReview(undefined); setRevisions([]);
if (!sourcesReady || !selectedBatch) return;
if (!scopedProjects.some(item => item.id === project)) {
setProject(scopedProjects.length === 1 ? scopedProjects[0].id : undefined);
return;
}
let cancelled = false;
api(`/projects/${project}/scores?batch_id=${batch}`).then(value => { if (!cancelled) setResult(value); })
.catch(error => { if (!cancelled) fail(error); });
return () => { cancelled = true; };
}, [batch, project, batches, projects, sourcesReady]);
const refresh = () => batch && project && load(`/projects/${project}/scores?batch_id=${batch}`, setResult);
return <><Title level={2}>逐项评分与教师复核</Title><Space wrap><Select style={{ width: 180 }} placeholder="评分批次" value={batch} options={options(batches, b => `批次 #${b.id} · ${b.status}`)} onChange={setBatch} /><Select style={{ width: 330 }} placeholder="学生项目" value={project} options={options(projects, p => `${p.student_no} ${p.name} · ${p.topic}`)} onChange={setProject} /></Space>{result && <><Card className="top-gap" title={`${result.student_no} ${result.name} · ${result.topic}`}><Space wrap>{Object.entries(result.totals).map(([key, value]) => <Tag key={key} color={value === null ? 'orange' : 'blue'}>{key}: {String(value ?? '待处理')}</Tag>)}</Space>{result.facts?.consistency_issues?.length > 0 && <Alert className="top-gap" type="warning" message="跨文档一致性提醒" description={result.facts.consistency_issues.map((issue: any) => issue.description).join(';')} />}</Card><Table className="top-gap" rowKey={(row: any) => row.criterion.criterion_id} dataSource={result.rows} pagination={false} columns={[{ title: '评分项', render: (_, r) => <><Tag>{categories[r.criterion.category] || r.criterion.category}</Tag>{r.criterion.name}</> }, { title: '模型原始分', render: (_, r) => `${r.model_score ?? '—'} / ${r.criterion.max_score}` }, { title: '教师分', render: (_, r) => r.teacher_score ?? '—' }, { title: '最终分', render: (_, r) => r.final_score ?? '待处理' }, { title: '状态', render: (_, r) => <Tag color={statusColor(r.status)}>{statusLabel(r.status)}</Tag> }, { title: '操作', render: (_, r) => <Button onClick={() => { setReview(r); form.setFieldsValue(reviewFormValues(r)); }}>查看证据/复核</Button> }]} /></>}
return <><Title level={2}>逐项评分与教师复核</Title>
<Space wrap>
<Select aria-label="评分批次" style={{ width: 250 }} placeholder="评分批次" value={batch}
options={options(batches, b => `批次 #${b.id} · ${batchStatusLabel(b.status)}`)} onChange={setBatch} />
<Select aria-label="学生项目" style={{ width: 460 }} placeholder="选择该批次中的学生项目" value={project}
disabled={!sourcesReady || !selectedBatch} options={options(scopedProjects, projectLabel)} onChange={setProject} />
</Space>
{selectedBatch && <Alert className="top-gap" type="info" showIcon
message={`批次 #${batch} 包含 ${scopedProjects.length} 个项目。下方分数仅对应当前选中的学生项目。`} />}
{result && <>{result.rows.some((r: any) => r.status === 'failed') && <Alert className="top-gap" type="error" showIcon message={`当前项目有 ${result.rows.filter((r: any) => r.status === 'failed').length} 项评分失败`} description="具体原因见下表。网络恢复后,可在评分批次页点击启动/重试;已经成功的评分和教师复核会保留。" />}<Card className="top-gap" title={`${result.student_no} ${result.name} · ${result.topic}`}><Space wrap>{Object.entries(result.totals).map(([key, value]) => <Tag key={key} color={value === null ? 'orange' : 'blue'}>{key}: {String(value ?? '待处理')}</Tag>)}</Space>{result.facts?.consistency_issues?.length > 0 && <Alert className="top-gap" type="warning" message="跨文档一致性提醒" description={result.facts.consistency_issues.map((issue: any) => issue.description).join(';')} />}</Card><Table className="top-gap" rowKey={(row: any) => row.criterion.criterion_id} dataSource={result.rows} pagination={false} columns={[{ title: '评分项', render: (_, r) => <><Tag>{categories[r.criterion.category] || r.criterion.category}</Tag>{r.criterion.name}</> }, { title: '模型原始分', render: (_, r) => `${r.model_score ?? '—'} / ${r.criterion.max_score}` }, { title: '教师分', render: (_, r) => r.teacher_score ?? '—' }, { title: '最终分', render: (_, r) => r.final_score ?? '待处理' }, { title: '状态 / 失败原因', render: (_, r) => <Space direction="vertical"><Tag color={statusColor(r.status)}>{statusLabel(r.status)}</Tag>{r.error && <Text type="secondary" style={{ maxWidth: 300 }}>{r.error}</Text>}</Space> }, { title: '操作', render: (_, r) => <Button onClick={() => { setReview(r); form.setFieldsValue(reviewFormValues(r)); }}>查看证据/复核</Button> }]} /></>}
<Modal open={!!review} title={review?.criterion?.name} width={850} onCancel={() => setReview(undefined)} onOk={async () => { try { const values = await form.validateFields(); await put(`/criterion-scores/${review.score_id}/review`, values); setReview(undefined); refresh(); message.success('复核记录已保存'); } catch (e) { fail(e); } }} okText="保存教师复核" okButtonProps={{ disabled: !review?.score_id }}>{review && <>{review.status === 'needs_review' && <Alert type="warning" showIcon className="top-gap" message="本项缺少正文证据,需要教师复核" description={`未找到正文证据的目标:${(review.evidence_gaps || []).map((gap: any) => gap.target_label).join('、') || review.missing_evidence?.join('、') || '见缺失证据'}。系统未给出模型分,也没有按低分处理;请核对材料后直接填写教师评分。`} />}<Descriptions column={1} items={[{ key: 'model', label: '模型分数/评语', children: `${review.model_score ?? '未给出'} · ${review.model_comment || ''}` }, { key: 'confidence', label: '模型置信度', children: review.confidence ?? '—' }, { key: 'missing', label: '缺失证据', children: review.missing_evidence?.join(';') || '无' }, { key: 'gaps', label: '缺正文证据的目标', children: (review.evidence_gaps || []).map((gap: any) => `${gap.target_label}(检索范围:${(gap.searched_page_types || []).map(pageTypeLabel).join('、') || '正文'})`).join(';') || '无' }, { key: 'suggestion', label: '改进建议', children: review.suggestion || '无' }, { key: 'error', label: '异常', children: review.error || '无' }]} /><Divider />{review.evidence?.map((ev: any, index: number) => <Card size="small" key={index} className="evidence"><Space wrap><Text strong>{ev.document} · {evidencePlace(ev)}</Text><Tag color={ev.page_type === 'body' || ev.page_type === 'appendix' ? 'green' : 'orange'}>{pageTypeLabel(ev.page_type)}</Tag>{ev.historical && <Tag>历史评分引用</Tag>}{ev.target_label && <Tag>{ev.target_label}</Tag>}</Space><Paragraph>{ev.quote}</Paragraph><Space wrap>{ev.url && <a href={ev.url} target="_blank" rel="noreferrer">查看页面文字</a>}{ev.image_url && <a href={ev.image_url} target="_blank" rel="noreferrer">{evidencePreviewLabel(ev)}(核对引文)</a>}</Space></Card>)}<Divider /><Form form={form} layout="vertical"><Form.Item label="教师评分" name="score" rules={[{ required: true, message: '请填写教师评分' }]}><InputNumber min={0} max={review.criterion.max_score} placeholder="请填写" /></Form.Item><Form.Item label="教师评语" name="comment" rules={[{ required: true }]}><Input.TextArea rows={3} /></Form.Item><Form.Item label="修改说明" name="reason"><Input /></Form.Item></Form>{review.score_id && <Button onClick={async () => { try { setRevisions(await api(`/criterion-scores/${review.score_id}/revisions`)); } catch (e) { fail(e); } }}>查看修改记录</Button>}{revisions.length > 0 && <pre className="json-view">{JSON.stringify(revisions, null, 2)}</pre>}</>}</Modal>
</>;
}
@@ -164,7 +208,12 @@ export default function App() {
};
useEffect(() => { const onPop = () => { const query = new URLSearchParams(window.location.search); setPage(query.get('page') || 'chat'); setBatchId(Number(query.get('batch_id')) || undefined); setProjectId(Number(query.get('project_id')) || undefined); }; window.addEventListener('popstate', onPop); return () => window.removeEventListener('popstate', onPop); }, []);
const openScores = (id: number) => navigate('scores', batchId, id);
const openBatch = (id: number) => navigate('scores', id, projectId);
const pages: Record<string, ReactNode> = { chat: <Chat />, dashboard: <Dashboard />, courses: <Courses />, knowledge: <Knowledge />, import: <Import />, projects: <Projects openScores={openScores} />, batches: <Batches openBatch={openBatch} />, scores: <Scores key={`${batchId}-${projectId}`} batchId={batchId} projectId={projectId} />, exports: <Exports />, settings: <Settings /> };
const openBatch = (id: number, targetProject?: number) => navigate('scores', id, targetProject);
const scoreImported = (versionId: number, ids: number[]) => {
navigate('batches');
const query = new URLSearchParams({ page: 'batches', course_version_id: String(versionId), project_ids: ids.join(',') });
window.history.replaceState({}, '', `/?${query.toString()}`);
};
const pages: Record<string, ReactNode> = { chat: <Suspense fallback={<div>正在加载会话…</div>}><Chat /></Suspense>, dashboard: <Dashboard />, courses: <Courses />, knowledge: <Knowledge />, import: <Import openImported={ids => navigate('projects', undefined, ids.length === 1 ? ids[0] : undefined)} scoreImported={scoreImported} />, projects: <Projects openScores={openScores} />, batches: <Batches key={`${params.get("course_version_id")}-${params.get("project_ids")}`} openBatch={openBatch} />, scores: <Scores key={`${batchId}-${projectId}`} batchId={batchId} projectId={projectId} />, exports: <Exports />, settings: <Settings /> };
return <Layout className="shell"><Sider breakpoint="lg" collapsedWidth="0" width={230} theme="dark"><div className="brand"><span className="brand-icon">智</span><div><strong>WiseGrade</strong><small>教师工作台</small></div></div><Menu theme="dark" mode="inline" selectedKeys={[page]} items={nav} onClick={({ key }) => navigate(key)} /></Sider><Layout><Header className="header"><div><strong>智评</strong><span className="header-sub">自动化专业综合实践</span></div><Tag color="blue">单机教师端</Tag></Header><Content className="content"><div className="content-inner">{pages[page] || pages.chat}</div></Content></Layout></Layout>;
}
+164
View File
@@ -0,0 +1,164 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Card, Form, Progress, Select, Space, Table, Tag, Typography, message } from 'antd';
import { api, post } from './api';
import { projectLabel, projectsInBatch } from './batchProjects';
const labels: Record<string, string> = {
created: '待启动', queued: '排队中', running: '运行中', completed: '已完成',
review_required: '已完成,有待复核项', partial: '部分完成', failed: '失败',
};
const fail = (error: unknown) => message.error(error instanceof Error ? error.message : String(error));
const options = (rows: any[], label: (row: any) => string) => rows.map(row => ({ value: row.id, label: label(row) }));
const materialTypes: Record<string, string> = { stage1: '阶段报告1', stage2: '阶段报告2', stage3: '阶段报告3', final_report: '最终报告', presentation: '汇报PPT' };
export function Batches({ openBatch }: { openBatch: (id: number, projectId?: number) => void }) {
const [batches, setBatches] = useState<any[]>([]), [projects, setProjects] = useState<any[]>([]);
const [courses, setCourses] = useState<any[]>([]), [versions, setVersions] = useState<any[]>([]), [rubrics, setRubrics] = useState<any[]>([]);
const [courseId, setCourseId] = useState<number>(), [versionId, setVersionId] = useState<number>(), [rubricId, setRubricId] = useState<number>();
const [projectIds, setProjectIds] = useState<number[]>([]), [creating, setCreating] = useState(false);
const refresh = () => Promise.all([api('/scoring-batches'), api('/projects')])
.then(([batchRows, projectRows]) => { setBatches(batchRows); setProjects(projectRows); }).catch(fail);
useEffect(() => {
refresh(); api('/courses').then(setCourses).catch(fail);
const timer = window.setInterval(refresh, 5000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const version = Number(params.get('course_version_id'));
const ids = (params.get('project_ids') || '').split(',').map(Number).filter(id => Number.isInteger(id) && id > 0);
if (!Number.isInteger(version) || version <= 0) return;
let cancelled = false;
api(`/course-versions/${version}`).then(item => {
if (!cancelled && item.published) {
setCourseId(item.course_id); setVersionId(item.id); setProjectIds([...new Set(ids)]);
}
}).catch(error => { if (!cancelled) fail(error); });
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (!courseId) return;
let cancelled = false;
api(`/courses/${courseId}/versions`).then(rows => { if (!cancelled) setVersions(rows); })
.catch(error => { if (!cancelled) fail(error); });
return () => { cancelled = true; };
}, [courseId]);
useEffect(() => {
if (!versionId) return;
let cancelled = false;
api(`/course-versions/${versionId}/rubrics`).then(rows => { if (!cancelled) setRubrics(rows); })
.catch(error => { if (!cancelled) fail(error); });
return () => { cancelled = true; };
}, [versionId]);
const available = projects.filter(project => project.course_version_id === versionId);
const selected = projectIds.filter(id => available.some(project => project.id === id));
const selectedFiles = available.filter(project => selected.includes(project.id)).flatMap(project =>
(project.materials || []).map((file: any) => ({ ...file, project_id: project.id, student: `${project.name}(${project.student_no})` })));
const materialLabel = (project: any) => `${projectLabel(project)}${project.materials?.length ? ` · ${project.materials.length === 1 ? project.materials[0].file_name : `${project.materials.length}份资料`}` : ''}`;
const chooseCourse = (id: number) => {
setCourseId(id); setVersionId(undefined); setRubricId(undefined);
setVersions([]); setRubrics([]); setProjectIds([]);
};
const chooseVersion = (id: number) => {
setVersionId(id); setRubricId(undefined); setRubrics([]); setProjectIds([]);
};
const create = async () => {
setCreating(true);
try {
const batch = await post('/scoring-batches', {
course_version_id: versionId, rubric_version_id: rubricId, project_ids: selected,
});
await refresh();
message.success(`已创建批次 #${batch.id},包含 ${selected.length} 个项目`);
} catch (error) { fail(error); }
finally { setCreating(false); }
};
return <>
<Typography.Title level={2}>评分批次与进度</Typography.Title>
<Card title="创建独立批次">
<Alert type="info" showIcon message="先选评分规则,再选择要评分的资料。资料按学生分组,选中后可核对具体文件。" />
<Form layout="vertical" className="top-gap">
<div className="batch-create-grid">
<Form.Item label="课程" htmlFor="batch-course">
<Select id="batch-course" style={{ width: '100%' }} placeholder="选择课程" value={courseId}
options={options(courses, course => course.name)} onChange={chooseCourse} />
</Form.Item>
<Form.Item label="课程版本" htmlFor="batch-version">
<Select id="batch-version" style={{ width: '100%' }} placeholder="选择已发布课程版本" value={versionId}
disabled={!courseId} options={options(versions.filter(version => version.published),
version => `课程 v${version.version} · ${version.title}`)} onChange={chooseVersion} />
</Form.Item>
<Form.Item label="评分规则" htmlFor="batch-rubric">
<Select id="batch-rubric" style={{ width: '100%' }} placeholder="选择已发布评分规则" value={rubricId}
disabled={!versionId} options={options(rubrics.filter(rubric => rubric.published),
rubric => `规则 v${rubric.version} · ${rubric.criteria.length}项`)} onChange={setRubricId} />
</Form.Item>
<Form.Item label="选择资料" htmlFor="batch-projects">
<Select id="batch-projects" mode="multiple" style={{ width: '100%' }} maxTagCount="responsive"
popupMatchSelectWidth={600} placeholder="选择已导入的资料" disabled={!versionId}
value={projectIds} options={options(available, materialLabel)} onChange={setProjectIds} optionFilterProp="label" />
</Form.Item>
<Form.Item label=" " colon={false}>
<Button type="primary" loading={creating} disabled={!versionId || !rubrics.some(rubric => rubric.id === rubricId && rubric.published) || !selected.length}
onClick={create}>创建批次</Button>
</Form.Item>
</div>
<Space wrap>
<Button disabled={!available.length} onClick={() => setProjectIds(available.map(project => project.id))}>全选本课程资料</Button>
<Button disabled={!projectIds.length} onClick={() => setProjectIds([])}>清空选择</Button>
<Typography.Text>已选择 {selected.length} 个项目</Typography.Text>
</Space>
</Form>
{selected.length > 0 && <div className="top-gap">
<Typography.Text strong>已选资料文件({selectedFiles.length}份)</Typography.Text>
<Table size="small" rowKey={file => `${file.project_id}-${file.id}`} className="top-gap" dataSource={selectedFiles}
pagination={selectedFiles.length > 5 ? { pageSize: 5 } : false} columns={[
{ title: '资料所属学生', dataIndex: 'student', width: 250 },
{ title: '文件名', dataIndex: 'file_name' },
{ title: '材料类型', render: (_, file) => materialTypes[file.category] || file.category, width: 130 },
{ title: '解析状态', render: (_, file) => <Tag color={file.parse_status === 'ready' ? 'green' : file.parse_status === 'failed' ? 'red' : 'blue'}>{file.parse_status === 'ready' ? '已解析' : file.parse_status === 'failed' ? '解析失败' : '处理中'}</Tag>, width: 110 },
]} />
</div>}
<Typography.Paragraph type="secondary" className="top-gap" style={{ marginBottom: 0 }}>
每个项目包含该学生已确认导入的材料。批次创建后固定项目清单;后续新导入的学生不会自动加入旧批次。
</Typography.Paragraph>
</Card>
<Table className="top-gap" rowKey="id" scroll={{ x: 1250 }} dataSource={batches}
rowClassName={(batch: any) => Number(new URLSearchParams(window.location.search).get('batch_id')) === batch.id ? 'linked-row' : ''}
columns={[
{ title: '批次', width: 100, render: (_, batch) => `#${batch.id}` },
{ title: '评分项目(谁的资料)', width: 310, render: (_, batch) => {
const members = projectsInBatch(batch, projects);
const ids: number[] = batch.project_ids ?? members.map(project => project.id);
return <Space direction="vertical" size={2}>
<Typography.Text>{ids.length} 个项目</Typography.Text>
{ids.map(id => {
const project = members.find(member => member.id === id);
return project ? <Button type="link" size="small" style={{ padding: 0 }} key={id}
title={project.topic} onClick={() => openBatch(batch.id, id)}>{project.name}({project.student_no})</Button>
: <Typography.Text type="secondary" key={id}>项目 #{id}(已不存在)</Typography.Text>;
})}
{batch.legacy_membership && <Tag>历史动态项目范围</Tag>}
</Space>;
}},
{ title: '课程 / 评分规则', width: 220, render: (_, batch) => <Space direction="vertical" size={2}>
<Typography.Text>{batch.course_title || `课程版本 ID ${batch.course_version_id}`}</Typography.Text>
<Typography.Text type="secondary">课程 {batch.course_version != null ? `v${batch.course_version}` : `ID ${batch.course_version_id}`} · 规则 {batch.rubric_version != null ? `v${batch.rubric_version}` : `ID ${batch.rubric_version_id}`}{batch.criterion_count != null && `(${batch.criterion_count}项)`}</Typography.Text>
</Space> },
{ title: '状态', width: 140, render: (_, batch) => <Space wrap>
<Tag color={batch.status === 'completed' ? 'green' : batch.status === 'review_required' ? 'gold' : ['partial', 'failed'].includes(batch.status) ? 'orange' : 'blue'}>{labels[batch.status] || batch.status}</Tag>
{batch.needs_review > 0 && <Tag color="gold">待复核 {batch.needs_review}</Tag>}
</Space> },
{ title: '进度', width: 160, render: (_, batch) => <>
<Typography.Text>{batch.progress_done} / {batch.progress_total} 项</Typography.Text>
<Progress percent={batch.progress_total ? Math.round(batch.progress_done / batch.progress_total * 100) : 0} size="small" />
</> },
{ title: '异常', dataIndex: 'error', width: 150 },
{ title: '操作', width: 180, render: (_, batch) => <Space wrap>
<Button disabled={!['created', 'failed', 'partial', 'review_required'].includes(batch.status)}
onClick={async () => { try { await post(`/scoring-batches/${batch.id}/start`); refresh(); } catch (error) { fail(error); } }}>启动/重试</Button>
<Button type="link" onClick={() => openBatch(batch.id)}>查看评分</Button>
</Space> },
]} />
</>;
}
+3 -1
View File
@@ -112,7 +112,9 @@ function CitationCards({ items }: { items: any[] }) {
const sourceHref = safeUrl(item.url);
const scoreHref = safeUrl(item.score_url);
const imageHref = safeUrl(item.image_url);
return <div className="citation-card" key={`${name}-${page}-${index}`}><div className="citation-heading"><Text strong>{name}</Text>{place && <Tag>{place}</Tag>}</div>
return <div className="citation-card" key={`${name}-${page}-${index}`}><div className="citation-heading"><Text strong>{name}</Text>{place && <Tag>{place}</Tag>}
{item.page_type_label && <Tag color={['body', 'appendix'].includes(item.page_type) ? 'green' : 'orange'}>{item.page_type_label}</Tag>}
{item.source_role === 'structure' && <Tag>结构信息</Tag>}{item.historical && <Tag>历史评分引用</Tag>}</div>
{quote && (quote.length > 320 ? <details><summary>{quote.slice(0, 240)}… 展开原文</summary><p>{quote}</p></details> : <p>{quote}</p>)}
<Space wrap size="small">{sourceHref && <a href={sourceHref} target="_blank" rel="noopener noreferrer">{sourceLabel(sourceHref)}</a>}
{scoreHref && scoreHref !== sourceHref && <a href={scoreHref} target="_blank" rel="noopener noreferrer">查看评分</a>}
+12
View File
@@ -0,0 +1,12 @@
export const projectLabel = (project: any) =>
`${project.name}(${project.student_no}) · ${project.topic}`;
export function projectsInBatch(batch: any, projects: any[]): any[] {
if (!batch) return [];
// [] is an empty snapshot. Only null/absent membership is a legacy batch.
if (batch.project_ids == null) {
return projects.filter(project => project.course_version_id === batch.course_version_id);
}
const byId = new Map(projects.map(project => [project.id, project]));
return batch.project_ids.map((id: number) => byId.get(id)).filter(Boolean);
}
+4
View File
@@ -12,6 +12,10 @@ body { margin: 0; }
.content { padding: 28px; }
.content-inner { max-width: 1450px; margin: 0 auto; }
.top-gap { margin-top: 20px; }
.batch-create-grid { display: grid; grid-template-columns: minmax(165px,.9fr) minmax(200px,1fr) minmax(160px,.8fr) minmax(260px,1.5fr) auto; gap: 10px; align-items: start; }
.batch-create-grid .ant-form-item { min-width: 0; }
@media (max-width: 1100px) { .batch-create-grid { grid-template-columns: repeat(2,minmax(0,1fr)); } }
@media (max-width: 700px) { .batch-create-grid { grid-template-columns: minmax(0,1fr); } }
.left-gap { margin-left: 12px; }
.folder-list { display: flex; flex-wrap: wrap; gap: 9px; margin-top: 18px; }
.folder-list button { height: auto; min-height: 40px; text-align: left; }