修复学生实训提交与步骤操作

bugfix/ai-assessment-evidence-rescore
陈沅 1 month ago
parent 4ea4f28402
commit 51f89898c6

@ -17,6 +17,14 @@ import cache from "@/plugins/cache";
import { removeUserInfo, removeToken } from "@/utils/auth.js"; import { removeUserInfo, removeToken } from "@/utils/auth.js";
const stepNameSelector = ".step-tab .step-copy > strong, .step-tab .step-copy > b, .step-tab .step-copy > .step-name"; const stepNameSelector = ".step-tab .step-copy > strong, .step-tab .step-copy > b, .step-tab .step-copy > .step-name";
const stepActionSelector = [
".student-training-shell .step-actions > button",
".student-training-shell .step-navigation > button",
".student-training-shell .footer-actions > button",
".student-training-shell .training-actions > button",
".student-training-shell .task-actions > button",
".student-training-shell .taskfooter > button",
].join(",");
let stepNameObserver; let stepNameObserver;
let stepTitleRefreshQueued = false; let stepTitleRefreshQueued = false;
@ -27,11 +35,24 @@ function addStepNameTitles() {
}); });
} }
function normalizeStepActionStyles() {
document.querySelectorAll(stepActionSelector).forEach((button) => {
const label = String(button.textContent || "").replace(/\s+/g, " ").trim();
const isPrevious = label.includes("上一步");
const isPrimary = label.includes("保存当前进度") || label.includes("下一步");
if (!isPrevious && !isPrimary) return;
button.classList.add("student-step-action");
button.classList.toggle("student-step-action--primary", isPrimary);
button.classList.toggle("student-step-action--secondary", isPrevious);
});
}
function queueStepNameTitleRefresh() { function queueStepNameTitleRefresh() {
if (stepTitleRefreshQueued) return; if (stepTitleRefreshQueued) return;
stepTitleRefreshQueued = true; stepTitleRefreshQueued = true;
requestAnimationFrame(() => { requestAnimationFrame(() => {
addStepNameTitles(); addStepNameTitles();
normalizeStepActionStyles();
stepTitleRefreshQueued = false; stepTitleRefreshQueued = false;
}); });
} }

