fix: 优化学生实训操作与成绩展示

bugfix/ai-assessment-evidence-rescore
陈沅 1 month ago
parent ec9e1822f5
commit e0d64a76b7

@ -1883,7 +1883,7 @@ export function checkBusinessFeasibilityStepThree(payload) {
if (!reportUrl || !supported.test(reportUrl) && !supported.test(reportName)) return Promise.reject(new Error("请上传 PDF、Word、PNG 或 JPG 格式的可行性论证报告")); if (!reportUrl || !supported.test(reportUrl) && !supported.test(reportName)) return Promise.reject(new Error("请上传 PDF、Word、PNG 或 JPG 格式的可行性论证报告"));
return Promise.resolve({ code: 200, data: { valid: true, message: "校验通过" } }); return Promise.resolve({ code: 200, data: { valid: true, message: "校验通过" } });
} }
return request({ url: "/api/student-training-answers/business-feasibility/step-3/validate", method: "post", data: { reportUrl, reportName }, headers: { repeatSubmit: false } }); return request({ url: "/api/student-training-answers/business-feasibility/step-3/validate", method: "post", data: { reportUrl, reportName }, headers: { repeatSubmit: false }, skipErrorMessage: true });
} }
export function checkCompetitiveEnvironmentAnalysisStepOne(payload) { export function checkCompetitiveEnvironmentAnalysisStepOne(payload) {

@ -93,8 +93,10 @@ service.interceptors.response.use(
ElMessage({ message: msg, type: "warning" }); ElMessage({ message: msg, type: "warning" });
return Promise.reject(new Error(msg)); return Promise.reject(new Error(msg));
} else if (code !== 200) { } else if (code !== 200) {
ElNotification.error({ title: msg }); if (!res.config?.skipErrorMessage) {
return Promise.reject("error"); ElNotification.error({ title: msg });
}
return Promise.reject(new Error(msg));
} else { } else {
return Promise.resolve(res.data); return Promise.resolve(res.data);
} }

@ -55,6 +55,9 @@
</p> </p>
</template> </template>
</TrainingIntro> </TrainingIntro>
<div class="legacy-training-toolbar">
<TrainingTaskToolbar task-key="product-diagnosis-iteration" />
</div>
<div class="training-step-area"> <div class="training-step-area">
<p>功效系数法:绩效评估的标准化工具‌,将各指标数据通过标准化处理消除量纲差异,转化为“功效值”后加权计算综合得分,适用于多维度绩效评估‌,可用于分析电商产品表现。</p> <p>功效系数法:绩效评估的标准化工具‌,将各指标数据通过标准化处理消除量纲差异,转化为“功效值”后加权计算综合得分,适用于多维度绩效评估‌,可用于分析电商产品表现。</p>
@ -198,6 +201,7 @@
import TrainingLegacyActions from "@/views/components/TrainingLegacyActions.vue"; import TrainingLegacyActions from "@/views/components/TrainingLegacyActions.vue";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"; import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue";
import TrainingIntro from '@/views/components/TrainingIntro.vue'; import TrainingIntro from '@/views/components/TrainingIntro.vue';
import useUserStore from "@/store/modules/user"; import useUserStore from "@/store/modules/user";
const userStore = useUserStore(); const userStore = useUserStore();
@ -433,4 +437,3 @@ onMounted(() => {

@ -36,8 +36,7 @@
<span>实训要求</span> <span>实训要求</span>
</button> </button>
</div> </div>
<TrainingMaterialButton <TrainingTaskToolbar
v-if="showMaterial"
class="brief-material" class="brief-material"
:material-name="materialName" :material-name="materialName"
:material-url="materialUrl" :material-url="materialUrl"
@ -63,7 +62,7 @@
<script setup> <script setup>
import { Aim, Clock, DataAnalysis } from "@element-plus/icons-vue"; import { Aim, Clock, DataAnalysis } from "@element-plus/icons-vue";
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue"; import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue";
const activeTab = ref("background"); const activeTab = ref("background");
@ -93,7 +92,7 @@ const props = defineProps({
default: true, default: true,
}, },
}); });
const { showMaterial, materialName, materialUrl } = toRefs(props); const { materialName, materialUrl } = toRefs(props);
const displayBackground = computed(() => props.background || "请根据教师端配置的实训背景理解本次任务场景。"); const displayBackground = computed(() => props.background || "请根据教师端配置的实训背景理解本次任务场景。");
const displayGoals = computed(() => normalizeGoals(props.goals)); const displayGoals = computed(() => normalizeGoals(props.goals));

