feat: 重构学生首页与题库管理功能,优化交互与样式

- 重构学生首页布局,新增学习时间与任务进度可视化模块
- 移除业务可行性视图重复的工具栏
- 注销保存当前进度按钮
- 修正题库管理的选项操作逻辑,新增选项删除功能
- 修正资料按钮的文本与注释
- 新增题目编辑与AI出题弹窗的样式适配
- 新增题库管理相关的SCSS样式
fix/exam-question-access-material-excel-delete-draft
qinzhenpen 2 weeks ago
parent 6c1a8149ae
commit 1de23486f3

@ -1572,7 +1572,9 @@ body.teacher-light-mode {
// ===== 弹窗(teacher-dialog / admin-edit-dialog / platform-admin-dialog:扁平纯白 + 教育风令牌)=====
.teacher-dialog,
.admin-edit-dialog,
.platform-admin-dialog {
.platform-admin-dialog,
.question-editor-dialog,
.question-ai-dialog {
// dialog 作用域内重设 EP CSS 变量,防止 html.dark 的 --el-color-primary:#00aaff 污染
--el-color-primary: #3b82f6;
--el-color-primary-light-3: #60a5fa;
@ -1853,6 +1855,46 @@ body.teacher-light-mode {
}
}
// ===== 超管题库管理弹窗(question-editor-dialog / question-ai-dialog)=====
// 继承上方 platform-admin-dialog 的颜色/令牌规则,但覆盖 flex 布局,
// 保持原有弹窗排版(不强制 flex 列布局,避免内部自定义网格被挤压)
.question-editor-dialog,
.question-ai-dialog {
display: block !important;
background: #ffffff !important;
border: 1px solid #e5e7eb !important;
border-radius: 12px !important;
box-shadow: 0 20px 56px rgba(30, 60, 100, 0.12) !important;
overflow: visible !important;
.el-dialog__header {
background: #ffffff !important;
border-bottom: 1px solid #e5e7eb;
padding: 16px 20px 12px;
.el-dialog__title {
color: #1f2937 !important;
}
.el-dialog__close {
color: #9ca3af !important;
}
}
.el-dialog__body {
background: #ffffff !important;
color: #1f2937;
padding: 20px;
overflow: visible;
}
.el-dialog__footer {
background: #ffffff !important;
border-top: 1px solid #e5e7eb;
padding: 12px 20px;
}
}
// 弹窗遮罩层:中灰半透明、无 blur、不影响内容清晰度
// 注:遮罩层在 body 下(teleport),用 body.teacher-light-mode 命中
.el-overlay {

@ -11,20 +11,20 @@
<Download />
</el-icon>案例资料
</el-button>
<el-button v-if="trainingData" class="material-button" @click="downloadMaterial(trainingData, '案例视频')">
<el-button v-if="trainingData" class="material-button" @click="downloadMaterial(trainingData, '案例数据')">
<el-icon>
<Download />
</el-icon>案例视频
</el-icon>案例数据
</el-button>
<el-button v-if="guideManual" class="material-button" @click="downloadMaterial(guideManual, '实训指导手册')">
<el-icon>
<Download />
</el-icon>实训指导手册
</el-button>
<el-button v-if="aiManual" class="material-button" @click="downloadMaterial(aiManual, 'AI排错指导手册')">
<el-button v-if="aiManual" class="material-button" @click="downloadMaterial(aiManual, 'AI辅助指导手册')">
<el-icon>
<Download />
</el-icon>AI排错指导手册
</el-icon>AI辅助指导手册
</el-button>
</div>
@ -46,13 +46,13 @@ const props = defineProps({
materialUrl: { type: String, default: "" },
caseMaterialName: { type: String, default: "" },
caseMaterialUrl: { type: String, default: "" },
// 案例视频(内部字段沿用 trainingData 命名)
// 案例数据(内部字段沿用 trainingData 命名)
trainingDataName: { type: String, default: "" },
trainingDataUrl: { type: String, default: "" },
// 实训指导手册
guideManualName: { type: String, default: "" },
guideManualUrl: { type: String, default: "" },
// AI 排错指导手册(内部字段沿用 aiManual 命名)
// AI 辅助指导手册(内部字段沿用 aiManual 命名)
aiManualName: { type: String, default: "" },
aiManualUrl: { type: String, default: "" }
})

@ -10,9 +10,11 @@
:ai-manual-name="resolvedAiManual.name"
:ai-manual-url="resolvedAiManual.url"
/>
<!-- 保存当前进度按钮已注销
<el-button class="task-toolbar-button task-toolbar-button--save" :loading="savingProgress" @click="triggerExistingAction('save')">
<el-icon><CircleCheck /></el-icon>保存当前进度
</el-button>
-->
<el-button class="task-toolbar-button task-toolbar-button--submit" @click="triggerExistingAction('submit')">
<el-icon><CircleCheck /></el-icon>提交任务
</el-button>
@ -314,6 +316,7 @@ function exportLegacyTraining() {
<style lang="scss" scoped>
.training-task-toolbar { display:flex; flex-wrap:wrap; align-items:center; justify-content:flex-end; gap:8px; }
.training-task-toolbar :deep(.material-actions) { margin-right:auto; }
.task-toolbar-button { min-height:34px; height:34px; padding:0 14px; border-color:rgba(37,99,235,.35); border-radius:8px; color:#2563eb; background:#fff; font-size:13px; font-weight:600; }
.task-toolbar-button:hover { border-color:#2563eb; color:#1d4ed8; background:#eff6ff; }
.task-toolbar-button--save { border-color:rgba(43,164,113,.45); color:#2ba471; }

@ -25,10 +25,6 @@
<TrainingTaskToolbar :task-key="TASK_KEY" :on-submit="saveStepThree" :on-export="exportOutcome" />
</section>
<section class="task-action-strip">
<TrainingTaskToolbar :task-key="TASK_KEY" :on-submit="saveStepThree" :on-export="exportOutcome" />
</section>
<section class="workbench">
<nav class="step-indicator" aria-label="商业可行性论证步骤">

@ -1,28 +1,28 @@
<template>
<main class="question-bank app-container">
<header class="page-head"><div><h1>题库管理</h1><p>维护平台公共题库,供教师组卷使用</p></div><div class="actions"><el-button type="primary" :icon="Plus" @click="openEditor()">添加题目</el-button><el-button type="success" :icon="Upload" :loading="loading" @click="pickImport">批量导入</el-button><el-dropdown trigger="click" @command="downloadTemplate"><el-button type="success" :icon="Download">下载模板<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="word">Word 导入模板</el-dropdown-item><el-dropdown-item command="excel">Excel 导入模板</el-dropdown-item></el-dropdown-menu></template></el-dropdown><el-button type="primary" @click="openAi">AI出题</el-button></div></header>
<header class="page-head"><div><h1>题库管理</h1><p>维护平台公共题库,供教师组卷使用</p></div><div class="actions"><el-button type="primary" :icon="Plus" @click="openEditor()">添加题目</el-button><el-button type="success" :icon="Upload" :loading="loading" @click="pickImport">批量导入</el-button><el-dropdown trigger="click" @command="downloadTemplate"><el-button type="success" :icon="Download">下载模板<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="word">Word 导入模板</el-dropdown-item><el-dropdown-item command="excel">Excel 导入模板</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></header>
<section class="toolbar"><el-select v-model="query.type" clearable placeholder="题型:请选择" @change="load"><el-option v-for="item in types" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-select v-model="query.taskKey" clearable placeholder="归属项目:请选择" @change="load"><el-option v-for="item in projects" :key="item.taskKey" :label="projectLabel(item)" :value="item.taskKey" /></el-select><el-input v-model="query.keyword" placeholder="请输入题干关键词" clearable @keyup.enter="load" /><el-button type="primary" :icon="Search" @click="load">搜索</el-button><input ref="fileInput" type="file" accept=".docx,.xlsx,.xls" hidden @change="importFile" /></section>
<el-table v-loading="loading" :data="questions" class="data-table"><el-table-column type="index" label="序号" width="78" /><el-table-column label="题干" min-width="420" show-overflow-tooltip><template #default="{ row }"><span>{{ plainHtml(row.stem) }}</span></template></el-table-column><el-table-column label="题型" width="120"><template #default="{ row }"><el-tag>{{ typeName[row.type] }}</el-tag></template></el-table-column><el-table-column label="题目来源" width="140"><template #default><el-tag type="success">平台题库</el-tag></template></el-table-column><el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openEditor(row)">编辑</el-button><el-button link type="primary" @click="openEditor(row)">查看</el-button><el-button link type="danger" @click="remove(row)">删除</el-button></template></el-table-column><template #empty><el-empty description="暂无题目" /></template></el-table>
<p class="bank-note">说明:平台题库为系统默认题库,所有教师可见;教师仅可查看平台题目,编辑删除自己新增的题目。</p>
<el-dialog v-model="editorVisible" :title="form.id ? '编辑题目' : '新增题目'" width="min(1180px, 94vw)" top="4vh" class="question-editor-dialog" destroy-on-close>
<div class="editor-shell"><div class="editor-project"><span>归属项目</span><el-select v-model="form.taskKey" placeholder="请选择归属项目"><el-option v-for="item in projects" :key="item.taskKey" :label="projectLabel(item)" :value="item.taskKey" /></el-select></div>
<div class="type-switch"><span>题型:</span><el-radio-group v-model="form.type" @change="resetForType"><el-radio-button v-for="item in types" :key="item.value" :label="item.value">{{ item.label }}</el-radio-button></el-radio-group></div>
<div class="type-switch"><span>题型</span><el-select v-model="form.type" @change="resetForType" class="type-select"><el-option v-for="item in types" :key="item.value" :label="item.label" :value="item.value" /></el-select></div>
<div class="rich-row"><label>题干</label><Editor v-model="form.stem" :min-height="154" /></div>
<template v-if="isChoice"><div class="option-block"><label>选项</label><div class="option-list"><div v-for="option in form.options" :key="option.label" class="option-row"><el-radio v-if="form.type === 'SINGLE'" v-model="singleAnswer" :label="option.label" class="answer-control">{{ option.label }}</el-radio><el-checkbox v-else v-model="answers" :label="option.label" class="answer-control">{{ option.label }}</el-checkbox><Editor v-model="option.content" :min-height="92" /></div></div></div><div class="rich-row analysis-row"><label>答案解析(选填)</label><Editor v-model="form.analysis" :min-height="112" /></div></template>
<template v-else-if="form.type === 'TRUE_FALSE'"><div class="tf-block"><label>选项</label><el-radio-group v-model="form.answer"><el-radio :label="true">对</el-radio><el-radio :label="false">错</el-radio></el-radio-group></div><div class="rich-row analysis-row"><label>答案解析(选填)</label><Editor v-model="form.analysis" :min-height="112" /></div></template>
<template v-if="isChoice"><div class="option-block"><label>选项</label><div class="option-list"><div v-for="(option, index) in form.options" :key="index" class="option-row"><div class="option-letter"><el-radio v-if="form.type === 'SINGLE'" v-model="singleAnswer" :label="optionLabel(index)" class="answer-control" /><el-checkbox v-else v-model="answers" :label="optionLabel(index)" class="answer-control" /><span class="letter-tag">{{ optionLabel(index) }}</span></div><Editor v-model="option.content" :min-height="92" /><el-button v-if="form.options.length > 2" link type="danger" :icon="Delete" @click="removeOption(index)" class="remove-btn" /></div><el-button type="primary" plain :icon="Plus" @click="addOption" class="add-option-btn">新增选项</el-button></div></div><div class="rich-row analysis-row"><label>答案解析(选填)</label><Editor v-model="form.analysis" :min-height="112" /></div></template>
<template v-else-if="form.type === 'TRUE_FALSE'"><div class="tf-block"><label>选项</label><div class="tf-options"><el-radio-group v-model="form.answer" class="tf-radio-group"><el-radio-button :label="true" class="tf-radio">对</el-radio-button><el-radio-button :label="false" class="tf-radio">错</el-radio-button></el-radio-group></div></div><div class="rich-row analysis-row"><label>答案解析(选填)</label><Editor v-model="form.analysis" :min-height="112" /></div></template>
<template v-else><div class="material-row"><el-upload :show-file-list="false" :http-request="uploadMaterial" accept=".pdf,.doc,.docx,.png,.jpg,.jpeg"><el-button type="primary" plain :loading="uploading">上传简答题资料</el-button></el-upload><span v-if="form.attachmentName" class="material-name">{{ form.attachmentName }}</span></div><div class="rich-row"><label>参考答案</label><Editor v-model="form.answer" :min-height="180" /></div><div class="rich-row analysis-row"><label>答案解析(选填)</label><Editor v-model="form.analysis" :min-height="112" /></div></template>
</div><template #footer><el-button @click="editorVisible=false">取消</el-button><el-button type="primary" :loading="saving" @click="save">确定</el-button></template>
</el-dialog>
<el-dialog v-model="aiVisible" title="AI 出题" width="620px"><el-form :model="aiForm" label-width="92px"><el-form-item label="归属项目"><el-select v-model="aiForm.taskKey" style="width:100%"><el-option v-for="item in projects" :key="item.taskKey" :label="projectLabel(item)" :value="item.taskKey" /></el-select></el-form-item><el-form-item label="题型"><el-select v-model="aiForm.type"><el-option v-for="item in types" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="数量"><el-input-number v-model="aiForm.count" :min="1" :max="50" /></el-form-item><el-form-item label="出题要求"><el-input v-model="aiForm.requirement" type="textarea" :rows="4" placeholder="例如:偏重实训场景,难度中等" /></el-form-item></el-form><el-alert v-if="preview.length" title="请确认预览内容后再入库" type="info" :closable="false" class="preview-alert" /><el-scrollbar v-if="preview.length" max-height="280px"><div v-for="(q, i) in preview" :key="i" class="preview-item"><b>{{ i + 1 }}. {{ plainHtml(q.stem) }}</b><div>答案:{{ displayAnswer(q.answer) }}</div></div></el-scrollbar><template #footer><el-button @click="aiVisible=false">取消</el-button><el-button v-if="!preview.length" type="primary" :loading="aiLoading" @click="previewAi">生成预览</el-button><el-button v-else type="primary" :loading="aiLoading" @click="confirmAi">确认入库</el-button></template></el-dialog>
<el-dialog v-model="aiVisible" title="AI 出题" width="620px" class="question-ai-dialog"><el-form :model="aiForm" label-width="92px"><el-form-item label="归属项目"><el-select v-model="aiForm.taskKey" style="width:100%"><el-option v-for="item in projects" :key="item.taskKey" :label="projectLabel(item)" :value="item.taskKey" /></el-select></el-form-item><el-form-item label="题型"><el-select v-model="aiForm.type"><el-option v-for="item in types" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="数量"><el-input-number v-model="aiForm.count" :min="1" :max="50" /></el-form-item><el-form-item label="出题要求"><el-input v-model="aiForm.requirement" type="textarea" :rows="4" placeholder="例如:偏重实训场景,难度中等" /></el-form-item></el-form><el-alert v-if="preview.length" title="请确认预览内容后再入库" type="info" :closable="false" class="preview-alert" /><el-scrollbar v-if="preview.length" max-height="280px"><div v-for="(q, i) in preview" :key="i" class="preview-item"><b>{{ i + 1 }}. {{ plainHtml(q.stem) }}</b><div>答案:{{ displayAnswer(q.answer) }}</div></div></el-scrollbar><template #footer><el-button @click="aiVisible=false">取消</el-button><el-button v-if="!preview.length" type="primary" :loading="aiLoading" @click="previewAi">生成预览</el-button><el-button v-else type="primary" :loading="aiLoading" @click="confirmAi">确认入库</el-button></template></el-dialog>
</main>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { ArrowDown, Download, Plus, Search, Upload } from "@element-plus/icons-vue";
import { ArrowDown, Delete, Download, Plus, Search, Upload } from "@element-plus/icons-vue";
import { saveAs } from "file-saver";
import * as api from "@/api/exam";
@ -35,7 +35,10 @@ function emptyQuestion() { return { id: null, taskKey: "", type: "SINGLE", stem:
function projectLabel(item) { return `${item.projectName} / ${item.taskName}`; } function plainHtml(value) { return String(value || "").replace(/<[^>]+>/g, "").replace(/&nbsp;/g, " "); }
async function load() { loading.value = true; try { questions.value = (await api.getPlatformQuestions(query)).data || []; } finally { loading.value = false; } } async function init() { projects.value = (await api.getPlatformQuestionProjects()).data || []; await load(); } onMounted(init);
function openEditor(row) { Object.assign(form, row ? JSON.parse(JSON.stringify(row)) : emptyQuestion()); if (!Array.isArray(form.answer) && isChoice.value) form.answer = []; editorVisible.value = true; } function openAi() { aiForm.taskKey = query.taskKey || aiForm.taskKey || projects.value[0]?.taskKey || ""; preview.value = []; aiVisible.value = true; }
function resetForType() { form.answer = form.type === "TRUE_FALSE" ? true : form.type === "SHORT_ANSWER" ? "<p></p>" : []; if (form.type !== "SHORT_ANSWER") { form.attachmentName = ""; form.attachmentUrl = ""; } }
function optionLabel(index) { return String.fromCharCode(65 + index); }
function addOption() { if (form.options.length >= 26) return ElMessage.warning("最多支持 26 个选项"); form.options.push({ label: optionLabel(form.options.length), content: "<p></p>" }); }
function removeOption(index) { if (form.options.length <= 2) return ElMessage.warning("至少保留 2 个选项"); const removed = form.options[index]; form.options.splice(index, 1); form.options.forEach((opt, i) => { opt.label = optionLabel(i); }); if (Array.isArray(form.answer)) form.answer = form.answer.filter((a) => a !== removed.label); }
function resetForType() { form.answer = form.type === "TRUE_FALSE" ? true : form.type === "SHORT_ANSWER" ? "<p></p>" : []; if (form.type !== "SHORT_ANSWER") { form.attachmentName = ""; form.attachmentUrl = ""; } if (isChoice.value) { const count = form.options.length || 4; form.options = Array.from({ length: Math.max(count, 4) }, (_, i) => ({ label: optionLabel(i), content: "<p></p>" })); } }
async function save() { if (!form.taskKey || !plainHtml(form.stem).trim()) return ElMessage.warning("请填写归属项目和题干"); if (isChoice.value && (!form.answer.length || form.options.some((item) => !plainHtml(item.content).trim()))) return ElMessage.warning("请补全选项并设置正确答案"); if (form.type === "SHORT_ANSWER" && !plainHtml(form.answer).trim()) return ElMessage.warning("请填写参考答案"); saving.value = true; try { await (form.id ? api.updatePlatformQuestion(form.id, form) : api.createPlatformQuestion(form)); ElMessage.success("题目已保存"); editorVisible.value = false; load(); } finally { saving.value = false; } }
async function remove(row) { await ElMessageBox.confirm("删除后无法恢复,确认删除?", "删除题目", { type: "warning" }); await api.deletePlatformQuestion(row.id); ElMessage.success("已删除"); load(); } function pickImport() { if (!query.taskKey) return ElMessage.warning("请先选择归属项目,再导入题目"); fileInput.value.value = ""; fileInput.value.click(); }
async function importFile(event) { const file = event.target.files[0]; if (!file) return; if (!query.taskKey) return ElMessage.warning("请先选择归属项目"); const data = new FormData(); data.append("file", file); await api.importPlatformQuestions(data, query.taskKey); ElMessage.success("导入成功"); event.target.value = ""; load(); }
@ -51,5 +54,5 @@ function displayAnswer(value) { return Array.isArray(value) ? value.join("、")
.page-head h1 { margin: 0; font-size: 22px; font-weight: 700; color: #1f2937; }.page-head p { margin: 7px 0 0; color: #6b7280; font-size: 13px; }.actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding: 14px; margin-bottom: 16px; border: 1px solid #e5e7eb; border-radius: 10px; background: #ffffff; }.toolbar .el-select { width: 210px; }.toolbar .el-input { width: min(300px, 30vw); }
.data-table { background: #ffffff; border-radius: 10px; }.bank-note { margin: 14px auto 0; max-width: 1180px; color: #6b7280; font-size: 13px; line-height: 1.8; text-align: center; }
.editor-shell { padding: 4px 8px 12px; color: #243746; }.editor-project { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; }.editor-project > span { width: 62px; color: #526979; }.editor-project .el-select { width: 420px; }.type-switch { display: flex; align-items: center; gap: 10px; margin: 4px 0 22px; font-size: 16px; }.rich-row, .option-block { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 12px; margin-bottom: 22px; }.rich-row > label, .option-block > label, .tf-block > label { padding-top: 10px; color: #526979; font-size: 15px; text-align: right; }.option-list { display: grid; gap: 12px; }.option-row { display: grid; grid-template-columns: 68px minmax(0, 1fr); align-items: start; gap: 8px; }.answer-control { padding-top: 12px; }.tf-block { display: flex; align-items: center; gap: 24px; margin: 18px 0 30px; }.tf-block .el-radio-group { display: flex; gap: 28px; }.material-row { display: flex; align-items: center; gap: 14px; margin: 6px 0 22px 102px; }.material-name { color: #5c7585; }.preview-alert { margin-bottom: 12px; }.preview-item { padding: 10px 4px; border-bottom: 1px solid #24465a; line-height: 1.7; }.editor-shell :deep(.ql-container) { min-height: 88px; font-size: 15px; }.editor-shell :deep(.ql-editor) { min-height: 74px; }.editor-shell :deep(.ql-toolbar) { background: #fff; }.editor-shell :deep(.ql-editor.ql-blank::before) { color: #a6b0b8; font-style: normal; }
.editor-shell { padding: 4px 8px 12px; color: #1f2937; }.editor-project { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; }.editor-project > span { width: 62px; color: #4b5563; }.editor-project .el-select { width: 420px; }.type-switch { display: flex; align-items: center; gap: 12px; margin: 4px 0 22px; }.type-switch > span { width: 62px; color: #4b5563; font-size: 15px; text-align: right; }.type-select { width: 200px; }.rich-row, .option-block { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 12px; margin-bottom: 22px; align-items: start; }.rich-row > label, .option-block > label, .tf-block > label { padding-top: 10px; color: #4b5563; font-size: 15px; text-align: right; }.option-list { display: grid; gap: 12px; }.option-row { display: grid; grid-template-columns: 56px minmax(0, 1fr) 36px; align-items: start; gap: 10px; }.option-letter { display: flex; flex-direction: column; align-items: center; gap: 6px; padding-top: 12px; }.option-letter .answer-control { margin-right: 0; }.letter-tag { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: 50%; background: #eff6ff; color: #2563eb; font-size: 13px; font-weight: 600; border: 1px solid #bfdbfe; }.remove-btn { padding-top: 16px; color: #9ca3af; }.add-option-btn { justify-self: start; margin-top: 4px; }.tf-block { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 12px; margin: 18px 0 30px; align-items: center; }.tf-options { display: flex; gap: 16px; }.tf-radio-group { display: flex; gap: 16px; }.tf-radio { padding: 10px 36px; font-size: 15px; border-radius: 8px; }.material-row { display: flex; align-items: center; gap: 14px; margin: 6px 0 22px 132px; }.material-name { color: #6b7280; }.preview-alert { margin-bottom: 12px; }.preview-item { padding: 10px 4px; border-bottom: 1px solid #e5e7eb; line-height: 1.7; color: #1f2937; }.editor-shell :deep(.ql-container) { min-height: 88px; font-size: 15px; color: #1f2937; }.editor-shell :deep(.ql-editor) { min-height: 74px; color: #1f2937; }.editor-shell :deep(.ql-toolbar) { background: #fff; border-color: #e5e7eb; }.editor-shell :deep(.ql-editor.ql-blank::before) { color: #9ca3af; font-style: normal; }
</style>

@ -1,13 +1,6 @@
<template>
<div v-loading="loading" class="student-home app-container">
<el-alert
v-if="loadError"
class="load-error"
type="error"
:title="loadError"
:closable="false"
show-icon
/>
<el-alert v-if="loadError" class="load-error" type="error" :title="loadError" :closable="false" show-icon />
<el-card class="dashboard-card score-card">
<template #header>
@ -26,11 +19,6 @@
<strong>{{ formatScore(aiScore) }}</strong>
<span class="summary-unit">分</span>
</div>
<div class="score-metric score-metric--teacher">
<span class="summary-label">老师评分</span>
<strong>{{ formatTeacherScore(teacherScore) }}</strong>
<span v-if="hasTeacherScore(teacherScore)" class="summary-unit">分</span>
</div>
</div>
</section>
@ -83,62 +71,62 @@
</template>
<script setup>
import * as echarts from "echarts";
import useUserStore from "@/store/modules/user";
import * as indexApi from "@/api/index";
import { getCurrentProjectStudyTime } from "@/api/projectStudyTime";
const userStore = useUserStore();
const scoreChartRef = ref(null);
const studyTimeChartRef = ref(null);
const progressChartRef = ref(null);
const report = ref({ totalScore: 0, modules: [] });
const projectStudyTimes = ref([]);
const loading = ref(true);
const loadError = ref("");
const MODULE_COLORS = ["#1d4ed8", "#2563eb", "#3b82f6", "#60a5fa", "#93c5fd", "#bfdbfe"];
let scoreChart;
let studyTimeChart;
let progressChart;
const displayModules = computed(() => report.value.modules || []);
const aiScore = computed(() => report.value.aiAssessmentScore ?? report.value.aiScore ?? report.value.totalScore);
const teacherScore = computed(() => report.value.teacherScore);
import * as echarts from "echarts"
import useUserStore from "@/store/modules/user"
import * as indexApi from "@/api/index"
import { getCurrentProjectStudyTime } from "@/api/projectStudyTime"
const userStore = useUserStore()
const scoreChartRef = ref(null)
const studyTimeChartRef = ref(null)
const progressChartRef = ref(null)
const report = ref({ totalScore: 0, modules: [] })
const projectStudyTimes = ref([])
const loading = ref(true)
const loadError = ref("")
const MODULE_COLORS = ["#1d4ed8", "#2563eb", "#3b82f6", "#60a5fa", "#93c5fd", "#bfdbfe"]
let scoreChart
let studyTimeChart
let progressChart
const displayModules = computed(() => report.value.modules || [])
const aiScore = computed(() => report.value.aiAssessmentScore ?? report.value.aiScore ?? report.value.totalScore)
const teacherScore = computed(() => report.value.teacherScore)
const studySecondsByProject = computed(() => Object.fromEntries(
projectStudyTimes.value.map((item) => [item.projectName, Number(item.studySeconds) || 0])
));
))
const displayStudyProjects = computed(() => {
const knownProjects = displayModules.value.map((module) => module.moduleName);
const extraProjects = projectStudyTimes.value.map((item) => item.projectName).filter((name) => !knownProjects.includes(name));
const knownProjects = displayModules.value.map((module) => module.moduleName)
const extraProjects = projectStudyTimes.value.map((item) => item.projectName).filter((name) => !knownProjects.includes(name))
return [...knownProjects, ...extraProjects].map((projectName) => ({
projectName,
studySeconds: studySecondsByProject.value[projectName] || 0,
}));
});
}))
})
const totalStudyMinutes = computed(() => Math.round(
displayStudyProjects.value.reduce((total, item) => total + item.studySeconds, 0) / 60
));
))
const studyHours = computed(() => (totalStudyMinutes.value / 60).toFixed(1));
const studyHours = computed(() => (totalStudyMinutes.value / 60).toFixed(1))
const formatScore = (value) => Number(value || 0).toFixed(2).replace(/\.00$/, "");
const hasTeacherScore = (value) => value !== null && value !== undefined && value !== "";
const formatTeacherScore = (value) => hasTeacherScore(value) ? formatScore(value) : "待评阅";
const formatWeight = (weight) => `${(Number(weight || 0) * 100).toFixed(0)}%`;
const moduleColor = (index) => MODULE_COLORS[index % MODULE_COLORS.length];
const formatScore = (value) => Number(value || 0).toFixed(2).replace(/\.00$/, "")
const hasTeacherScore = (value) => value !== null && value !== undefined && value !== ""
const formatTeacherScore = (value) => hasTeacherScore(value) ? formatScore(value) : "待评阅"
const formatWeight = (weight) => `${(Number(weight || 0) * 100).toFixed(0)}%`
const moduleColor = (index) => MODULE_COLORS[index % MODULE_COLORS.length]
const progressPercent = (module) => {
const total = Number(module.totalTaskCount) || 0;
const completed = Number(module.completedTaskCount) || 0;
return total > 0 ? Math.min(100, Math.round((completed / total) * 100)) : 0;
};
const progressText = (module) => `${Number(module.completedTaskCount) || 0}/${Number(module.totalTaskCount) || 0}`;
const total = Number(module.totalTaskCount) || 0
const completed = Number(module.completedTaskCount) || 0
return total > 0 ? Math.min(100, Math.round((completed / total) * 100)) : 0
}
const progressText = (module) => `${Number(module.completedTaskCount) || 0}/${Number(module.totalTaskCount) || 0}`
const renderScoreChart = () => {
if (!scoreChartRef.value) return;
scoreChart = scoreChart || echarts.init(scoreChartRef.value);
if (!scoreChartRef.value) return
scoreChart = scoreChart || echarts.init(scoreChartRef.value)
scoreChart.setOption({
tooltip: { trigger: "item", valueFormatter: (value) => `${formatScore(value)} 分` },
legend: { orient: "vertical", right: 8, top: "middle", itemWidth: 12, itemHeight: 12 },
@ -156,13 +144,13 @@ const renderScoreChart = () => {
itemStyle: { color: moduleColor(index) }
}))
}]
}, true);
};
}, true)
}
const renderProgressChart = () => {
if (!progressChartRef.value) return;
progressChart = progressChart || echarts.init(progressChartRef.value);
const modules = displayModules.value;
if (!progressChartRef.value) return
progressChart = progressChart || echarts.init(progressChartRef.value)
const modules = displayModules.value
progressChart.setOption({
tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
legend: { top: 0, data: ["已完成", "待完成"] },
@ -192,13 +180,13 @@ const renderProgressChart = () => {
data: modules.map((module) => Math.max(0, (Number(module.totalTaskCount) || 0) - (Number(module.completedTaskCount) || 0)))
}
]
}, true);
};
}, true)
}
const renderStudyTimeChart = () => {
if (!studyTimeChartRef.value) return;
studyTimeChart = studyTimeChart || echarts.init(studyTimeChartRef.value);
const projects = displayStudyProjects.value;
if (!studyTimeChartRef.value) return
studyTimeChart = studyTimeChart || echarts.init(studyTimeChartRef.value)
const projects = displayStudyProjects.value
studyTimeChart.setOption({
tooltip: {
trigger: "axis",
@ -220,55 +208,55 @@ const renderStudyTimeChart = () => {
itemStyle: { color: "#3b82f6", borderRadius: [0, 9, 9, 0] },
data: projects.map((item) => Number((item.studySeconds / 60).toFixed(1))),
}],
}, true);
};
}, true)
}
const renderCharts = () => {
renderScoreChart();
renderStudyTimeChart();
renderProgressChart();
};
renderScoreChart()
renderStudyTimeChart()
renderProgressChart()
}
const resizeCharts = () => {
scoreChart?.resize();
studyTimeChart?.resize();
progressChart?.resize();
};
scoreChart?.resize()
studyTimeChart?.resize()
progressChart?.resize()
}
const loadDashboard = async () => {
loading.value = true;
loadError.value = "";
loading.value = true
loadError.value = ""
const [reportResult, projectStudyTimeResult] = await Promise.allSettled([
indexApi.getCurrentExperimentReport(),
getCurrentProjectStudyTime(),
]);
])
if (reportResult.status !== "fulfilled") {
loadError.value = "新版实训报告加载失败,请刷新页面后重试。";
loading.value = false;
return;
loadError.value = "新版实训报告加载失败,请刷新页面后重试。"
loading.value = false
return
}
report.value = reportResult.value?.data || { totalScore: 0, modules: [] };
report.value = reportResult.value?.data || { totalScore: 0, modules: [] }
projectStudyTimes.value = projectStudyTimeResult.status === "fulfilled" && Array.isArray(projectStudyTimeResult.value?.data)
? projectStudyTimeResult.value.data
: [];
loading.value = false;
await nextTick();
renderCharts();
};
: []
loading.value = false
await nextTick()
renderCharts()
}
onMounted(() => {
window.addEventListener("resize", resizeCharts);
loadDashboard();
});
window.addEventListener("resize", resizeCharts)
loadDashboard()
})
onBeforeUnmount(() => {
window.removeEventListener("resize", resizeCharts);
scoreChart?.dispose();
studyTimeChart?.dispose();
progressChart?.dispose();
});
window.removeEventListener("resize", resizeCharts)
scoreChart?.dispose()
studyTimeChart?.dispose()
progressChart?.dispose()
})
</script>
<style lang="scss" scoped>
@ -278,14 +266,19 @@ onBeforeUnmount(() => {
padding: 0;
}
.load-error { margin-bottom: 12px; }
.load-error {
margin-bottom: 12px;
}
.dashboard-card {
margin-bottom: 14px;
border: 1px solid #e5e7eb;
box-shadow: 0 2px 10px rgba(17, 24, 39, 0.05);
:deep(.el-card__header) { padding: 13px 16px; border-bottom: 1px solid #e5e7eb; }
:deep(.el-card__header) {
padding: 13px 16px;
border-bottom: 1px solid #e5e7eb;
}
}
.card-header {
@ -294,11 +287,23 @@ onBeforeUnmount(() => {
justify-content: space-between;
gap: 12px;
span { color: #1f2937; font-size: 15px; font-weight: 700; }
small { color: #9ca3af; font-size: 12px; }
span {
color: #1f2937;
font-size: 15px;
font-weight: 700;
}
small {
color: #9ca3af;
font-size: 12px;
}
}
.score-layout { display: grid; grid-template-columns: minmax(520px, 0.95fr) minmax(760px, 1.35fr); min-height: 330px; }
.score-layout {
display: grid;
grid-template-columns: minmax(520px, 0.95fr) minmax(760px, 1.35fr);
min-height: 330px;
}
.score-visual {
position: relative;
@ -307,30 +312,69 @@ onBeforeUnmount(() => {
border-right: 1px dashed #e5e7eb;
}
.score-chart { width: 100%; min-height: 330px; }
.score-chart {
width: 100%;
min-height: 330px;
}
.score-summary {
position: absolute;
left: 28px;
top: 26px;
left: 34%;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
gap: 18px;
flex-wrap: nowrap;
white-space: nowrap;
color: #72809a;
pointer-events: none;
strong {
font-size: 26px;
line-height: 1;
}
.summary-unit {
font-weight: 600;
}
}
.score-metric {
display: inline-flex;
align-items: baseline;
gap: 4px;
}
.score-metric--ai {
color: #1d4ed8;
}
.score-metric--teacher {
color: #6b7280;
}
.summary-label {
font-size: 12px;
font-weight: 600;
}
.score-table-wrap {
display: flex;
align-items: center;
min-width: 0;
overflow-x: auto;
padding: 8px 0 8px 24px;
}
strong { font-size: 30px; line-height: 1; }
.summary-unit { font-weight: 600; }
.score-table {
width: 100%;
min-width: 760px;
}
.score-metric { display: inline-flex; align-items: baseline; gap: 4px; }
.score-metric--ai { color: #1d4ed8; }
.score-metric--teacher { color: #6b7280; }
.summary-label { font-size: 12px; font-weight: 600; }
.score-table-wrap { display: flex; align-items: center; min-width: 0; overflow-x: auto; padding: 8px 0 8px 24px; }
.score-table { width: 100%; min-width: 760px; }
.score-table :deep(.el-table__cell) { white-space: nowrap; }
.score-table :deep(.el-table__cell) {
white-space: nowrap;
}
/* 成绩表:纯白底 + 中性灰边框,无蓝色/斑马重色 */
.score-table :deep(th.el-table__cell) {
@ -355,7 +399,12 @@ onBeforeUnmount(() => {
background: #f3f4f6;
}
.progress-layout { display: grid; grid-template-columns: 190px minmax(370px, 1fr) minmax(370px, 1fr); gap: 24px; align-items: stretch; }
.progress-layout {
display: grid;
grid-template-columns: 190px minmax(370px, 1fr) minmax(370px, 1fr);
gap: 24px;
align-items: stretch;
}
.learning-time {
display: flex;
@ -368,8 +417,17 @@ onBeforeUnmount(() => {
border-radius: 14px;
background: #fafbfc;
strong { margin-top: 16px; font-size: 14px; font-weight: 800; }
span { margin-top: 6px; font-size: 12px; color: #6b7280; }
strong {
margin-top: 16px;
font-size: 14px;
font-weight: 800;
}
span {
margin-top: 6px;
font-size: 12px;
color: #6b7280;
}
}
.time-orbit {
@ -383,10 +441,29 @@ onBeforeUnmount(() => {
box-shadow: inset 0 0 0 7px #eff6ff;
}
.time-value { font-size: 25px; font-weight: 800; line-height: 1; }
.time-unit { margin-top: 5px; font-size: 12px; letter-spacing: 0.08em; color: #6b7280; }
.study-time-chart-wrap, .progress-chart-wrap { min-width: 0; }
.study-time-chart, .progress-chart { min-height: 270px; width: 100%; }
.time-value {
font-size: 25px;
font-weight: 800;
line-height: 1;
}
.time-unit {
margin-top: 5px;
font-size: 12px;
letter-spacing: 0.08em;
color: #6b7280;
}
.study-time-chart-wrap,
.progress-chart-wrap {
min-width: 0;
}
.study-time-chart,
.progress-chart {
min-height: 270px;
width: 100%;
}
.module-progress-list {
display: flex;
@ -397,28 +474,91 @@ onBeforeUnmount(() => {
padding: 8px 4px;
}
.module-progress-item { min-width: 0; }
.module-progress-head { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; color: #6f7b91; font-size: 13px; }
.module-progress-head strong { flex: 1; overflow: hidden; color: #273858; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
.module-color { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; }
.module-progress-item {
min-width: 0;
}
.module-progress-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 7px;
color: #6f7b91;
font-size: 13px;
}
.module-progress-head strong {
flex: 1;
overflow: hidden;
color: #273858;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.module-color {
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: 50%;
}
@media (max-width: 1320px) {
.score-layout { grid-template-columns: 1fr; }
.score-visual { border-bottom: 1px dashed #e5e7eb; }
.score-table-wrap { padding: 20px 0 0; }
.progress-layout { grid-template-columns: 180px 1fr; }
.progress-chart-wrap { grid-column: 1 / -1; }
.score-layout {
grid-template-columns: 1fr;
}
.score-visual {
border-bottom: 1px dashed #e5e7eb;
}
.score-table-wrap {
padding: 20px 0 0;
}
.progress-layout {
grid-template-columns: 180px 1fr;
}
.progress-chart-wrap {
grid-column: 1 / -1;
}
}
@media (max-width: 760px) {
.student-home { padding: 12px; }
.card-header { align-items: flex-start; flex-direction: column; }
.score-visual { min-height: 290px; }
.score-chart { min-height: 290px; }
.score-summary { left: 18px; top: 18px; }
.progress-layout { grid-template-columns: 1fr; }
.learning-time { min-height: 210px; }
.progress-chart-wrap { grid-column: auto; }
.student-home {
padding: 12px;
}
.card-header {
align-items: flex-start;
flex-direction: column;
}
.score-visual {
min-height: 290px;
}
.score-chart {
min-height: 290px;
}
.score-summary {
left: 18px;
top: 18px;
}
.progress-layout {
grid-template-columns: 1fr;
}
.learning-time {
min-height: 210px;
}
.progress-chart-wrap {
grid-column: auto;
}
}
</style>

Loading…
Cancel
Save