@ -866,3 +866,55 @@
width: 100% !important; width: 100% !important;
} }
} }
/*
* 页面历史上使用了多套按钮类名。由 App.vue 为步骤操作标记统一语义类,
* 这里作为最终覆盖层,保证所有学生端底部导航的尺寸、圆角和主次层级一致。
*/
.student-training-shell .step-actions > button.student-step-action,
.student-training-shell .step-navigation > button.student-step-action,
.student-training-shell .footer-actions > button.student-step-action,
.student-training-shell .training-actions > button.student-step-action,
.student-training-shell .task-actions > button.student-step-action,
.student-training-shell .taskfooter > button.student-step-action {
box-sizing: border-box !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
min-width: 132px !important;
height: 42px !important;
min-height: 42px !important;
margin: 0 !important;
padding: 0 18px !important;
border: 1px solid #2cc9ff !important;
border-radius: 999px !important;
color: #dff8ff !important;
background: rgba(8, 87, 121, 0.55) !important;
box-shadow: none !important;
font-size: 15px !important;
font-weight: 800 !important;
line-height: 1 !important;
letter-spacing: 0 !important;
}
.student-training-shell .step-actions > button.student-step-action--primary,
.student-training-shell .step-navigation > button.student-step-action--primary,
.student-training-shell .footer-actions > button.student-step-action--primary,
.student-training-shell .training-actions > button.student-step-action--primary,
.student-training-shell .task-actions > button.student-step-action--primary,
.student-training-shell .taskfooter > button.student-step-action--primary {
border-color: #36d9ff !important;
color: #ffffff !important;
background: linear-gradient(110deg, #0eaae4, #0572ac) !important;
}
.student-training-shell .step-actions > button.student-step-action--secondary,
.student-training-shell .step-navigation > button.student-step-action--secondary,
.student-training-shell .footer-actions > button.student-step-action--secondary,
.student-training-shell .training-actions > button.student-step-action--secondary,
.student-training-shell .task-actions > button.student-step-action--secondary,
.student-training-shell .taskfooter > button.student-step-action--secondary {
border-color: #2cc9ff !important;
color: #dff8ff !important;
background: rgba(8, 87, 121, 0.55) !important;
}

@ -4,7 +4,7 @@
<section class="task-header"><span>核心实训 · 产品上线与运营推广</span><h1>{{ taskTitle }}</h1></section> <section class="task-header"><span>核心实训 · 产品上线与运营推广</span><h1>{{ taskTitle }}</h1></section>
<TrainingTaskBrief :background="taskBackground" :goals="taskGoals" :requirements="taskRequirement" :show-material="false" :show-toolbar="false" /> <TrainingTaskBrief :background="taskBackground" :goals="taskGoals" :requirements="taskRequirement" :show-material="false" :show-toolbar="false" />
<section class="workbench"> <section class="workbench">
<div class="workbench-top"><div><p>产品问题诊断与迭代</p><h2>{{ currentStep === 1 ? '全链路转化漏斗诊断' : currentStep === 2 ? '用户评论根因分析' : 'AB 测试验证方案' }}</h2></div><TrainingTaskToolbar class="diagnosis-toolbar" :task-key="TASK_KEY" /></div> <div class="workbench-top"><div><p>产品问题诊断与迭代</p><h2>{{ currentStep === 1 ? '全链路转化漏斗诊断' : currentStep === 2 ? '用户评论根因分析' : 'AB 测试验证方案' }}</h2></div><TrainingTaskToolbar class="diagnosis-toolbar" :task-key="TASK_KEY" :on-submit="submitTask" :on-reset="resetTraining" :on-export="exportTrainingOutcome" /></div>
<nav class="step-tabs" aria-label="产品问题诊断步骤"><button type="button" :class="['step-tab', { active: currentStep === 1, complete: currentStep > 1 }]" :aria-current="currentStep === 1 ? 'step' : undefined" @click="currentStep = 1"><span class="step-marker">01</span><span class="step-copy"><b>全链路漏斗诊断</b><small>{{ currentStep === 1 ? '当前步骤 →' : '点击切换 →' }}</small></span></button><span class="step-connector" aria-hidden="true" /><button type="button" :class="['step-tab', { active: currentStep === 2, complete: currentStep > 2 }]" :aria-current="currentStep === 2 ? 'step' : undefined" @click="currentStep = 2"><span class="step-marker">02</span><span class="step-copy"><b>用户评论根因分析</b><small>{{ currentStep === 2 ? '当前步骤 →' : '点击切换 →' }}</small></span></button><span class="step-connector" aria-hidden="true" /><button type="button" :class="['step-tab', { active: currentStep === 3 }]" :aria-current="currentStep === 3 ? 'step' : undefined" @click="currentStep = 3"><span class="step-marker">03</span><span class="step-copy"><b>AB测试验证</b><small>{{ currentStep === 3 ? '当前步骤 →' : '点击切换 →' }}</small></span></button></nav> <nav class="step-tabs" aria-label="产品问题诊断步骤"><button type="button" :class="['step-tab', { active: currentStep === 1, complete: currentStep > 1 }]" :aria-current="currentStep === 1 ? 'step' : undefined" @click="currentStep = 1"><span class="step-marker">01</span><span class="step-copy"><b>全链路漏斗诊断</b><small>{{ currentStep === 1 ? '当前步骤 →' : '点击切换 →' }}</small></span></button><span class="step-connector" aria-hidden="true" /><button type="button" :class="['step-tab', { active: currentStep === 2, complete: currentStep > 2 }]" :aria-current="currentStep === 2 ? 'step' : undefined" @click="currentStep = 2"><span class="step-marker">02</span><span class="step-copy"><b>用户评论根因分析</b><small>{{ currentStep === 2 ? '当前步骤 →' : '点击切换 →' }}</small></span></button><span class="step-connector" aria-hidden="true" /><button type="button" :class="['step-tab', { active: currentStep === 3 }]" :aria-current="currentStep === 3 ? 'step' : undefined" @click="currentStep = 3"><span class="step-marker">03</span><span class="step-copy"><b>AB测试验证</b><small>{{ currentStep === 3 ? '当前步骤 →' : '点击切换 →' }}</small></span></button></nav>
<template v-if="currentStep === 1"> <template v-if="currentStep === 1">
@ -22,7 +22,7 @@
<template v-else-if="currentStep === 2"> <template v-else-if="currentStep === 2">
<p class="lead">漏斗只能定位“哪个环节出了问题”,要回答为什么出问题,需要分析用户最真实的反馈。本步骤通过词云分析和情感倾向分析,从用户评价中提取高频问题,判断整体口碑倾向,验证漏斗异常背后的真实根因。</p> <p class="lead">漏斗只能定位“哪个环节出了问题”,要回答为什么出问题,需要分析用户最真实的反馈。本步骤通过词云分析和情感倾向分析,从用户评价中提取高频问题,判断整体口碑倾向,验证漏斗异常背后的真实根因。</p>
<section class="section-block"><div class="section-heading"><span>一</span><div><h3>词云分析</h3><p>导入数据,生成词云,基于案例评论数据自动生成高频问题词云。</p></div></div><input ref="commentInput" class="visually-hidden" type="file" accept=".csv,.txt,.xls,.xlsx,text/csv,text/plain,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" @change="handleCommentImport" /><div class="comment-import"><button type="button" class="upload-button" :disabled="analyzing" @click="commentInput?.click()">{{ analyzing ? '正在生成词云…' : '导入评论数据生成词云' }}</button><p>支持 CSV、TXT、XLS、XLSX,文件不超过 5MB。</p><div v-if="commentPreview.fileName" class="data-preview"><div><strong>{{ commentPreview.fileName }}</strong><span>已解析 {{ commentPreview.totalComments }} 条评论 · 数据预览</span></div><ul><li v-for="(comment, index) in commentPreview.comments" :key="index">{{ comment }}</li></ul></div></div><div v-if="wordCloud.length" class="word-cloud-card"><div class="word-cloud-title"><span>高频问题词云</span><small>基于导入评论自动生成</small></div><div ref="wordCloudRef" class="word-cloud" aria-label="评论高频问题词云"></div></div><div class="top-issues"><p>请根据词云图填写词云中出现频次最高的TOP3问题。</p><div><label v-for="(_, index) in topIssues" :key="index">TOP{{ index + 1 }}:<el-input v-model="topIssues[index]" :placeholder="`填写第 ${index + 1} 个高频问题`" /></label></div></div></section> <section class="section-block"><div class="section-heading"><span>一</span><div><h3>词云分析</h3><p>导入数据,生成词云,基于案例评论数据自动生成高频问题词云。</p></div></div><input ref="commentInput" class="visually-hidden" type="file" accept=".csv,.txt,.xls,.xlsx,text/csv,text/plain,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" @change="handleCommentImport" /><div class="comment-import"><button type="button" class="upload-button" :disabled="analyzing" @click="commentInput?.click()">{{ analyzing ? '正在生成词云…' : '导入评论数据生成词云' }}</button><p>支持 CSV、TXT、XLS、XLSX,文件不超过 5MB。</p><div v-if="commentPreview.fileName" class="data-preview"><div><strong>{{ commentPreview.fileName }}</strong><span>已解析 {{ commentPreview.totalComments }} 条评论 · 数据预览</span></div><ul><li v-for="(comment, index) in commentPreview.comments" :key="index">{{ comment }}</li></ul></div></div><div v-if="wordCloud.length" class="word-cloud-card"><div class="word-cloud-title"><span>高频问题词云</span><small>基于导入评论自动生成</small></div><div ref="wordCloudRef" class="word-cloud" aria-label="评论高频问题词云"></div></div><div class="top-issues"><p>请根据词云图填写词云中出现频次最高的TOP3问题。</p><div><label v-for="(_, index) in topIssues" :key="index">TOP{{ index + 1 }}:<el-input v-model="topIssues[index]" :placeholder="`填写第 ${index + 1} 个高频问题`" /></label></div></div></section>
<section class="section-block"><div class="section-heading"><span>二</span><div><h3>情感倾向分析</h3></div></div><div class="sentiment-actions"><span>请点击情感倾向分析按钮,系统自动判断各情感类型评论数量,请计算各情感类型占比,正面评论占比=正面评论数量/总评论数量,以此类推,计算结果用百分数表示,保留两位小数。</span><button type="button" class="upload-button" :disabled="!analysisResult" @click="runSentimentAnalysis">情感倾向分析</button></div><div class="table-wrap"><table class="sentiment-table"><thead><tr><th>情感类型</th><th>评论数量(条)</th><th>占比</th></tr></thead><tbody><tr v-for="row in sentiments" :key="row.type"><th>{{ row.type }}</th><td><span class="auto-count">{{ row.count ?? '—' }}</span></td><td><el-input-number v-model="row.percentage" :min="0" :max="100" :precision="2" :controls="false" :disabled="row.count === null" placeholder="请输入" /><span class="unit">%</span></td></tr></tbody></table></div></section> <section class="section-block"><div class="section-heading"><span>二</span><div><h3>情感倾向分析</h3></div></div><div class="sentiment-actions"><span>点击情感倾向分析后,系统会自动计算正面、中性、负面评论数量及占比,并按校验所需精度保留两位小数。</span><button type="button" class="upload-button" :disabled="!analysisResult" @click="runSentimentAnalysis">情感倾向分析</button></div><div class="table-wrap"><table class="sentiment-table"><thead><tr><th>情感类型</th><th>评论数量(条)</th><th>占比</th></tr></thead><tbody><tr v-for="row in sentiments" :key="row.type"><th>{{ row.type }}</th><td><span class="auto-count">{{ row.count ?? '—' }}</span></td><td><el-input-number v-model="row.percentage" :min="0" :max="100" :precision="2" :controls="false" disabled placeholder="系统自动计算" /><span class="unit">%</span></td></tr></tbody></table></div></section>
<section class="section-block root-cause"><p>请结合词云TOP3与情感分析结果,说明本案例的核心问题根本原因;说明高频问题与情感数据是如何验证步骤一漏斗异常结论的</p><el-input v-model="rootCauseAnalysis" type="textarea" :rows="6" maxlength="1000" show-word-limit placeholder="请填写根因分析与对步骤一异常结论的验证说明" /></section> <section class="section-block root-cause"><p>请结合词云TOP3与情感分析结果,说明本案例的核心问题根本原因;说明高频问题与情感数据是如何验证步骤一漏斗异常结论的</p><el-input v-model="rootCauseAnalysis" type="textarea" :rows="6" maxlength="1000" show-word-limit placeholder="请填写根因分析与对步骤一异常结论的验证说明" /></section>
</template> </template>
@ -48,7 +48,7 @@ import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue"; import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue";
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue"; import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
import { getTrainingTaskByKey } from "@/api/trainingTask"; import { getTrainingTaskByKey } from "@/api/trainingTask";
import { analyzeProductDiagnosisIterationStepTwo, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile, validateProductDiagnosisIterationStepOne, validateProductDiagnosisIterationStepTwo, validateProductDiagnosisIterationStepThree } from "@/api/studentTrainingAnswer"; import { analyzeProductDiagnosisIterationStepTwo, deleteStudentTrainingAnswer, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile, validateProductDiagnosisIterationStepOne, validateProductDiagnosisIterationStepTwo, validateProductDiagnosisIterationStepThree } from "@/api/studentTrainingAnswer";
import { parseTrainingArray } from "@/views/training/taskKeyMap"; import { parseTrainingArray } from "@/views/training/taskKeyMap";
import { isStudentDemo } from "@/utils/studentDemo"; import { isStudentDemo } from "@/utils/studentDemo";
@ -77,7 +77,17 @@ async function handleChartUpload(event) { const file = event.target?.files?.[0];
async function saveStepOneDraft() { await persistStepOne(false); } async function validateStepOne() { if (saving.value) return; saving.value = true; try { const data = buildStepOne(); await validateProductDiagnosisIterationStepOne(data); await saveStudentTrainingAnswer(TASK_KEY, { step1Answer: JSON.stringify(data), currentStep: 2, submitted: false, saveAction: "SAVE" }); currentStep.value = 2; proxy?.$modal?.msgSuccess?.("第 1 步校验通过,已进入第 2 步"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请检查第 1 步填写内容"); } finally { saving.value = false; } } async function persistStepOne(showMessage) { if (saving.value) return; saving.value = true; try { await saveStudentTrainingAnswer(TASK_KEY, { step1Answer: JSON.stringify(buildStepOne()), currentStep: 1, submitted: false, saveAction: "SAVE" }); if (showMessage !== false) proxy?.$modal?.msgSuccess?.("草稿已保存"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "保存失败,请重试"); } finally { saving.value = false; } } async function saveStepOneDraft() { await persistStepOne(false); } async function validateStepOne() { if (saving.value) return; saving.value = true; try { const data = buildStepOne(); await validateProductDiagnosisIterationStepOne(data); await saveStudentTrainingAnswer(TASK_KEY, { step1Answer: JSON.stringify(data), currentStep: 2, submitted: false, saveAction: "SAVE" }); currentStep.value = 2; proxy?.$modal?.msgSuccess?.("第 1 步校验通过,已进入第 2 步"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请检查第 1 步填写内容"); } finally { saving.value = false; } } async function persistStepOne(showMessage) { if (saving.value) return; saving.value = true; try { await saveStudentTrainingAnswer(TASK_KEY, { step1Answer: JSON.stringify(buildStepOne()), currentStep: 1, submitted: false, saveAction: "SAVE" }); if (showMessage !== false) proxy?.$modal?.msgSuccess?.("草稿已保存"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "保存失败,请重试"); } finally { saving.value = false; } }
async function handleCommentImport(event) { const file = event.target?.files?.[0]; if (!file) return; if (!/\.(csv|txt|xls|xlsx)$/i.test(file.name) || file.size > 5 * 1024 * 1024) { proxy?.$modal?.msgError?.("请上传不超过 5MB 的 CSV、TXT、XLS 或 XLSX 评论文件"); event.target.value = ""; return; } analyzing.value = true; try { const result = (await analyzeProductDiagnosisIterationStepTwo(file))?.data; if (!result) throw new Error("未获得评论分析结果"); analysisResult.value = result; commentPreview.value = { fileName: result.sourceFileName || file.name, totalComments: result.totalComments || 0, comments: result.previewComments || [] }; wordCloud.value = result.wordCloud || []; topIssues.value = [0, 1, 2].map((index) => result.topIssues?.[index] || ""); sentiments.value = createSentiments(); await nextTick(); renderWordCloud(); proxy?.$modal?.msgSuccess?.("评论数据已导入,词云已生成"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "评论数据分析失败,请检查文件格式"); } finally { analyzing.value = false; event.target.value = ""; } } async function handleCommentImport(event) { const file = event.target?.files?.[0]; if (!file) return; if (!/\.(csv|txt|xls|xlsx)$/i.test(file.name) || file.size > 5 * 1024 * 1024) { proxy?.$modal?.msgError?.("请上传不超过 5MB 的 CSV、TXT、XLS 或 XLSX 评论文件"); event.target.value = ""; return; } analyzing.value = true; try { const result = (await analyzeProductDiagnosisIterationStepTwo(file))?.data; if (!result) throw new Error("未获得评论分析结果"); analysisResult.value = result; commentPreview.value = { fileName: result.sourceFileName || file.name, totalComments: result.totalComments || 0, comments: result.previewComments || [] }; wordCloud.value = result.wordCloud || []; topIssues.value = [0, 1, 2].map((index) => result.topIssues?.[index] || ""); sentiments.value = createSentiments(); await nextTick(); renderWordCloud(); proxy?.$modal?.msgSuccess?.("评论数据已导入,词云已生成"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "评论数据分析失败,请检查文件格式"); } finally { analyzing.value = false; event.target.value = ""; } }
function renderWordCloud() { if (!wordCloudRef.value || !wordCloud.value.length) return; wordCloudChart?.dispose(); wordCloudChart = echarts.init(wordCloudRef.value); wordCloudChart.setOption({ backgroundColor: "transparent", tooltip: { show: true }, series: [{ type: "wordCloud", shape: "circle", gridSize: 5, sizeRange: [16, 58], rotationRange: [-35, 35], textStyle: { color: () => ["#53e2ff", "#6ae1bb", "#ffd06a", "#91b8ff", "#ff9e88"][Math.floor(Math.random() * 5)] }, data: wordCloud.value }] }); } function renderWordCloud() { if (!wordCloudRef.value || !wordCloud.value.length) return; wordCloudChart?.dispose(); wordCloudChart = echarts.init(wordCloudRef.value); wordCloudChart.setOption({ backgroundColor: "transparent", tooltip: { show: true }, series: [{ type: "wordCloud", shape: "circle", gridSize: 5, sizeRange: [16, 58], rotationRange: [-35, 35], textStyle: { color: () => ["#53e2ff", "#6ae1bb", "#ffd06a", "#91b8ff", "#ff9e88"][Math.floor(Math.random() * 5)] }, data: wordCloud.value }] }); }
function runSentimentAnalysis() { if (!analysisResult.value) return; sentiments.value = [{ type: "正面", count: analysisResult.value.positiveCount, percentage: null }, { type: "中性", count: analysisResult.value.neutralCount, percentage: null }, { type: "负面", count: analysisResult.value.negativeCount, percentage: null }]; proxy?.$modal?.msgSuccess?.("评论数量已自动填充,请计算并填写各类情感占比"); } function runSentimentAnalysis() {
if (!analysisResult.value) return;
const counts = [analysisResult.value.positiveCount, analysisResult.value.neutralCount, analysisResult.value.negativeCount].map((value) => Number(value) || 0);
const total = counts.reduce((sum, value) => sum + value, 0);
sentiments.value = ["正面", "中性", "负面"].map((type, index) => ({
type,
count: counts[index],
percentage: total > 0 ? Number((counts[index] * 100 / total).toFixed(2)) : null,
}));
proxy?.$modal?.msgSuccess?.("情感评论数量与占比已自动计算");
}
async function saveStepTwoDraft() { if (saving.value) return false; saving.value = true; try { await saveStudentTrainingAnswer(TASK_KEY, { step2Answer: JSON.stringify(buildStepTwo()), currentStep: 2, submitted: false, saveAction: "SAVE" }); proxy?.$modal?.msgSuccess?.("当前步骤已保存"); return true; } catch (error) { proxy?.$modal?.msgError?.(error?.message || "保存失败,请重试"); return false; } finally { saving.value = false; } } async function validateStepTwo() { if (saving.value) return false; saving.value = true; try { const data = buildStepTwo(); await validateProductDiagnosisIterationStepTwo(data); await saveStudentTrainingAnswer(TASK_KEY, { step2Answer: JSON.stringify(data), currentStep: 2, submitted: false, saveAction: "SAVE" }); proxy?.$modal?.msgSuccess?.("第 2 步校验通过,已保存"); return true; } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请检查词云、情感占比和根因分析"); return false; } finally { saving.value = false; } } async function saveStepTwoDraft() { if (saving.value) return false; saving.value = true; try { await saveStudentTrainingAnswer(TASK_KEY, { step2Answer: JSON.stringify(buildStepTwo()), currentStep: 2, submitted: false, saveAction: "SAVE" }); proxy?.$modal?.msgSuccess?.("当前步骤已保存"); return true; } catch (error) { proxy?.$modal?.msgError?.(error?.message || "保存失败,请重试"); return false; } finally { saving.value = false; } } async function validateStepTwo() { if (saving.value) return false; saving.value = true; try { const data = buildStepTwo(); await validateProductDiagnosisIterationStepTwo(data); await saveStudentTrainingAnswer(TASK_KEY, { step2Answer: JSON.stringify(data), currentStep: 2, submitted: false, saveAction: "SAVE" }); proxy?.$modal?.msgSuccess?.("第 2 步校验通过,已保存"); return true; } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请检查词云、情感占比和根因分析"); return false; } finally { saving.value = false; } }
function clearAbChart() { if (abChart.value.url.startsWith("blob:")) URL.revokeObjectURL(abChart.value.url); abChart.value = { url: "", name: "" }; } function clearAbChart() { if (abChart.value.url.startsWith("blob:")) URL.revokeObjectURL(abChart.value.url); abChart.value = { url: "", name: "" }; }
async function handleAbChartUpload(event) { const file = event.target?.files?.[0]; if (!file) return; const accepted = ["image/jpeg", "image/png", "image/webp"]; if (!accepted.includes(file.type) || file.size > 5 * 1024 * 1024) { proxy?.$modal?.msgError?.("请上传不超过 5MB 的 PNG、JPG 或 WEBP 图片"); event.target.value = ""; return; } uploading.value = true; try { let url = ""; if (isStudentDemo()) url = URL.createObjectURL(file); else { const data = new FormData(); data.append("file", file); const response = await uploadStudentTrainingFile(data); url = response?.url || response?.data?.url || ""; } if (!url) throw new Error("上传成功但未返回图片地址"); clearAbChart(); abChart.value = { url, name: file.name }; proxy?.$modal?.msgSuccess?.("AB 测试结果对比图上传成功,可点击图片预览"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "图片上传失败,请重试"); } finally { uploading.value = false; event.target.value = ""; } } async function handleAbChartUpload(event) { const file = event.target?.files?.[0]; if (!file) return; const accepted = ["image/jpeg", "image/png", "image/webp"]; if (!accepted.includes(file.type) || file.size > 5 * 1024 * 1024) { proxy?.$modal?.msgError?.("请上传不超过 5MB 的 PNG、JPG 或 WEBP 图片"); event.target.value = ""; return; } uploading.value = true; try { let url = ""; if (isStudentDemo()) url = URL.createObjectURL(file); else { const data = new FormData(); data.append("file", file); const response = await uploadStudentTrainingFile(data); url = response?.url || response?.data?.url || ""; } if (!url) throw new Error("上传成功但未返回图片地址"); clearAbChart(); abChart.value = { url, name: file.name }; proxy?.$modal?.msgSuccess?.("AB 测试结果对比图上传成功,可点击图片预览"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "图片上传失败,请重试"); } finally { uploading.value = false; event.target.value = ""; } }
@ -92,6 +102,50 @@ async function nextStep() {
if (currentStep.value === 2 && await validateStepTwo()) currentStep.value = 3; if (currentStep.value === 2 && await validateStepTwo()) currentStep.value = 3;
} }
async function validateStepThree() { if (saving.value) return; saving.value = true; try { const data = buildStepThree(); await validateProductDiagnosisIterationStepThree(data); await saveStudentTrainingAnswer(TASK_KEY, { step3Answer: JSON.stringify(data), currentStep: 3, submitted: false, saveAction: "SAVE" }); proxy?.$modal?.msgSuccess?.("第 3 步校验通过,已保存"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请检查 AB 测试方案、指标数据和对比图"); } finally { saving.value = false; } } async function validateStepThree() { if (saving.value) return; saving.value = true; try { const data = buildStepThree(); await validateProductDiagnosisIterationStepThree(data); await saveStudentTrainingAnswer(TASK_KEY, { step3Answer: JSON.stringify(data), currentStep: 3, submitted: false, saveAction: "SAVE" }); proxy?.$modal?.msgSuccess?.("第 3 步校验通过,已保存"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请检查 AB 测试方案、指标数据和对比图"); } finally { saving.value = false; } }
async function submitTask() {
if (saving.value || uploading.value || analyzing.value) return false;
saving.value = true;
try {
const stepOne = buildStepOne();
await validateProductDiagnosisIterationStepOne(stepOne);
await saveStudentTrainingAnswer(TASK_KEY, { step1Answer: JSON.stringify(stepOne), currentStep: 1, submitted: false, saveAction: "SAVE" });
const stepTwo = buildStepTwo();
await validateProductDiagnosisIterationStepTwo(stepTwo);
await saveStudentTrainingAnswer(TASK_KEY, { step2Answer: JSON.stringify(stepTwo), currentStep: 2, submitted: false, saveAction: "SAVE" });
const stepThree = buildStepThree();
await validateProductDiagnosisIterationStepThree(stepThree);
await saveStudentTrainingAnswer(TASK_KEY, { step3Answer: JSON.stringify(stepThree), currentStep: 3, submitted: true, saveAction: "SUBMIT" });
currentStep.value = 3;
proxy?.$modal?.msgSuccess?.("实训任务已提交");
return true;
} catch (error) {
proxy?.$modal?.msgError?.(error?.message || "提交失败,请检查三个步骤的填写内容");
return false;
} finally {
saving.value = false;
}
}
async function resetTraining() {
if (saving.value || uploading.value || analyzing.value) return false;
try {
await deleteStudentTrainingAnswer(TASK_KEY);
clearChart(); clearAbChart(); wordCloudChart?.dispose(); wordCloudChart = null;
conversionRates.value = createConversionRates(); anomalyRows.value = [createAnomalyRow()]; analysisResult.value = null;
commentPreview.value = { fileName: "", totalComments: 0, comments: [] }; wordCloud.value = []; topIssues.value = ["", "", ""]; sentiments.value = createSentiments(); rootCauseAnalysis.value = "";
abTestPlan.value = ""; abRows.value = createAbRows(); iterationRecommendation.value = ""; currentStep.value = 1;
proxy?.$modal?.msgSuccess?.("已清空全部实训步骤,可以重新开始");
return true;
} catch (error) {
proxy?.$modal?.msgError?.(error?.message || "重新实训失败,请稍后重试");
return false;
}
}
function exportTrainingOutcome() {
const outcome = { taskName: taskTitle.value, exportedAt: new Date().toISOString(), step1: buildStepOne(), step2: buildStepTwo(), step3: buildStepThree() };
const url = URL.createObjectURL(new Blob([JSON.stringify(outcome, null, 2)], { type: "application/json;charset=utf-8" }));
const link = document.createElement("a"); link.href = url; link.download = "产品问题诊断与迭代-实训成果.json"; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url);
proxy?.$modal?.msgSuccess?.("实训成果已导出");
}
function restore(answer) { try { const stepOne = JSON.parse(answer?.step1Answer || "{}"); if (Array.isArray(stepOne.conversionRates) && stepOne.conversionRates.length === nodes.length) conversionRates.value = nodes.map((node) => ({ ...createConversionRates().find((row) => row.linkNode === node), ...(stepOne.conversionRates.find((row) => row?.linkNode === node) || {}) })); if (Array.isArray(stepOne.anomalyRows) && stepOne.anomalyRows.length) anomalyRows.value = stepOne.anomalyRows.slice(0, 5).map((row) => ({ ...createAnomalyRow(), ...row })); if (stepOne.funnelChartUrl) funnelChart.value = { url: stepOne.funnelChartUrl, name: stepOne.funnelChartName || "节点转化漏斗对比图" }; const stepTwo = JSON.parse(answer?.step2Answer || "{}"); if (stepTwo.sourceFileName) { commentPreview.value = { fileName: stepTwo.sourceFileName, totalComments: 0, comments: stepTwo.previewComments || [] }; wordCloud.value = stepTwo.wordCloud || []; topIssues.value = [0, 1, 2].map((index) => stepTwo.topIssues?.[index] || ""); sentiments.value = (stepTwo.sentiments || createSentiments()).map((row) => ({ ...createSentiments().find((item) => item.type === row.type), ...row })); rootCauseAnalysis.value = stepTwo.rootCauseAnalysis || ""; nextTick(renderWordCloud); } const stepThree = JSON.parse(answer?.step3Answer || "{}"); if (stepThree.testPlan || stepThree.comparisonChartUrl || stepThree.iterationRecommendation) { abTestPlan.value = stepThree.testPlan || ""; if (Array.isArray(stepThree.rows) && stepThree.rows.length === createAbRows().length) abRows.value = createAbRows().map((row) => ({ ...row, ...(stepThree.rows.find((item) => item?.metric === row.metric) || {}) })); if (stepThree.comparisonChartUrl) abChart.value = { url: stepThree.comparisonChartUrl, name: stepThree.comparisonChartName || "AB 测试结果对比图" }; iterationRecommendation.value = stepThree.iterationRecommendation || ""; } currentStep.value = [1, 2, 3].includes(Number(answer?.currentStep)) ? Number(answer.currentStep) : 1; } catch (error) {} } function restore(answer) { try { const stepOne = JSON.parse(answer?.step1Answer || "{}"); if (Array.isArray(stepOne.conversionRates) && stepOne.conversionRates.length === nodes.length) conversionRates.value = nodes.map((node) => ({ ...createConversionRates().find((row) => row.linkNode === node), ...(stepOne.conversionRates.find((row) => row?.linkNode === node) || {}) })); if (Array.isArray(stepOne.anomalyRows) && stepOne.anomalyRows.length) anomalyRows.value = stepOne.anomalyRows.slice(0, 5).map((row) => ({ ...createAnomalyRow(), ...row })); if (stepOne.funnelChartUrl) funnelChart.value = { url: stepOne.funnelChartUrl, name: stepOne.funnelChartName || "节点转化漏斗对比图" }; const stepTwo = JSON.parse(answer?.step2Answer || "{}"); if (stepTwo.sourceFileName) { commentPreview.value = { fileName: stepTwo.sourceFileName, totalComments: 0, comments: stepTwo.previewComments || [] }; wordCloud.value = stepTwo.wordCloud || []; topIssues.value = [0, 1, 2].map((index) => stepTwo.topIssues?.[index] || ""); sentiments.value = (stepTwo.sentiments || createSentiments()).map((row) => ({ ...createSentiments().find((item) => item.type === row.type), ...row })); rootCauseAnalysis.value = stepTwo.rootCauseAnalysis || ""; nextTick(renderWordCloud); } const stepThree = JSON.parse(answer?.step3Answer || "{}"); if (stepThree.testPlan || stepThree.comparisonChartUrl || stepThree.iterationRecommendation) { abTestPlan.value = stepThree.testPlan || ""; if (Array.isArray(stepThree.rows) && stepThree.rows.length === createAbRows().length) abRows.value = createAbRows().map((row) => ({ ...row, ...(stepThree.rows.find((item) => item?.metric === row.metric) || {}) })); if (stepThree.comparisonChartUrl) abChart.value = { url: stepThree.comparisonChartUrl, name: stepThree.comparisonChartName || "AB 测试结果对比图" }; iterationRecommendation.value = stepThree.iterationRecommendation || ""; } currentStep.value = [1, 2, 3].includes(Number(answer?.currentStep)) ? Number(answer.currentStep) : 1; } catch (error) {} }
onMounted(async () => { try { taskConfig.value = (await getTrainingTaskByKey(TASK_KEY))?.data || null; } catch (error) { taskConfig.value = null; } try { restore((await getStudentTrainingAnswer(TASK_KEY))?.data); } catch (error) {} }); onBeforeUnmount(() => { if (funnelChart.value.url.startsWith("blob:")) URL.revokeObjectURL(funnelChart.value.url); if (abChart.value.url.startsWith("blob:")) URL.revokeObjectURL(abChart.value.url); wordCloudChart?.dispose(); }); onMounted(async () => { try { taskConfig.value = (await getTrainingTaskByKey(TASK_KEY))?.data || null; } catch (error) { taskConfig.value = null; } try { restore((await getStudentTrainingAnswer(TASK_KEY))?.data); } catch (error) {} }); onBeforeUnmount(() => { if (funnelChart.value.url.startsWith("blob:")) URL.revokeObjectURL(funnelChart.value.url); if (abChart.value.url.startsWith("blob:")) URL.revokeObjectURL(abChart.value.url); wordCloudChart?.dispose(); });
</script> </script>