@ -0,0 +1,93 @@
<template>
<div class="training-task-toolbar">
<TrainingMaterialButton :material-name="resolvedMaterial.name" :material-url="resolvedMaterial.url" />
<el-button class="task-toolbar-button task-toolbar-button--submit" @click="triggerExistingAction('submit')">
<el-icon><CircleCheck /></el-icon>提交任务
</el-button>
<el-button class="task-toolbar-button" @click="triggerExistingAction('reset')">
<el-icon><RefreshLeft /></el-icon>重新实训
</el-button>
<el-button class="task-toolbar-button" @click="triggerExistingAction('export')">
<el-icon><Download /></el-icon>导出实训成果
</el-button>
</div>
</template>
<script setup>
import { computed, onMounted, ref, watch } from "vue";
import { CircleCheck, Download, RefreshLeft } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { getTrainingTaskByKey } from "@/api/trainingTask";
import { resolveTrainingTaskKey } from "@/views/training/taskKeyMap";
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
const props = defineProps({
materialName: { type: String, default: "" },
materialUrl: { type: String, default: "" },
taskKey: { type: String, default: "" },
});
const route = useRoute();
const loadedMaterial = ref({ name: "", url: "" });
const resolvedTaskKey = computed(() => resolveTrainingTaskKey(route, props.taskKey));
const resolvedMaterial = computed(() => ({
name: String(props.materialName || loadedMaterial.value.name || ""),
url: String(props.materialUrl || loadedMaterial.value.url || ""),
}));
onMounted(loadMaterial);
watch(resolvedTaskKey, loadMaterial);
async function loadMaterial() {
if (props.materialName || props.materialUrl || !resolvedTaskKey.value) return;
try {
const response = await getTrainingTaskByKey(resolvedTaskKey.value);
const task = response?.data || response || {};
loadedMaterial.value = {
name: String(task.materialName || ""),
url: String(task.materialUrl || ""),
};
} catch {
// The material button keeps its unavailable state when a task has no configured case file.
}
}
function triggerExistingAction(action) {
const labelPriority = {
submit: [[/提交任务/], [/完成实训/, /保存并完成/, /校验并完成/]],
reset: [[/重新实训/], [/清空填写/]],
export: [[/导出实训成果/]],
};
const buttons = Array.from(document.querySelectorAll("button"))
.filter((button) => !button.closest(".training-task-toolbar") && isVisible(button) && !button.disabled);
const target = labelPriority[action]
.map((matchers) => buttons.find((button) => matchers.some((matcher) => matcher.test(button.innerText || ""))))
.find(Boolean);
if (target) {
target.click();
return;
}
const messages = {
submit: "请先完成全部实训步骤,再提交任务。",
reset: "当前步骤没有可重新实训的内容。",
export: "当前任务暂无可导出的实训成果。",
};
ElMessage.warning(messages[action]);
}
function isVisible(element) {
const style = window.getComputedStyle(element);
return style.display !== "none" && style.visibility !== "hidden" && element.getClientRects().length > 0;
}
</script>
<style lang="scss" scoped>
.training-task-toolbar { display:flex; flex-wrap:wrap; align-items:center; justify-content:flex-end; gap:8px; }
.task-toolbar-button { min-height:32px; padding:0 10px; border-color:rgba(99,217,255,.55); border-radius:7px; color:#e9fbff; background:rgba(17,126,178,.36); font-size:13px; font-weight:800; }
.task-toolbar-button:hover { border-color:rgba(116,235,255,.85); color:#e9fbff; background:rgba(18,146,204,.5); }
.task-toolbar-button--submit { border-color:rgba(66,224,187,.7); background:rgba(13,137,111,.34); }
.task-toolbar-button--submit:hover { border-color:rgba(100,245,208,.9); background:rgba(14,164,132,.45); }
@media (max-width:768px) { .training-task-toolbar { justify-content:stretch; } .training-task-toolbar > * { flex:1 1 auto; } }
</style>

@ -174,7 +174,7 @@ async function saveStepTwo() { if (saving.value) return; saving.value = true; tr
function resetStepTwo() { stepTwo.value = createStepTwo(); stepTwoCompleted.value = false; proxy?.$modal?.msgSuccess("已清空第 2 步填写内容"); } function resetStepTwo() { stepTwo.value = createStepTwo(); stepTwoCompleted.value = false; proxy?.$modal?.msgSuccess("已清空第 2 步填写内容"); }
function handleDecisionChange() { if (stepTwo.value.decision === "not-feasible") stepTwo.value.priorities = []; } function handleDecisionChange() { if (stepTwo.value.decision === "not-feasible") stepTwo.value.priorities = []; }
function openReportPicker() { reportInput.value?.click(); } function openReportPicker() { reportInput.value?.click(); }
async function handleReportUpload(event) { const file = event?.target?.files?.[0]; if (event?.target) event.target.value = ""; if (!file) return; const extension = file.name.split(".").pop()?.toLowerCase(); if (!["pdf", "doc", "docx", "png", "jpg", "jpeg"].includes(extension)) { proxy?.$modal?.msgError("仅支持 PDF、Word、PNG、JPG 格式的报告附件"); return; } if (file.size > 20 * 1024 * 1024) { proxy?.$modal?.msgError("报告附件不能超过 20MB"); return; } uploading.value = true; try { const data = new FormData(); data.append("file", file); const response = await uploadStudentTrainingFile(data); const url = typeof response?.data === "string" ? response.data : response?.data?.url; if (!url) throw new Error("上传结果缺少文件地址"); report.value = { url, name: file.name, size: file.size }; proxy?.$modal?.msgSuccess("报告附件上传成功,可点击预览核对"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "报告附件上传失败,请重试"); } finally { uploading.value = false; } } async function handleReportUpload(event) { const file = event?.target?.files?.[0]; if (event?.target) event.target.value = ""; if (!file) return; const extension = file.name.split(".").pop()?.toLowerCase(); if (!["pdf", "doc", "docx", "png", "jpg", "jpeg"].includes(extension)) { proxy?.$modal?.msgError("仅支持 PDF、Word、PNG、JPG 格式的报告附件"); return; } if (file.size > 20 * 1024 * 1024) { proxy?.$modal?.msgError("报告附件不能超过 20MB"); return; } uploading.value = true; try { const data = new FormData(); data.append("file", file); const response = await uploadStudentTrainingFile(data); const url = response?.url || response?.data?.url || ""; if (!url) throw new Error("上传未返回文件地址,请重新选择报告附件"); report.value = { url, name: file.name, size: file.size }; proxy?.$modal?.msgSuccess("报告附件上传成功,可点击预览核对"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "报告附件上传失败,请重试"); } finally { uploading.value = false; } }
function resolveReportKind(file) { const name = `${file?.name || ""} ${file?.url || ""}`.toLowerCase(); if (/\.(png|jpe?g)(?:\?|$)/.test(name)) return "image"; if (/\.pdf(?:\?|$)/.test(name)) return "pdf"; return "word"; } function resolveReportKind(file) { const name = `${file?.name || ""} ${file?.url || ""}`.toLowerCase(); if (/\.(png|jpe?g)(?:\?|$)/.test(name)) return "image"; if (/\.pdf(?:\?|$)/.test(name)) return "pdf"; return "word"; }
function readableSize(size) { const value = Number(size || 0); return value > 1024 * 1024 ? `${(value / 1024 / 1024).toFixed(1)} MB` : value > 1024 ? `${(value / 1024).toFixed(1)} KB` : "附件已上传"; } function readableSize(size) { const value = Number(size || 0); return value > 1024 * 1024 ? `${(value / 1024 / 1024).toFixed(1)} MB` : value > 1024 ? `${(value / 1024).toFixed(1)} KB` : "附件已上传"; }
function openReportPreview() { if (report.value.url) reportPreviewVisible.value = true; } function openReportPreview() { if (report.value.url) reportPreviewVisible.value = true; }

@ -31,7 +31,7 @@
<section class="score-table-wrap" aria-label="模块成绩明细"> <section class="score-table-wrap" aria-label="模块成绩明细">
<el-table :data="displayModules" stripe border class="score-table" empty-text="暂无实训模块"> <el-table :data="displayModules" stripe border class="score-table" empty-text="暂无实训模块">
<el-table-column type="index" label="序号" width="64" align="center" /> <el-table-column type="index" label="序号" width="64" align="center" />
<el-table-column prop="moduleName" label="实训模块" min-width="210" /> <el-table-column prop="moduleName" label="实训模块" min-width="280" />
<el-table-column label="成绩占比" width="116" align="center"> <el-table-column label="成绩占比" width="116" align="center">
<template #default="scope">{{ formatWeight(scope.row.weight) }}</template> <template #default="scope">{{ formatWeight(scope.row.weight) }}</template>
</el-table-column> </el-table-column>
@ -136,7 +136,8 @@ const renderScoreChart = () => {
radius: ["42%", "72%"], radius: ["42%", "72%"],
center: ["34%", "50%"], center: ["34%", "50%"],
avoidLabelOverlap: true, avoidLabelOverlap: true,
label: { formatter: "{b}" }, label: { show: false },
labelLine: { show: false },
data: displayModules.value.map((module, index) => ({ data: displayModules.value.map((module, index) => ({
name: module.moduleName, name: module.moduleName,
value: Number(module.score) || 0, value: Number(module.score) || 0,
@ -252,7 +253,7 @@ onBeforeUnmount(() => {
small { color: #8a98af; font-size: 13px; } small { color: #8a98af; font-size: 13px; }
} }
.score-layout { display: grid; grid-template-columns: minmax(400px, 0.9fr) minmax(540px, 1.3fr); min-height: 330px; } .score-layout { display: grid; grid-template-columns: minmax(520px, 0.95fr) minmax(760px, 1.35fr); min-height: 330px; }
.score-visual { .score-visual {
position: relative; position: relative;
@ -277,8 +278,9 @@ onBeforeUnmount(() => {
} }
.summary-label { margin-bottom: 4px; font-size: 13px; } .summary-label { margin-bottom: 4px; font-size: 13px; }
.score-table-wrap { display: flex; align-items: center; min-width: 0; padding: 8px 0 8px 24px; } .score-table-wrap { display: flex; align-items: center; min-width: 0; overflow-x: auto; padding: 8px 0 8px 24px; }
.score-table { width: 100%; } .score-table { width: 100%; min-width: 760px; }
.score-table :deep(.el-table__cell) { white-space: nowrap; }
.progress-layout { display: grid; grid-template-columns: 190px minmax(370px, 1fr) minmax(320px, 0.9fr); gap: 24px; align-items: stretch; } .progress-layout { display: grid; grid-template-columns: 190px minmax(370px, 1fr) minmax(320px, 0.9fr); gap: 24px; align-items: stretch; }

@ -0,0 +1,35 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const source = fs.readFileSync(
path.resolve(__dirname, "../src/views/demand/business-feasibility.vue"),
"utf8",
);
const api = fs.readFileSync(
path.resolve(__dirname, "../src/api/studentTrainingAnswer.js"),
"utf8",
);
const request = fs.readFileSync(
path.resolve(__dirname, "../src/utils/request.js"),
"utf8",
);
test("商业可行性报告上传兼容公共上传接口的顶层文件地址", () => {
assert.match(
source,
/const url = response\?\.url \|\| response\?\.data\?\.url \|\| ""/,
);
});
test("商业可行性报告校验失败仅交由页面显示中文提示", () => {
assert.match(
api,
/business-feasibility\/step-3\/validate[\s\S]{0,240}skipErrorMessage:\s*true/,
);
assert.match(
request,
/code !== 200[\s\S]{0,260}skipErrorMessage[\s\S]{0,260}Promise\.reject\(new Error\(msg\)\)/,
);
});

@ -0,0 +1,17 @@
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const assert = require("node:assert/strict");
const source = fs.readFileSync(path.resolve(__dirname, "../src/views/student/index.vue"), "utf8");
test("首页成绩环图不再同时展示会与图例重叠的外部标签", () => {
assert.match(source, /label:\s*\{\s*show:\s*false\s*\}/);
assert.match(source, /legend:\s*\{\s*orient:\s*"vertical"/);
});
test("首页成绩表的数值保持单行,并在窄空间内横向滚动", () => {
assert.match(source, /\.score-table-wrap[^}]*overflow-x:\s*auto/);
assert.match(source, /\.score-table[^}]*min-width:/);
assert.match(source, /\.score-table\s*:deep\([^)]*\)\s*\{[^}]*white-space:\s*nowrap/);
});

@ -0,0 +1,57 @@
const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const assert = require("node:assert/strict");
const frontendRoot = path.resolve(__dirname, "..");
const toolbarPath = path.join(frontendRoot, "src/views/components/TrainingTaskToolbar.vue");
const studentTrainingPages = [
"foundation/new-product-survey.vue",
"foundation/product-development-factors.vue",
"foundation/product-development-process.vue",
"demand/industry-demand-analysis.vue",
"demand/competitive-environment-analysis.vue",
"demand/business-feasibility.vue",
"demand/people.vue",
"demand/consumer-behavior.vue",
"demand/consumer-scenario.vue",
"demand/feature-conversion.vue",
"product/market-opportunity-selection.vue",
"product/differentiation.vue",
"product/structure.vue",
"product/after-sales-service.vue",
"product/requirements-doc.vue",
"product/hardware-cost.vue",
"product/price.vue",
"test/index.vue",
"test/promotion.vue",
"channel/index.vue",
"channel/supplier.vue",
"assessment/index.vue",
"assessment/diagnosis.vue",
"assessment/optimization.vue",
];
test("统一实训工具栏同时提供案例下载、提交、重新实训和导出入口", () => {
const source = fs.readFileSync(toolbarPath, "utf8");
assert.match(source, /TrainingMaterialButton/);
assert.match(source, /提交任务/);
assert.match(source, /重新实训/);
assert.match(source, /导出实训成果/);
});
test("每个学生端实训菜单均通过任务说明区接入统一顶部工具栏", () => {
for (const relativePath of studentTrainingPages) {
const source = fs.readFileSync(path.join(frontendRoot, "src/views", relativePath), "utf8");
if (relativePath === "assessment/index.vue") {
assert.match(source, /<TrainingTaskToolbar\b/, relativePath);
} else {
assert.match(source, /<TrainingTaskBrief\b/, relativePath);
}
}
const briefSource = fs.readFileSync(path.join(frontendRoot, "src/views/components/TrainingTaskBrief.vue"), "utf8");
assert.match(briefSource, /<TrainingTaskToolbar\b/);
});
Loading…
Cancel
Save