feat: add conversation workspace and confirmation cards
This commit is contained in:
+28
-10
@@ -1,13 +1,15 @@
|
||||
import { 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 { DashboardOutlined, BookOutlined, FileSearchOutlined, FolderOpenOutlined, TeamOutlined, ThunderboltOutlined, AuditOutlined, ExportOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
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';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const categories: Record<string, string> = { stage1: '阶段报告1', stage2: '阶段报告2', stage3: '阶段报告3', final_report: '最终报告', presentation: '汇报PPT/PDF' };
|
||||
const nav = [
|
||||
{ key: 'chat', icon: <MessageOutlined />, label: '对话' },
|
||||
{ key: 'dashboard', icon: <DashboardOutlined />, label: '仪表盘' },
|
||||
{ key: 'courses', icon: <BookOutlined />, label: '课程与规则' },
|
||||
{ key: 'knowledge', icon: <FileSearchOutlined />, label: '知识库资料' },
|
||||
@@ -40,6 +42,7 @@ function Courses() {
|
||||
const [courses, setCourses] = useState<any[]>([]), [courseId, setCourseId] = useState<number>(), [versions, setVersions] = useState<any[]>([]), [versionId, setVersionId] = useState<number>(), [draft, setDraft] = useState<any>(), [rubrics, setRubrics] = useState<any[]>([]), [rubricId, setRubricId] = useState<number>(), [rubric, setRubric] = useState<any>();
|
||||
const refreshCourses = () => load('/courses', setCourses);
|
||||
useEffect(() => { refreshCourses(); }, []);
|
||||
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(() => { setDraft(versions.find(v => v.id === versionId)); if (versionId) load(`/course-versions/${versionId}/rubrics`, setRubrics); }, [versions, versionId]);
|
||||
useEffect(() => { setRubric(rubrics.find(r => r.id === rubricId)); }, [rubrics, rubricId]);
|
||||
@@ -71,6 +74,7 @@ function Courses() {
|
||||
function Knowledge() {
|
||||
const [courses, setCourses] = useState<any[]>([]), [versions, setVersions] = useState<any[]>([]), [courseId, setCourseId] = useState<number>(), [versionId, setVersionId] = useState<number>(), [docs, setDocs] = useState<any[]>([]);
|
||||
useEffect(() => { load('/courses', setCourses); }, []);
|
||||
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' }]} /></>;
|
||||
@@ -90,9 +94,11 @@ function Import() {
|
||||
}
|
||||
|
||||
function Projects({ openScores }: { openScores: (id: number) => void }) {
|
||||
const [projects, setProjects] = useState<any[]>([]), [selected, setSelected] = useState<any>();
|
||||
const [projects, setProjects] = useState<any[]>([]), [selected, setSelected] = useState<any>(), [indexTasks, setIndexTasks] = useState<any[]>([]);
|
||||
useEffect(() => { load('/projects', setProjects); }, []);
|
||||
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><pre className="json-view">{JSON.stringify(selected.facts, null, 2)}</pre></>}</Drawer></>;
|
||||
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 }) {
|
||||
@@ -101,7 +107,7 @@ function Batches({ openBatch }: { openBatch: (id: number) => void }) {
|
||||
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" dataSource={batches} columns={[{ title: '批次', dataIndex: 'id' }, { title: '课程/规则版本', render: (_, b) => `${b.course_version_id} / ${b.rubric_version_id}` }, { title: '状态', render: (_, b) => <Tag color={b.status === 'completed' ? 'green' : b.status === 'partial' || b.status === 'failed' ? 'orange' : 'blue'}>{b.status}</Tag> }, { 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 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><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) => <Tag color={b.status === 'completed' ? 'green' : b.status === 'partial' || b.status === 'failed' ? 'orange' : 'blue'}>{b.status}</Tag> }, { 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'].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> }]} /></>;
|
||||
}
|
||||
|
||||
function Scores({ batchId, projectId }: { batchId?: number; projectId?: number }) {
|
||||
@@ -119,8 +125,9 @@ function Exports() {
|
||||
const [tasks, setTasks] = useState<any[]>([]), [batches, setBatches] = useState<any[]>([]), [projects, setProjects] = useState<any[]>([]), [batchId, setBatchId] = useState<number>(), [projectId, setProjectId] = useState<number>();
|
||||
const refresh = () => load('/exports', setTasks);
|
||||
useEffect(() => { refresh(); load('/scoring-batches', setBatches); load('/projects', setProjects); }, []);
|
||||
useEffect(() => { const id = Number(new URLSearchParams(window.location.search).get('batch_id')); if (id) setBatchId(id); }, []);
|
||||
const create = async (kind: 'excel' | 'project-pdf') => { try { await post(kind === 'excel' ? '/exports/excel' : '/exports/project-pdf', { batch_id: batchId, project_id: projectId }); refresh(); message.success('导出完成'); } catch (e) { fail(e); } };
|
||||
return <><Title level={2}>成绩与反馈导出</Title><Alert type="info" message="未完成或无法评分项目在Excel中显示为空,并标记待处理;每个批次独立导出。" /><Space className="top-gap" wrap><Select style={{ width: 210 }} placeholder="选择批次" options={options(batches, b => `批次 #${b.id} · ${b.status}`)} onChange={setBatchId} /><Button disabled={!batchId} onClick={() => create('excel')}>导出Excel成绩表</Button><Select style={{ width: 300 }} placeholder="选择学生" options={options(projects, p => `${p.student_no} ${p.name}`)} onChange={setProjectId} /><Button disabled={!batchId || !projectId} onClick={() => create('project-pdf')}>导出学生PDF反馈</Button></Space><Table className="top-gap" rowKey="id" dataSource={tasks} columns={[{ title: '任务', dataIndex: 'id' }, { title: '批次', dataIndex: 'batch_id' }, { title: '项目', dataIndex: 'project_id' }, { title: '格式', dataIndex: 'kind' }, { title: '状态', dataIndex: 'status' }, { title: '异常', dataIndex: 'error' }, { title: '下载', render: (_, task) => task.status === 'completed' ? <a href={`/api/exports/${task.id}/download`}>下载文件</a> : '—' }]} /></>;
|
||||
return <><Title level={2}>成绩与反馈导出</Title><Alert type="info" message="未完成或无法评分项目在Excel中显示为空,并标记待处理;每个批次独立导出。" /><Space className="top-gap" wrap><Select style={{ width: 210 }} placeholder="选择批次" value={batchId} options={options(batches, b => `批次 #${b.id} · ${b.status}`)} onChange={setBatchId} /><Button disabled={!batchId} onClick={() => create('excel')}>导出Excel成绩表</Button><Select style={{ width: 300 }} placeholder="选择学生" options={options(projects, p => `${p.student_no} ${p.name}`)} onChange={setProjectId} /><Button disabled={!batchId || !projectId} onClick={() => create('project-pdf')}>导出学生PDF反馈</Button></Space><Table className="top-gap" rowKey="id" dataSource={tasks} columns={[{ title: '任务', dataIndex: 'id' }, { title: '批次', dataIndex: 'batch_id' }, { title: '项目', dataIndex: 'project_id' }, { title: '格式', dataIndex: 'kind' }, { title: '状态', dataIndex: 'status' }, { title: '异常', dataIndex: 'error' }, { title: '下载', render: (_, task) => task.status === 'completed' ? <a href={`/api/exports/${task.id}/download`}>下载文件</a> : '—' }]} /></>;
|
||||
}
|
||||
|
||||
function Settings() {
|
||||
@@ -130,9 +137,20 @@ function Settings() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [page, setPage] = useState('dashboard'), [batchId, setBatchId] = useState<number>(), [projectId, setProjectId] = useState<number>();
|
||||
const openScores = (id: number) => { setProjectId(id); setPage('scores'); };
|
||||
const openBatch = (id: number) => { setBatchId(id); setPage('scores'); };
|
||||
const pages: Record<string, ReactNode> = { 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 /> };
|
||||
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 }) => setPage(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]}</div></Content></Layout></Layout>;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const [page, setPage] = useState(params.get('page') || 'chat');
|
||||
const [batchId, setBatchId] = useState<number | undefined>(Number(params.get('batch_id')) || undefined);
|
||||
const [projectId, setProjectId] = useState<number | undefined>(Number(params.get('project_id')) || undefined);
|
||||
const navigate = (next: string, batch?: number, project?: number) => {
|
||||
setPage(next); setBatchId(batch); setProjectId(project);
|
||||
const query = new URLSearchParams({ page: next });
|
||||
if (batch) query.set('batch_id', String(batch));
|
||||
if (project) query.set('project_id', String(project));
|
||||
window.history.pushState({}, '', `/?${query.toString()}`);
|
||||
};
|
||||
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 /> };
|
||||
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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user