@ -20,7 +20,13 @@
<p class="section-eyebrow">BOM cost workspace</p> <p class="section-eyebrow">BOM cost workspace</p>
<h2>{{ currentStepTitle }}</h2> <h2>{{ currentStepTitle }}</h2>
</div> </div>
<TrainingTaskToolbar class="hardware-cost-toolbar" :task-key="TASK_KEY" /> <TrainingTaskToolbar
class="hardware-cost-toolbar"
:task-key="TASK_KEY"
:on-submit="submitTask"
:on-reset="resetTraining"
:on-export="exportTrainingOutcome"
/>
</div> </div>
<nav class="step-indicator" aria-label="智能硬件成本估算步骤"> <nav class="step-indicator" aria-label="智能硬件成本估算步骤">
@ -283,7 +289,7 @@ import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue";
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue"; import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
import { getTrainingTaskByKey } from "@/api/trainingTask"; import { getTrainingTaskByKey } from "@/api/trainingTask";
import { parseTrainingArray } from "@/views/training/taskKeyMap"; import { parseTrainingArray } from "@/views/training/taskKeyMap";
import { getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile, validateHardwareCostStepOne, validateHardwareCostStepTwo, validateHardwareCostStepThree, validateHardwareCostStepFour } from "@/api/studentTrainingAnswer"; import { deleteStudentTrainingAnswer, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile, validateHardwareCostStepOne, validateHardwareCostStepTwo, validateHardwareCostStepThree, validateHardwareCostStepFour } from "@/api/studentTrainingAnswer";
import { isStudentDemo } from "@/utils/studentDemo"; import { isStudentDemo } from "@/utils/studentDemo";
const TASK_KEY = "hardware-cost"; const TASK_KEY = "hardware-cost";
@ -294,7 +300,7 @@ const MAX_ROWS = 40;
const { proxy } = getCurrentInstance(); const { proxy } = getCurrentInstance();
const taskConfig = ref({}); const taskConfig = ref({});
const currentStep = ref(1); const currentStep = ref(1);
const isStepSaving = computed(() => saving.value || savingStepTwo.value || savingStepThree.value || savingStepFour.value || Boolean(uploadingChart.value)); const isStepSaving = computed(() => saving.value || savingStepTwo.value || savingStepThree.value || savingStepFour.value || submittingTraining.value || Boolean(uploadingChart.value));
const currentStepTitle = computed(() => ({ const currentStepTitle = computed(() => ({
1: "核心元器件选型组合", 1: "核心元器件选型组合",
2: "批量成本核算与约束验证", 2: "批量成本核算与约束验证",
@ -312,6 +318,7 @@ const savingStepThree = ref(false);
const exportingStepThree = ref(false); const exportingStepThree = ref(false);
const stepThreeSaved = ref(false); const stepThreeSaved = ref(false);
const savingStepFour = ref(false); const savingStepFour = ref(false);
const submittingTraining = ref(false);
const uploadingChart = ref(""); const uploadingChart = ref("");
const stepTwoValidation = ref(null); const stepTwoValidation = ref(null);
const stepTwo = reactive({ smallBatchConstraint: "", largeBatchConstraint: "", items: [] }); const stepTwo = reactive({ smallBatchConstraint: "", largeBatchConstraint: "", items: [] });
@ -595,7 +602,7 @@ async function handleCostChartUpload(event, type) {
uploadingChart.value = ""; uploadingChart.value = "";
} }
} }
async function saveStepFour() { async function saveStepFour(saveAction = "SAVE") {
savingStepFour.value = true; savingStepFour.value = true;
try { try {
const payload = stepFourPayload(); const payload = stepFourPayload();
@ -605,17 +612,19 @@ async function saveStepFour() {
if (!validated.valid) { notifyError(validated.message || "请完善成本结构图和优化方案"); return; } if (!validated.valid) { notifyError(validated.message || "请完善成本结构图和优化方案"); return; }
await saveStudentTrainingAnswer(TASK_KEY, { await saveStudentTrainingAnswer(TASK_KEY, {
currentStep: 4, currentStep: 4,
saveAction: "SAVE", saveAction,
submitted: false, submitted: saveAction === "SUBMIT",
step4Answer: JSON.stringify({ step4Answer: JSON.stringify({
smallBatchChart: { ...stepFour.smallBatchChart }, smallBatchChart: { ...stepFour.smallBatchChart },
largeBatchChart: { ...stepFour.largeBatchChart }, largeBatchChart: { ...stepFour.largeBatchChart },
optimizationPlan: payload.optimizationPlan, optimizationPlan: payload.optimizationPlan,
}), }),
}); });
notifySuccess("成本优化决策已保存"); notifySuccess(saveAction === "SUBMIT" ? "实训任务已提交" : "成本优化决策已保存");
return true;
} catch (error) { } catch (error) {
notifyError(error?.message || "成本优化决策保存失败,请检查填写内容"); notifyError(error?.message || "成本优化决策保存失败,请检查填写内容");
return false;
} finally { } finally {
savingStepFour.value = false; savingStepFour.value = false;
} }
@ -624,9 +633,147 @@ async function saveCurrentProgress() {
if (currentStep.value === 1) { await saveStepOne(false); return Boolean(savedStepOneItems.value.length); } if (currentStep.value === 1) { await saveStepOne(false); return Boolean(savedStepOneItems.value.length); }
if (currentStep.value === 2) { await saveStepTwo(); return stepTwoSaved.value; } if (currentStep.value === 2) { await saveStepTwo(); return stepTwoSaved.value; }
if (currentStep.value === 3) { await saveStepThree(false); return stepThreeSaved.value; } if (currentStep.value === 3) { await saveStepThree(false); return stepThreeSaved.value; }
await saveStepFour(); return saveStepFour();
return true; }
function buildStepOneAnswer(items, total) {
return JSON.stringify({ items, totalAmount: Number(total).toFixed(2) });
} }
function buildStepTwoAnswer(validated) {
return JSON.stringify({
items: stepTwo.items,
smallBatchConstraint: validated.smallBatchConstraint,
largeBatchConstraint: validated.largeBatchConstraint,
smallBatchTotal: validated.smallBatchTotal,
largeBatchTotal: validated.largeBatchTotal,
});
}
function buildStepThreeAnswer(validated) {
return JSON.stringify({
items: stepThree.items,
smallBatchManufacturingCost: validated.smallBatchManufacturingCost,
largeBatchManufacturingCost: validated.largeBatchManufacturingCost,
smallBatchBomCost: validated.smallBatchBomCost,
largeBatchBomCost: validated.largeBatchBomCost,
smallBatchUnitTotalCost: validated.smallBatchUnitTotalCost,
largeBatchUnitTotalCost: validated.largeBatchUnitTotalCost,
});
}
function buildStepFourAnswer() {
return JSON.stringify({
smallBatchChart: { ...stepFour.smallBatchChart },
largeBatchChart: { ...stepFour.largeBatchChart },
optimizationPlan: stepFourPayload().optimizationPlan,
});
}
async function submitTask() {
if (isStepSaving.value) return false;
submittingTraining.value = true;
try {
// 顶部“提交任务”必须使用本页四步真实数据,不能走通用 DOM 快照提交。
const stepOneResult = (await validateHardwareCostStepOne(stepOnePayload()))?.data || {};
if (stepOneResult.valid === false) throw new Error(stepOneResult.message || "请完善第 1 步 BOM 物料选型");
const normalizedRows = Array.isArray(stepOneResult.items) && stepOneResult.items.length
? normalizeRows(stepOneResult.items)
: normalizeRows(stepOnePayload().items);
rows.value = normalizedRows;
const stepOneItems = stepOnePayload(normalizedRows).items;
savedStepOneItems.value = stepOneItems;
syncStepTwoRows(stepOneItems);
await saveStudentTrainingAnswer(TASK_KEY, {
currentStep: 1,
saveAction: "SAVE",
step1Answer: buildStepOneAnswer(stepOneItems, stepOneResult.totalAmount ?? totalAmount.value),
});
const stepTwoResult = (await validateHardwareCostStepTwo(stepTwoPayload()))?.data || {};
applyValidationResult(stepTwoResult);
if (!stepTwoResult.valid) throw new Error(stepTwoResult.message || "请完善第 2 步批量成本核算");
await saveStudentTrainingAnswer(TASK_KEY, {
currentStep: 2,
saveAction: "SAVE",
step2Answer: buildStepTwoAnswer(stepTwoResult),
});
stepTwoSaved.value = true;
const stepThreeResult = (await validateHardwareCostStepThree(stepThreePayload()))?.data || {};
applyStepThreeValidation(stepThreeResult);
if (!stepThreeResult.valid) throw new Error(stepThreeResult.message || "请完善第 3 步制造成本核算");
await saveStudentTrainingAnswer(TASK_KEY, {
currentStep: 3,
saveAction: "SAVE",
step3Answer: buildStepThreeAnswer(stepThreeResult),
});
stepThreeSaved.value = true;
const stepFourResult = (await validateHardwareCostStepFour(stepFourPayload()))?.data || {};
stepFourValidation.value = stepFourResult;
if (!stepFourResult.valid) throw new Error(stepFourResult.message || "请完善第 4 步成本优化决策");
await saveStudentTrainingAnswer(TASK_KEY, {
currentStep: 4,
saveAction: "SUBMIT",
submitted: true,
step4Answer: buildStepFourAnswer(),
});
currentStep.value = 4;
notifySuccess("实训任务已提交");
return true;
} catch (error) {
notifyError(error?.message || "提交失败,请检查四个步骤的填写内容");
return false;
} finally {
submittingTraining.value = false;
}
}
async function resetTraining() {
if (isStepSaving.value) return false;
try {
await deleteStudentTrainingAnswer(TASK_KEY);
rows.value = [createExampleRow()];
savedStepOneItems.value = [];
stepTwo.smallBatchConstraint = "";
stepTwo.largeBatchConstraint = "";
stepTwo.items = [];
stepTwoSaved.value = false;
stepTwoValidation.value = null;
stepThree.items = createDefaultManufacturingRows();
stepThreeSaved.value = false;
stepThreeValidation.value = null;
resetStepFour();
currentStep.value = 1;
notifySuccess("已清空全部实训步骤,可以重新开始");
return true;
} catch (error) {
notifyError(error?.message || "重新实训失败,请稍后重试");
return false;
}
}
function exportTrainingOutcome() {
const outcome = {
taskName: taskTitle.value,
exportedAt: new Date().toISOString(),
step1: { items: stepOnePayload().items, totalAmount: totalAmount.value },
step2: { ...stepTwoPayload(), smallBatchTotal: smallBatchTotal.value, largeBatchTotal: largeBatchTotal.value },
step3: { ...stepThreePayload(), smallBatchUnitTotalCost: smallBatchUnitTotalCost.value, largeBatchUnitTotalCost: largeBatchUnitTotalCost.value },
step4: JSON.parse(buildStepFourAnswer()),
};
const url = URL.createObjectURL(new Blob([JSON.stringify(outcome, null, 2)], { type: "application/json;charset=utf-8" }));
const link = document.createElement("a");
link.href = url;
link.download = "智能硬件成本估算-实训成果.json";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
notifySuccess("实训成果已导出");
}
function previousStep() { if (currentStep.value > 1) currentStep.value -= 1; } function previousStep() { if (currentStep.value > 1) currentStep.value -= 1; }
async function nextStep() { if (currentStep.value < 4 && await saveCurrentProgress()) currentStep.value += 1; } async function nextStep() { if (currentStep.value < 4 && await saveCurrentProgress()) currentStep.value += 1; }
function resultClass(field) { return stepTwoValidation.value?.[field] === true ? "passed" : stepTwoValidation.value?.[field] === false ? "failed" : ""; } function resultClass(field) { return stepTwoValidation.value?.[field] === true ? "passed" : stepTwoValidation.value?.[field] === false ? "failed" : ""; }

Loading…
Cancel
Save