< template >
< div class = "diagnosis-page student-training-shell" >
< main class = "diagnosis-core" >
< section class = "task-header" >
< div class = "task-head-main" >
< span class = "task-badge" > < el -icon > < DataAnalysis / > < / e l - i c o n > 核 心 实 训 · 产 品 上 线 与 运 营 推 广 < / s p a n >
< h1 class = "task-title" > { { taskTitle } } < / h1 >
< p class = "task-brief" >
< span class = "task-brief-text" > { { taskBackground } } < / span >
< button type = "button" class = "task-brief-toggle" @ click = "briefOpen = !briefOpen" >
{ { briefOpen ? "收起" : "展开目标与要求" } }
< / button >
< span v-if ="briefOpen" class="task-brief-more" >
< b > 实训目标 < / b >
< ul > < li v-for ="goal in taskGoals" :key ="goal" > {{ goal }} < / li > < / ul >
< b > 实训要求 < / b >
< p > { { taskRequirement } } < / p >
< / span >
< / p >
< / div >
< TrainingTaskToolbar class = "diagnosis-toolbar" :task-key ="TASK_KEY" :on-submit ="submitTask" :on-reset ="resetTraining" :on-export ="exportTrainingOutcome" / >
< / section >
< section class = "workbench" >
< nav class = "step-indicator" 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 class = "step-name" > 全链路漏斗诊断 < / b > < small class = "step-action" > { { 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 class = "step-name" > 用户评论根因分析 < / b > < small class = "step-action" > { { 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 class = "step-name" > AB测试验证 < / b > < small class = "step-action" > { { currentStep === 3 ? '当前步骤 →' : '点击切换 →' } } < / small > < / span > < / button > < / nav >
< template v-if ="currentStep === 1" >
< p class = "lead" > 从用户触达产品的全路径出发 , 搭建从曝光到复购的完整数据漏斗 , 逐层计算各环节转化率并对比行业均值 , 定位产品在哪个环节出现了明显异常流失 , 精准锁定问题所在的运营环节 。 < / p >
< div class = "diagnosis-materials" aria -label = " 第 1 步 参 考 资 料 " >
< div class = "diagnosis-materials__item" > < strong > 1. 下载案例数据表 , 使用Excel计算各节点的转化率 , 参考 《 实训操作说明 》 。 < / strong > < div class = "diagnosis-materials__actions" > < button type = "button" class = "secondary-button" @click ="downloadNodeGuide" > 下 载 实 训 操 作 说 明 < / button > < / div > < / div >
< div class = "diagnosis-materials__item" > < strong > 2. 下载Excel模版 , 填写关键信息 , 自动计算各环节转化率 。 提供自动化模版 。 < / strong > < div class = "diagnosis-materials__actions" > < button type = "button" class = "secondary-button" @click ="downloadNodeTemplate" > 下 载 Excel 模 板 < / button > < / div > < / div >
< p class = "diagnosis-materials__ai" > 3. 导出数据表上传到通用AI大模型 , 通过AI大模型辅助完成计算 , 参考步骤及提示词如下 : 请根据表格数据 , 计算从曝光到复购链路节点转化率 。 < / p >
< / div >
< section class = "section-block" >
< div class = "section-heading" > < span > 一 < / span > < div > < h3 > 计算各节点转化率 < / h3 > < p > 根据案例数据计算各个渠道链路节点转化率 , 参考方法及工具如下 : < / p > < / div > < / div >
< div class = "table-toolbar funnel-channel-toolbar" >
< span > 链路节点和行业均值固定 , 中间渠道可按案例数据增加或删除 。 < / span >
< button type = "button" class = "table-button" @click ="addFunnelChannel" > 增 加 渠 道 < / button >
< / div >
< div class = "table-wrap" >
< table class = "funnel-table" >
< thead >
< tr >
< th class = "fixed-column" > 链路节点 < / th >
< th v-for ="channel in channels" :key="channel.id" class="channel-column" >
< div class = "channel-heading" >
< el -input v -model = " channel.name " maxlength = "20" placeholder = "填写渠道名称" / >
< button type = "button" class = "channel-remove" : aria -label = " ` 删 除 $ { channel.name | | ' 未 命 名 ' } 渠 道 ` " @click ="removeFunnelChannel(channel.id)" > × < / button >
< / div >
< / th >
< th class = "fixed-column" > 行业均值 < / th >
< / tr >
< / thead >
< tbody >
< tr v-for ="row in conversionRates" :key="row.linkNode" >
< th > { { row . linkNode } } < / th >
< td v-for ="(channel, channelIndex) in channels" :key="channel.id" >
< div class = "rate-input" >
< el -input -number v -model = " row.channelRates [ channel.id ] " :min ="0" :max ="100" :precision ="2" :controls ="false" : placeholder = "row.linkNode === '曝光-点击' && channelIndex === 0 ? '示例: 11.5' : '请输入'" / >
< span class = "unit" > % < / span >
< / div >
< / td >
< td > < div class = "rate-input" > < el -input -number v-model ="row.industryAverage" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit" > % < / span > < / div > < / td >
< / tr >
< / tbody >
< / table >
< / div >
< / section >
< section class = "section-block visual-section" > < div class = "section-heading" > < span > 二 < / span > < div > < h3 > 全链路转化漏斗可视化 < / h3 > < p > 根据节点转化率计算结果 , 绘制各渠道转化率与行业均值的转化漏斗对比图 , 可视化呈现多环节情况 , 参考方法及工具如下 : < / p > < / div > < / div > < div class = "diagnosis-materials" aria -label = " 第 2 步 参 考 资 料 " > < div class = "diagnosis-materials__item" > < strong > 1. 使用Excel绘图功能 , 自主完成转化漏斗对比图 , 可参考 《 实训操作说明 》 。 < / strong > < div class = "diagnosis-materials__actions" > < button type = "button" class = "secondary-button" @click ="downloadFunnelGuide" > 下 载 实 训 操 作 说 明 < / button > < / div > < / div > < div class = "diagnosis-materials__item" > < strong > 2. 下载Excel模版 , 填写关键信息 , 自动计算各环节转化率并生成转化漏斗对比图 。 < / strong > < div class = "diagnosis-materials__actions" > < button type = "button" class = "secondary-button" @click ="downloadFunnelTemplate" > 下 载 Excel 模 板 < / button > < / div > < / div > < p class = "diagnosis-materials__ai" > 3. 导出数据表上传到通用AI大模型 , 通过AI大模型辅助完成 , 参考步骤及提示词如下 : 请根据表格数据绘制漏斗图 , 并与行业均值漏斗图进行对比 。 < / p > < / div > < input ref = "chartInput" class = "visually-hidden" type = "file" accept = "image/png,image/jpeg,image/webp" @change ="handleChartUpload" / > < div class = "chart-upload-area" > < template v-if ="funnelChart.url" > < div class = "chart-preview" > < el -image :src ="funnelChart.url" :preview-src-list ="[funnelChart.url]" fit = "cover" preview -teleported / > < div > < strong > { { funnelChart . name || '节点转化漏斗对比图' } } < / strong > < span > 点击缩略图可放大预览 < / span > < button type = "button" @click ="clearChart" > 移 除 图 片 < / button > < / div > < / div > < button type = "button" class = "secondary-button" :disabled ="uploading" @click ="chartInput?.click()" > {{ uploading ? ' 上 传 中 … ' : ' 更 换 对 比 图 ' }} < / button > < / template > < template v-else > < button type = "button" class = "upload-button" :disabled ="uploading" @click ="chartInput?.click()" > {{ uploading ? ' 上 传 中 … ' : ' 上 传 节 点 转 化 漏 斗 对 比 图 ' }} < / button > < p > 支持 PNG 、 JPG 、 WEBP 格式 , 图片不超过 5 MB ; 上传后可点击预览 。 < / p > < / template > < / div > < / section >
< section class = "section-block diagnosis-section" > < div class = "section-heading" > < span > 三 < / span > < div > < h3 > 定位异常节点 < / h3 > < p > 对比案例行业均值数据 , 定位异常节点 。 < / p > < / div > < / div > < div class = "table-toolbar" > < span > 对比案例行业均值数据 , 定位异常节点 。 < / span > < div > < button type = "button" class = "table-button" : disabled = "anomalyRows.length >= 5" @click ="addAnomalyRow" > 增 加 一 行 < / button > < button type = "button" class = "table-button ghost" : disabled = "anomalyRows.length <= 1" @click ="removeAnomalyRow" > 删 除 一 行 < / button > < / div > < / div > < div class = "table-wrap" > < table class = "diagnosis-table" > < thead > < tr > < th > 渠道 < / th > < th > 链路节点 < / th > < th > 店铺转化率 < / th > < th > 行业均值转化率 < / th > < th > 异常分析与判定 < / th > < / tr > < / thead > < tbody > < tr v-for ="(row, index) in anomalyRows" :key ="index" > < td > < el -select v-model ="row.channelId" placeholder="请选择" @change="syncAnomalyChannel(row)"><el-option v-for="channel in availableChannels" :key="channel.id" :label="channel.name" :value="channel.id" /></el-select></td><td><el-select v-model="row.linkNode" placeholder="请选择"><el-option v-for="node in nodes" :key="node" :label="node" :value="node" /></el-select></td><td><div class="rate-input"><el-input-number v-model="row.storeConversionRate" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit">%</span></div></td><td><div class="rate-input"><el-input-number v-model="row.industryAverageConversionRate" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit">%</span></div></td><td><el-input v-model="row.anomalyAnalysis" type="textarea" :rows="2" maxlength="300" show-word-limit placeholder="请填写异常原因或正常判定" /></td></tr></tbody></table></div><p class="diagnosis-step-one-notice" > 说 明 : 这 个 表 格 默 认 展 示 一 行 可 填 写 , 最 少 一 行 , 可 以 增 加 行 , 最 多 5 行 , 这 里 填 写 的 渠 道 需 要 来 源 于 第 一 个 表 的 渠 道 。 < / p > < / section >
< / template >
< template v -else -if = " currentStep = = = 2 " >
< 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 , 文件不超过 5 MB 。 < / 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><div class="rate-input"><el-input-number v-model="row.percentage" :min="0" :max="100" :precision="2" :controls="false" disabled placeholder="系统自动计算" /><span class="unit" > % < / span > < / div > < / 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 >
< / template >
< template v-else >
< p class = "lead" > 找到根因后 , 还需验证 “ 改进方案是否真的有效 ” , 避免拍脑袋迭代 。 AB测试对照诊断法将用户随机分为对照组 ( 维持原方案 ) 和实验组 ( 应用改进方案 ) , 对比两组的关键指标差异 , 用数据判断改进方案是否值得落地 。 < / p >
< section class = "section-block" > < div class = "section-heading" > < span > ( 1 ) < / span > < div > < h3 > 确定测试方案 < / h3 > < p > 请基于案例资料 , 针对步骤一和步骤二分析结果 , 设计AB测试方案 < / p > < / div > < / div > < el -input v -model = " abTestPlan " type = "textarea" :rows ="6" maxlength = "1000" show -word -limit placeholder = "填写对照组与实验组设置、改进方案、随机分流方式、观察周期和成功判定标准" / > < / section >
< section class = "section-block" > < div class = "section-heading" > < span > ( 2 ) < / span > < div > < h3 > AB测试数据填报 < / h3 > < p > 请从案例资料中获取AB测试两组数据 , 计算后填入下表并逐项判定 : < / p > < / div > < / div > < div class = "table-wrap" > < table class = "ab-table" > < thead > < tr > < th > 测试指标 < / th > < th > 对照组数据 < / th > < th > 实验组数据 < / th > < th > 判定与分析 < / th > < / tr > < / thead > < tbody > < tr v-for ="row in abRows" :key="row.metric"><th>{{ row.metric }}</th><td><div class="rate-input"><el-input-number v-model="row.controlValue" :min="0" :max="100" :precision="2" :controls="false" placeholder="示例: 56%" /><span class="unit">%</span></div></td><td><div class="rate-input"><el-input-number v-model="row.experimentValue" :min="0" :max="100" :precision="2" :controls="false" placeholder="示例: 64%" /><span class="unit" > % < / span > < / div > < / td > < td > < el -input v -model = " row.analysis " type = "textarea" :rows ="2" maxlength = "300" show -word -limit placeholder = "示例: 提升8pp, 显著提升, 采纳" / > < / td > < / tr > < / tbody > < / table > < / div > < / section >
< section class = "section-block" > < div class = "section-heading" > < span > ( 3 ) < / span > < div > < h3 > 可视化呈现 < / h3 > < p > 绘制AB测试结果对比图 , 参考方法及工具如下 : < / p > < / div > < / div > < input ref = "abChartInput" class = "visually-hidden" type = "file" accept = "image/png,image/jpeg,image/webp" @change ="handleAbChartUpload" / > < div class = "chart-upload-area" > < template v-if ="abChart.url" > < div class = "chart-preview" > < el -image :src ="abChart.url" :preview-src-list ="[abChart.url]" fit = "cover" preview -teleported / > < div > < strong > { { abChart . name || 'AB 测试结果对比图' } } < / strong > < span > 点击缩略图可放大预览 < / span > < button type = "button" @click ="clearAbChart" > 移 除 图 片 < / button > < / div > < / div > < button type = "button" class = "secondary-button" :disabled ="uploading" @click ="abChartInput?.click()" > {{ uploading ? ' 上 传 中 … ' : ' 更 换 对 比 图 ' }} < / button > < / template > < template v-else > < button type = "button" class = "upload-button" :disabled ="uploading" @click ="abChartInput?.click()" > {{ uploading ? ' 上 传 中 … ' : ' 上 传 AB 测 试 结 果 对 比 图 ' }} < / button > < p > 支持 PNG 、 JPG 、 WEBP 格式 , 图片不超过 5 MB ; 上传后可点击预览 。 < / p > < / template > < / div > < div class = "ab-materials" > < div > < ol > < li > 1. 使用Excel / WPS绘图功能 , 自主完成AB测试结果对比图 , 参考 《 实训操作说明 》 。 < / li > < li > 2. 下载Excel模版 , 填写关键信息 , 自动计算AB测试数据 , 并生成测试结果对比图 。 < / li > < li > 3. 导出数据表上传到通用AI大模型 , 通过AI大模型辅助完成 , 参考步骤及提示词如下 : 请根据表格数据 , 进行对照组和实验组数据对比 , 采用组合柱状图生成可视化图形 。 < / li > < / ol > < / div > < div class = "ab-materials__actions" > < button type = "button" class = "secondary-button" @click ="downloadAbGuide" > 下 载 实 训 操 作 说 明 < / button > < button type = "button" class = "secondary-button" @click ="downloadAbTemplate" > 下 载 Excel 模 板 < / button > < / div > < / div > < / section >
< section class = "section-block" > < div class = "section-heading" > < span > ( 4 ) < / span > < div > < h3 > 诊断结论与迭代优化建议 < / h3 > < p > 结合全链路数据漏斗诊断 、 售后评论分析法及AB测试对照诊断 , 分析产品存在的问题 , 并提出具体的迭代优化建议 。 < / p > < / div > < / div > < el -input v -model = " iterationRecommendation " type = "textarea" :rows ="6" maxlength = "1200" show -word -limit placeholder = "请填写产品问题诊断结论及具体迭代优化建议" / > < / section >
< / template >
< div class = "actions step-navigation" > < button v-if ="currentStep > 1" type="button" class="secondary-button" :disabled="saving" @click="currentStep -= 1">上一步</button><button type="button" class="submit-button" :disabled="saving || uploading || analyzing" @click="saveCurrentProgress">保存当前进度</button><button v-if="currentStep < 3" type="button" class="submit-button" :disabled="saving || uploading || analyzing" @click="nextStep" > 下 一 步 < / button > < / div >
< / section >
< / main >
< TrainingAiSidebar : study -tip = " currentStep = = = 1 ? ' 先 从 案 例 资 料 提 取 各 渠 道 漏 斗 数 据 , 计 算 转 化 率 并 与 行 业 均 值 逐 层 对 比 。 ' : currentStep = = = 2 ? ' 先 导 入 案 例 评 论 数 据 生 成 词 云 , 再 点 击 情 感 倾 向 分 析 , 并 用 高 频 问 题 与 情 感 占 比 解 释 漏 斗 异 常 。 ' : ' 基 于 前 两 步 结 论 设 计 随 机 分 流 的 AB 测 试 , 用 五 项 指 标 的 组 间 差 异 验 证 改 进 方 案 。 ' " :progress-items ="progressItems" / >
< / div >
< / template >
< script setup >
import { computed , getCurrentInstance , nextTick , onBeforeUnmount , onMounted , ref } from "vue" ;
import { DataAnalysis } from "@element-plus/icons-vue" ;
import * as echarts from "echarts" ;
import "echarts-wordcloud" ;
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue" ;
import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue" ;
import { getTrainingTaskByKey } from "@/api/trainingTask" ;
import { analyzeProductDiagnosisIterationStepTwo , deleteStudentTrainingAnswer , getStudentTrainingAnswer , saveStudentTrainingAnswer , uploadStudentTrainingFile , validateProductDiagnosisIterationStepOne , validateProductDiagnosisIterationStepTwo , validateProductDiagnosisIterationStepThree } from "@/api/studentTrainingAnswer" ;
import { parseTrainingArray } from "@/views/training/taskKeyMap" ;
import { isStudentDemo } from "@/utils/studentDemo" ;
const NODE _GUIDE _URL = "/file/training-materials/product-diagnosis-iteration/23.%E5%AE%9E%E8%AE%AD%E6%93%8D%E4%BD%9C%E8%AF%B4%E6%98%8E1.docx" ;
const NODE _TEMPLATE _URL = "/file/training-materials/product-diagnosis-iteration/23.Excel%E6%A8%A1%E7%89%881.xlsx" ;
const FUNNEL _GUIDE _URL = "/file/training-materials/product-diagnosis-iteration/23.%E5%AE%9E%E8%AE%AD%E6%93%8D%E4%BD%9C%E8%AF%B4%E6%98%8E2.docx" ;
const FUNNEL _TEMPLATE _URL = "/file/training-materials/product-diagnosis-iteration/23.Excel%E6%A8%A1%E7%89%882.xlsx" ;
const AB _TEST _GUIDE _URL = "/file/training-materials/product-diagnosis-iteration/23.%E5%AE%9E%E8%AE%AD%E6%93%8D%E4%BD%9C%E8%AF%B4%E6%98%8E3.docx" ;
const AB _TEST _TEMPLATE _URL = "/file/training-materials/product-diagnosis-iteration/23.Excel%E6%A8%A1%E7%89%883.xlsx" ;
function downloadBuiltInFile ( url ) { window . open ( url , "_blank" , "noopener" ) ; }
function downloadNodeGuide ( ) { downloadBuiltInFile ( NODE _GUIDE _URL ) ; }
function downloadNodeTemplate ( ) { downloadBuiltInFile ( NODE _TEMPLATE _URL ) ; }
function downloadFunnelGuide ( ) { downloadBuiltInFile ( FUNNEL _GUIDE _URL ) ; }
function downloadFunnelTemplate ( ) { downloadBuiltInFile ( FUNNEL _TEMPLATE _URL ) ; }
function downloadAbGuide ( ) { downloadBuiltInFile ( AB _TEST _GUIDE _URL ) ; }
function downloadAbTemplate ( ) { downloadBuiltInFile ( AB _TEST _TEMPLATE _URL ) ; }
const briefOpen = ref ( false ) ;
const TASK _KEY = "product-diagnosis-iteration" ;
const nodes = [ "曝光-点击" , "点击-加购" , "加购-下单" , "下单-好评" , "好评-复购" ] ;
const defaultChannels = [ { id : "tmall" , name : "天猫" } , { id : "jd" , name : "京东" } , { id : "douyin" , name : "抖音" } , { id : "pinduoduo" , name : "拼多多" } ] ;
let channelSerial = 0 ;
const { proxy } = getCurrentInstance ( ) ; const taskConfig = ref ( null ) ; const currentStep = ref ( 1 ) ; const saving = ref ( false ) ; const uploading = ref ( false ) ; const analyzing = ref ( false ) ; const chartInput = ref ( null ) ; const commentInput = ref ( null ) ; const abChartInput = ref ( null ) ; const wordCloudRef = ref ( null ) ; let wordCloudChart = null ;
const channels = ref ( createDefaultChannels ( ) ) ;
const conversionRates = ref ( createConversionRates ( ) ) ; const anomalyRows = ref ( [ createAnomalyRow ( ) ] ) ; const funnelChart = ref ( { url : "" , name : "" } ) ; const analysisResult = ref ( null ) ; const commentPreview = ref ( { fileName : "" , totalComments : 0 , comments : [ ] } ) ; const wordCloud = ref ( [ ] ) ; const topIssues = ref ( [ "" , "" , "" ] ) ; const sentiments = ref ( createSentiments ( ) ) ; const rootCauseAnalysis = ref ( "" ) ; const abTestPlan = ref ( "" ) ; const abRows = ref ( createAbRows ( ) ) ; const abChart = ref ( { url : "" , name : "" } ) ; const iterationRecommendation = ref ( "" ) ;
const taskTitle = computed ( ( ) => taskConfig . value ? . taskName || "产品问题诊断与迭代" ) ; const taskBackground = computed ( ( ) => taskConfig . value ? . background || "从曝光到复购的完整数据漏斗与用户评价出发,定位异常流失并形成迭代方向。" ) ; const taskRequirement = computed ( ( ) => taskConfig . value ? . requirements || "完成转化漏斗诊断、用户评论词云和情感根因分析。" ) ; const taskGoals = computed ( ( ) => parseTrainingArray ( taskConfig . value ? . objectives , [ "掌握全链路转化率计算方法" , "能够将店铺数据与行业均值进行对比" , "从用户评价中定位问题根因" ] ) ) ;
const availableChannels = computed ( ( ) => channels . value . filter ( ( channel ) => channel . name . trim ( ) ) ) ;
const funnelRatesComplete = computed ( ( ) => {
const names = availableChannels . value . map ( ( channel ) => channel . name . trim ( ) ) ;
if ( ! channels . value . length || names . length !== channels . value . length || new Set ( names ) . size !== names . length ) return false ;
return conversionRates . value . every ( ( row ) => isRate ( row . industryAverage ) && channels . value . every ( ( channel ) => isRate ( row . channelRates [ channel . id ] ) ) ) ;
} ) ;
const progressItems = computed ( ( ) => currentStep . value === 1 ? [ { text : "填写五个节点的渠道转化率" , status : funnelRatesComplete . value ? "done" : "doing" } , { text : "上传节点转化漏斗对比图" , status : funnelChart . value . url ? "done" : "" } , { text : "完成异常节点分析" , status : anomalyRows . value . every ( ( row ) => row . anomalyAnalysis ? . trim ( ) ) ? "done" : "" } ] : currentStep . value === 2 ? [ { text : "导入评论数据并生成词云" , status : analysisResult . value ? "done" : "doing" } , { text : "完成情感倾向分析" , status : sentiments . value . every ( ( row ) => row . count !== null ) ? "done" : "" } , { text : "填写根因分析" , status : rootCauseAnalysis . value . trim ( ) ? "done" : "" } ] : [ { text : "设计 AB 测试方案" , status : abTestPlan . value . trim ( ) ? "done" : "doing" } , { text : "填写五项对照数据" , status : abRows . value . every ( ( row ) => row . controlValue !== null && row . experimentValue !== null && row . analysis ? . trim ( ) ) ? "done" : "" } , { text : "上传 AB 对比图" , status : abChart . value . url ? "done" : "" } , { text : "提交迭代优化建议" , status : iterationRecommendation . value . trim ( ) ? "done" : "" } ] ) ;
function createDefaultChannels ( ) { return defaultChannels . map ( ( channel ) => ( { ... channel } ) ) ; }
function createConversionRates ( channelList = channels . value ) { return nodes . map ( ( linkNode ) => ( { linkNode , channelRates : Object . fromEntries ( channelList . map ( ( channel ) => [ channel . id , null ] ) ) , industryAverage : null } ) ) ; }
function createAnomalyRow ( ) { return { channelId : "" , channel : "" , linkNode : "" , storeConversionRate : null , industryAverageConversionRate : null , anomalyAnalysis : "" } ; }
function createSentiments ( ) { return [ "正面" , "中性" , "负面" ] . map ( ( type ) => ( { type , count : null , percentage : null } ) ) ; }
function createAbRows ( ) { return [ "点击-加购转化率" , "加购-下单转化率" , "下单-好评转化率" , "详情页跳出率" , "退货率" ] . map ( ( metric ) => ( { metric , controlValue : null , experimentValue : null , analysis : "" } ) ) ; }
function isRate ( value ) { return value !== null && value !== "" && Number . isFinite ( Number ( value ) ) ; }
function diagnosisErrorMessage ( error , fallback ) {
const serverMessage = String ( error ? . response ? . data ? . msg || "" ) . trim ( ) ;
const message = serverMessage || String ( error ? . message || "" ) . trim ( ) ;
return /Request failed with status code|系统接口\d{3}异常/i . test ( message ) ? fallback : message || fallback ;
}
function buildStepOne ( ) {
const channelSnapshot = channels . value . map ( ( channel ) => ( { id : channel . id , name : channel . name . trim ( ) } ) ) ;
return {
channels : channelSnapshot ,
conversionRates : conversionRates . value . map ( ( row ) => ( { linkNode : row . linkNode , channelRates : { ... row . channelRates } , industryAverage : row . industryAverage } ) ) ,
funnelChartUrl : funnelChart . value . url ,
funnelChartName : funnelChart . value . name ,
anomalyRows : anomalyRows . value . map ( ( row ) => ( { ... row , channel : channelSnapshot . find ( ( channel ) => channel . id === row . channelId ) ? . name || row . channel || "" } ) ) ,
} ;
}
function buildStepTwo ( ) {
return {
sourceFileName : commentPreview . value . fileName ,
totalComments : commentPreview . value . totalComments ,
topIssues : [ ... topIssues . value ] ,
sentiments : sentiments . value . map ( ( row ) => ( { ... row } ) ) ,
rootCauseAnalysis : rootCauseAnalysis . value ,
wordCloud : wordCloud . value ,
previewComments : commentPreview . value . comments ,
analysisResult : analysisResult . value ? { ... analysisResult . value } : null ,
} ;
}
function buildStepTwoValidationPayload ( stepTwo = buildStepTwo ( ) ) {
return {
sourceFileName : stepTwo . sourceFileName ,
topIssues : stepTwo . topIssues ,
sentiments : stepTwo . sentiments ,
rootCauseAnalysis : stepTwo . rootCauseAnalysis ,
} ;
}
function buildStepThree ( ) { return { testPlan : abTestPlan . value , rows : abRows . value . map ( ( row ) => ( { ... row } ) ) , comparisonChartUrl : abChart . value . url , comparisonChartName : abChart . value . name , iterationRecommendation : iterationRecommendation . value } ; }
function addFunnelChannel ( ) {
channelSerial += 1 ;
const channel = { id : ` channel- ${ Date . now ( ) } - ${ channelSerial } ` , name : "" } ;
channels . value . push ( channel ) ;
conversionRates . value . forEach ( ( row ) => { row . channelRates [ channel . id ] = null ; } ) ;
}
function removeFunnelChannel ( channelId ) {
channels . value = channels . value . filter ( ( channel ) => channel . id !== channelId ) ;
conversionRates . value . forEach ( ( row ) => { delete row . channelRates [ channelId ] ; } ) ;
anomalyRows . value . forEach ( ( row ) => {
if ( row . channelId === channelId ) {
row . channelId = "" ;
row . channel = "" ;
row . storeConversionRate = null ;
}
} ) ;
}
function syncAnomalyChannel ( row ) { row . channel = channels . value . find ( ( channel ) => channel . id === row . channelId ) ? . name . trim ( ) || "" ; }
function addAnomalyRow ( ) { if ( anomalyRows . value . length < 5 ) anomalyRows . value . push ( createAnomalyRow ( ) ) ; }
function removeAnomalyRow ( ) { if ( anomalyRows . value . length > 1 ) anomalyRows . value . pop ( ) ; }
function clearChart ( ) { if ( funnelChart . value . url . startsWith ( "blob:" ) ) URL . revokeObjectURL ( funnelChart . value . url ) ; funnelChart . value = { url : "" , name : "" } ; }
async function handleChartUpload ( 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 ( "上传成功但未返回图片地址" ) ; clearChart ( ) ; funnelChart . value = { url , name : file . name } ; proxy ? . $modal ? . msgSuccess ? . ( "漏斗对比图上传成功,可点击图片预览" ) ; } catch ( error ) { proxy ? . $modal ? . msgError ? . ( error ? . message || "图片上传失败,请重试" ) ; } finally { uploading . value = false ; event . target . value = "" ; } }
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 = "" ; } }
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 ;
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 ( buildStepTwoValidationPayload ( 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 ? . ( diagnosisErrorMessage ( error , "请检查词云、情感占比和根因分析" ) ) ; return false ; } finally { saving . value = false ; } }
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 saveStepThreeDraft ( ) { if ( saving . value ) return ; saving . value = true ; try { await saveStudentTrainingAnswer ( TASK _KEY , { step3Answer : JSON . stringify ( buildStepThree ( ) ) , currentStep : 3 , submitted : false , saveAction : "SAVE" } ) ; proxy ? . $modal ? . msgSuccess ? . ( "草稿已保存" ) ; } catch ( error ) { proxy ? . $modal ? . msgError ? . ( error ? . message || "保存失败,请重试" ) ; } finally { saving . value = false ; } }
async function saveCurrentProgress ( ) {
if ( currentStep . value === 1 ) return persistStepOne ( true ) ;
if ( currentStep . value === 2 ) return saveStepTwoDraft ( ) ;
return saveStepThreeDraft ( ) ;
}
async function nextStep ( ) {
if ( currentStep . value === 1 ) return validateStepOne ( ) ;
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 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 ( buildStepTwoValidationPayload ( 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 ? . ( diagnosisErrorMessage ( error , "提交失败,请检查三个步骤的填写内容" ) ) ;
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 ;
channels . value = createDefaultChannels ( ) ; 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 restoreStepOne ( stepOne ) {
const hasSavedChannels = Array . isArray ( stepOne . channels ) ;
const seenIds = new Set ( ) ;
const restoredChannels = hasSavedChannels
? stepOne . channels . map ( ( channel ) => ( { id : String ( channel ? . id || "" ) . trim ( ) , name : String ( channel ? . name || "" ) . trim ( ) } ) ) . filter ( ( channel ) => {
if ( ! channel . id || seenIds . has ( channel . id ) ) return false ;
seenIds . add ( channel . id ) ;
return true ;
} )
: createDefaultChannels ( ) ;
channels . value = restoredChannels ;
if ( Array . isArray ( stepOne . conversionRates ) && stepOne . conversionRates . length === nodes . length ) {
conversionRates . value = nodes . map ( ( linkNode ) => {
const saved = stepOne . conversionRates . find ( ( row ) => row ? . linkNode === linkNode ) || { } ;
return {
linkNode ,
channelRates : Object . fromEntries ( restoredChannels . map ( ( channel ) => [ channel . id , saved . channelRates ? . [ channel . id ] ? ? saved [ channel . id ] ? ? null ] ) ) ,
industryAverage : saved . industryAverage ? ? null ,
} ;
} ) ;
} else {
conversionRates . value = createConversionRates ( restoredChannels ) ;
}
if ( Array . isArray ( stepOne . anomalyRows ) && stepOne . anomalyRows . length ) {
anomalyRows . value = stepOne . anomalyRows . slice ( 0 , 5 ) . map ( ( row ) => {
const channelId = String ( row ? . channelId || restoredChannels . find ( ( channel ) => channel . name === row ? . channel ) ? . id || "" ) ;
return { ... createAnomalyRow ( ) , ... row , channelId } ;
} ) ;
}
if ( stepOne . funnelChartUrl ) funnelChart . value = { url : stepOne . funnelChartUrl , name : stepOne . funnelChartName || "节点转化漏斗对比图" } ;
}
function parseSavedStep ( value ) {
if ( ! value ) return { } ;
if ( typeof value === "object" ) return value ;
try { return JSON . parse ( value ) ; } catch ( error ) { return { } ; }
}
function restoreStepTwo ( stepTwo ) {
if ( ! stepTwo || typeof stepTwo !== "object" || ! Object . keys ( stepTwo ) . length ) return ;
const savedAnalysis = stepTwo . analysisResult && typeof stepTwo . analysisResult === "object" ? stepTwo . analysisResult : { } ;
const restoredWordCloud = Array . isArray ( stepTwo . wordCloud ) ? stepTwo . wordCloud : Array . isArray ( savedAnalysis . wordCloud ) ? savedAnalysis . wordCloud : [ ] ;
const restoredComments = Array . isArray ( stepTwo . previewComments ) ? stepTwo . previewComments : Array . isArray ( savedAnalysis . previewComments ) ? savedAnalysis . previewComments : [ ] ;
const restoredTopIssues = [ 0 , 1 , 2 ] . map ( ( index ) => stepTwo . topIssues ? . [ index ] || savedAnalysis . topIssues ? . [ index ] || "" ) ;
const savedSentiments = Array . isArray ( stepTwo . sentiments ) ? stepTwo . sentiments : [ ] ;
sentiments . value = createSentiments ( ) . map ( ( row ) => ( { ... row , ... ( savedSentiments . find ( ( item ) => item ? . type === row . type ) || { } ) } ) ) ;
const count = ( type , analysisKey ) => {
const analysisCount = Number ( savedAnalysis [ analysisKey ] ) ;
if ( Number . isFinite ( analysisCount ) ) return analysisCount ;
const sentimentCount = Number ( sentiments . value . find ( ( row ) => row . type === type ) ? . count ) ;
return Number . isFinite ( sentimentCount ) ? sentimentCount : 0 ;
} ;
const positiveCount = count ( "正面" , "positiveCount" ) ;
const neutralCount = count ( "中性" , "neutralCount" ) ;
const negativeCount = count ( "负面" , "negativeCount" ) ;
const sourceFileName = String ( stepTwo . sourceFileName || savedAnalysis . sourceFileName || "" ) ;
const inferredTotal = positiveCount + neutralCount + negativeCount ;
const savedTotal = Number ( stepTwo . totalComments ? ? savedAnalysis . totalComments ) ;
const totalComments = Number . isFinite ( savedTotal ) ? savedTotal : inferredTotal ;
commentPreview . value = { fileName : sourceFileName , totalComments , comments : restoredComments } ;
wordCloud . value = restoredWordCloud ;
topIssues . value = restoredTopIssues ;
rootCauseAnalysis . value = stepTwo . rootCauseAnalysis || "" ;
if ( sourceFileName || restoredWordCloud . length || restoredComments . length || restoredTopIssues . some ( Boolean ) ) {
analysisResult . value = { sourceFileName , totalComments , positiveCount , neutralCount , negativeCount , topIssues : restoredTopIssues , wordCloud : restoredWordCloud , previewComments : restoredComments } ;
}
nextTick ( renderWordCloud ) ;
}
function restore ( answer ) {
const stepOne = parseSavedStep ( answer ? . step1Answer ) ;
restoreStepOne ( stepOne ) ;
restoreStepTwo ( parseSavedStep ( answer ? . step2Answer ) ) ;
const stepThree = parseSavedStep ( 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 ;
}
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 >
< style scoped lang = "scss" >
/* ===== 页面级令牌(墨黑极简主题) ===== */
. diagnosis - page {
-- student - accent : # 1 f2937 ;
-- student - accent - deep : # 111827 ;
-- student - accent - light : # 4 b5563 ;
-- student - accent - soft : # f3f4f6 ;
-- student - danger : # d9554c ;
-- student - success : # 4 a9d6e ;
-- student - layer - 1 : # 374151 ;
-- student - layer - 2 : # 43 a294 ;
-- student - layer - 3 : # 8 b6fc0 ;
-- student - panel - border : # e5e7eb ;
-- student - card - bg : # ffffff ;
-- student - panel - bg : # fafbfc ;
-- student - text : # 1 f2937 ;
-- student - text - secondary : # 6 b7280 ;
-- student - fs - display : 17 px ;
-- student - fs - h1 : 15 px ;
-- student - fs - h2 : 14 px ;
-- student - fs - h3 : 13 px ;
-- student - fs - body : 13 px ;
-- student - fs - body - sm : 12 px ;
-- student - fs - caption : 11 px ;
-- student - fs - button : 13 px ;
-- student - fs - button - lg : 13 px ;
-- student - fs - data : 14 px ;
/* 不设 grid-template-columns, 由全局 .student-training-shell 单列布局接管 */
min - width : 0 ;
color : var ( -- student - text ) ;
background : # ffffff ;
padding : 24 px ;
}
/* 对齐 foundation 的 .training-core: 只设 gap, 无边框/背景/阴影 */
. diagnosis - core {
display : grid ;
gap : 10 px ;
align - content : start ;
min - width : 0 ;
padding : 0 ;
border : 0 ;
background : transparent ;
box - shadow : none ;
}
/* 任务头:独立卡片 */
. task - header {
display : flex ;
align - items : flex - start ;
justify - content : space - between ;
gap : 12 px ;
margin : 0 ;
padding : 16 px ;
border : 1 px solid # e7e9ed ;
border - radius : 16 px ;
background : # ffffff ;
}
. task - head - main {
display : grid ;
justify - items : start ;
gap : 8 px ;
min - width : 0 ;
flex : 1 1 auto ;
}
. task - badge {
display : inline - flex ;
align - items : center ;
gap : 7 px ;
padding : 4 px 12 px ;
border : 1 px solid var ( -- student - panel - border ) ;
border - radius : 999 px ;
color : var ( -- student - accent - deep ) ;
background : var ( -- student - accent - soft ) ;
font - size : 12 px ;
font - weight : 700 ;
}
. task - title {
margin : 0 ;
color : var ( -- student - text ) ;
font - size : var ( -- student - fs - display ) ;
font - weight : 700 ;
line - height : 1.35 ;
}
. task - brief {
margin : 0 ;
color : var ( -- student - text - secondary ) ;
font - size : 12 px ;
line - height : 1.6 ;
}
. task - brief - text {
display : - webkit - box ;
overflow : hidden ;
- webkit - box - orient : vertical ;
- webkit - line - clamp : 1 ;
}
. task - brief - toggle {
padding : 0 ;
border : 0 ;
color : var ( -- student - accent - deep ) ;
background : transparent ;
font : inherit ;
font - weight : 700 ;
cursor : pointer ;
& : hover { text - decoration : underline ; }
}
. task - brief - more {
display : grid ;
gap : 6 px ;
margin - top : 8 px ;
padding - top : 8 px ;
border - top : 1 px solid var ( -- student - panel - border ) ;
b { color : var ( -- student - text ) ; font - size : 12 px ; }
ul { display : grid ; gap : 4 px ; margin : 0 ; padding - left : 18 px ; }
p { margin : 0 ; }
}
. diagnosis - toolbar {
flex : 0 0 auto ;
justify - content : flex - end ;
max - width : 100 % ;
align - self : center ;
}
/* workbench: 透明容器, 去掉旧的 border/bg */
. workbench {
display : grid ;
gap : 10 px ;
padding : 0 ;
border : 0 ;
border - radius : 0 ;
background : transparent ;
margin - top : 0 ;
}
/* lead 描述 */
. lead {
margin : 0 0 14 px ;
color : var ( -- student - text - secondary ) ;
font - size : var ( -- student - fs - body ) ;
line - height : 1.6 ;
}
/* section-block: 透明化 */
. section - block {
margin - top : 14 px ;
padding : 0 ;
border : 0 ;
border - radius : 0 ;
background : transparent ;
}
. section - heading {
display : flex ;
align - items : flex - start ;
gap : 12 px ;
margin - bottom : 10 px ;
> span {
display : grid ;
flex : 0 0 28 px ;
width : 28 px ;
height : 28 px ;
place - items : center ;
border - radius : 6 px ;
color : # ffffff ;
background : var ( -- student - accent ) ;
font - size : 13 px ;
font - weight : 700 ;
}
h3 {
margin : 0 ;
color : var ( -- student - text ) ;
font - size : var ( -- student - fs - h1 ) ;
font - weight : 700 ;
}
p {
margin : 4 px 0 0 ;
color : var ( -- student - text - secondary ) ;
font - size : var ( -- student - fs - body ) ;
line - height : 1.6 ;
}
}
/* ===== 按钮(墨黑极简) ===== */
. secondary - button ,
. table - button ,
. upload - button ,
. submit - button {
display : inline - flex ;
align - items : center ;
justify - content : center ;
gap : 6 px ;
min - height : 40 px ;
padding : 0 18 px ;
border : 1 px solid rgba ( 31 , 41 , 55 , 0.4 ) ;
border - radius : 10 px ;
color : var ( -- student - text ) ;
background : # ffffff ;
font : inherit ;
font - size : var ( -- student - fs - button ) ;
font - weight : 700 ;
cursor : pointer ;
text - shadow : none ;
transition : border - color 0.16 s ease , background 0.16 s ease , color 0.16 s ease , transform 0.16 s ease , box - shadow 0.16 s ease ;
& : hover : not ( : disabled ) {
border - color : var ( -- student - accent ) ;
background : var ( -- student - accent - soft ) ;
color : var ( -- student - accent ) ;
transform : translateY ( - 1 px ) ;
box - shadow : 0 3 px 10 px rgba ( 31 , 41 , 55 , 0.12 ) ;
}
}
. submit - button {
border - color : var ( -- student - accent ) ;
color : # ffffff ;
background : var ( -- student - accent ) ;
font - weight : 700 ;
box - shadow : 0 3 px 10 px rgba ( 31 , 41 , 55 , 0.28 ) ;
& : hover : not ( : disabled ) {
border - color : var ( -- student - accent - deep ) ;
background : var ( -- student - accent - deep ) ;
color : # ffffff ;
transform : translateY ( - 1 px ) ;
box - shadow : 0 5 px 14 px rgba ( 17 , 24 , 39 , 0.32 ) ;
}
}
. table - button . ghost {
border - color : rgba ( 31 , 41 , 55 , 0.3 ) ;
color : var ( -- student - text - secondary ) ;
background : # ffffff ;
font - weight : 600 ;
}
button : disabled ,
button . is - disabled {
cursor : not - allowed ;
opacity : 0.5 ;
transform : none ;
box - shadow : none ;
}
/* ===== 表格 ===== */
. table - wrap {
overflow - x : auto ;
border : 1 px solid var ( -- student - panel - border ) ;
border - radius : 10 px ;
}
. funnel - table ,
. diagnosis - table ,
. sentiment - table ,
. ab - table {
width : 100 % ;
border - collapse : collapse ;
}
. funnel - table { min - width : 900 px ; }
. diagnosis - table { min - width : 1060 px ; }
. sentiment - table { min - width : 640 px ; }
. ab - table { min - width : 860 px ; }
. funnel - table th , . funnel - table td ,
. diagnosis - table th , . diagnosis - table td ,
. sentiment - table th , . sentiment - table td ,
. ab - table th , . ab - table td {
padding : 10 px 12 px ;
border - right : 1 px solid var ( -- student - panel - border ) ;
border - bottom : 1 px solid var ( -- student - panel - border ) ;
vertical - align : middle ;
text - align : center ;
}
. funnel - table thead th ,
. diagnosis - table thead th ,
. sentiment - table thead th ,
. ab - table thead th {
color : var ( -- student - text ) ;
background : # fafbfc ;
font - size : var ( -- student - fs - body - sm ) ;
font - weight : 800 ;
white - space : nowrap ;
}
. funnel - table tbody th ,
. sentiment - table tbody th ,
. ab - table tbody th {
color : var ( -- student - text ) ;
background : transparent ;
font - weight : 700 ;
text - align : center ;
}
. funnel - table td ,
. diagnosis - table td ,
. sentiment - table td ,
. ab - table td {
background : transparent ;
}
. ab - table thead th { padding : 12 px 10 px ; font - size : var ( -- student - fs - h2 ) ; }
. ab - table tbody th { width : 25 % ; padding : 12 px 10 px ; }
. ab - table td { padding : 10 px 11 px ; }
. ab - table td : nth - child ( 2 ) , . ab - table td : nth - child ( 3 ) { width : 18 % ; }
. ab - table td : last - child { width : 39 % ; }
/* 表格内组件 */
. funnel - table : deep ( . el - input - number ) ,
. diagnosis - table : deep ( . el - input - number ) ,
. sentiment - table : deep ( . el - input - number ) ,
. diagnosis - table : deep ( . el - select ) { width : 100 % ; }
. unit {
margin - left : 5 px ;
color : var ( -- student - text - secondary ) ;
font - size : 12 px ;
font - weight : 700 ;
}
. auto - count {
color : var ( -- student - accent ) ;
font - size : 16 px ;
font - weight : 800 ;
}
. funnel - channel - toolbar > span { display : block ; }
. funnel - table . fixed - column ,
. funnel - table tbody th { min - width : 145 px ; }
. funnel - table . channel - column ,
. funnel - table tbody td { min - width : 180 px ; }
. channel - heading {
display : flex ;
align - items : center ;
gap : 7 px ;
: deep ( . el - input ) { min - width : 120 px ; }
: deep ( . el - input _ _wrapper ) {
background : # ffffff ;
box - shadow : 0 0 0 1 px var ( -- student - panel - border ) inset ;
}
: deep ( . el - input _ _inner ) {
color : var ( -- student - text ) ;
text - align : center ;
font - weight : 600 ;
}
}
. channel - remove {
display : inline - grid ;
flex : 0 0 24 px ;
width : 24 px ;
height : 24 px ;
place - items : center ;
padding : 0 ;
border : 1 px solid rgba ( 217 , 85 , 76 , 0.4 ) ;
border - radius : 6 px ;
color : var ( -- student - danger ) ;
background : rgba ( 217 , 85 , 76 , 0.08 ) ;
font : inherit ;
font - size : 17 px ;
line - height : 1 ;
cursor : pointer ;
& : hover , & : focus - visible {
border - color : var ( -- student - danger ) ;
color : # ffffff ;
background : var ( -- student - danger ) ;
outline : none ;
}
}
. rate - input {
display : flex ;
align - items : center ;
gap : 6 px ;
width : 100 % ;
white - space : nowrap ;
: deep ( . el - input - number ) {
flex : 1 1 auto ;
width : calc ( 100 % - 22 px ) ;
min - width : 0 ;
}
. unit {
display : inline - flex ;
flex : 0 0 auto ;
align - items : center ;
height : 32 px ;
margin - left : 0 ;
white - space : nowrap ;
}
}
. table - toolbar {
display : flex ;
align - items : center ;
justify - content : space - between ;
gap : 14 px ;
margin : 0 0 12 px ;
color : var ( -- student - text - secondary ) ;
font - size : var ( -- student - fs - body ) ;
> div ,
. actions ,
. diagnosis - materials _ _actions ,
. ab - materials _ _actions {
display : flex ;
flex - wrap : wrap ;
gap : 8 px ;
}
}
. actions {
justify - content : center ;
margin - top : 20 px ;
. secondary - button ,
. submit - button {
min - width : 142 px ;
min - height : 40 px ;
}
}
/* ===== 参考资料区 ===== */
. diagnosis - materials {
display : grid ;
gap : 10 px ;
margin : 14 px 0 ;
& _ _item {
display : flex ;
align - items : center ;
justify - content : space - between ;
gap : 20 px ;
padding : 12 px 14 px ;
border : 1 px solid var ( -- student - panel - border ) ;
border - radius : 10 px ;
background : var ( -- student - accent - soft ) ;
strong , span { display : block ; }
strong {
color : var ( -- student - text ) ;
font - size : 13 px ;
font - weight : 600 ;
}
span {
margin - top : 4 px ;
color : var ( -- student - text - secondary ) ;
font - size : 12 px ;
}
}
& _ _ai {
margin : 0 ;
padding : 10 px 14 px ;
border - left : 3 px solid var ( -- student - accent ) ;
background : var ( -- student - accent - soft ) ;
color : var ( -- student - text - secondary ) ;
font - size : 13 px ;
line - height : 1.7 ;
border - radius : 0 8 px 8 px 0 ;
}
& _ _actions {
display : flex ;
flex - wrap : wrap ;
justify - content : flex - end ;
gap : 8 px ;
}
}
. diagnosis - section . table - toolbar > span { display : none ; }
. diagnosis - step - one - notice { display : none ; }
@ media ( max - width : 860 px ) {
. diagnosis - materials _ _item {
align - items : flex - start ;
flex - direction : column ;
}
. diagnosis - materials _ _actions { justify - content : flex - start ; }
}
/* ===== AB 测试资料区 ===== */
. ab - materials {
display : flex ;
align - items : flex - start ;
justify - content : space - between ;
gap : 20 px ;
margin - top : 14 px ;
padding : 12 px 14 px ;
border - left : 3 px solid var ( -- student - accent ) ;
background : var ( -- student - accent - soft ) ;
border - radius : 0 8 px 8 px 0 ;
color : var ( -- student - text - secondary ) ;
ol {
margin : 0 ;
padding - left : 20 px ;
line - height : 1.75 ;
font - size : var ( -- student - fs - body ) ;
}
& _ _actions {
display : flex ;
flex - wrap : wrap ;
flex : 0 0 auto ;
gap : 8 px ;
}
}
@ media ( max - width : 860 px ) {
. ab - materials {
align - items : stretch ;
flex - direction : column ;
}
}
/* ===== 上传区 ===== */
. chart - upload - area ,
. comment - import {
display : grid ;
place - items : center ;
gap : 10 px ;
min - height : 180 px ;
padding : 22 px ;
border : 1 px dashed var ( -- student - panel - border ) ;
border - radius : 12 px ;
background : var ( -- student - panel - bg ) ;
text - align : center ;
p {
margin : 0 ;
color : var ( -- student - text - secondary ) ;
font - size : var ( -- student - fs - body - sm ) ;
}
}
. upload - button {
min - height : 42 px ;
padding : 0 18 px ;
}
. chart - preview {
display : flex ;
align - items : center ;
gap : 16 px ;
width : 100 % ;
text - align : left ;
: deep ( . el - image ) {
width : 180 px ;
height : 110 px ;
border : 1 px solid var ( -- student - panel - border ) ;
border - radius : 8 px ;
}
> div {
display : grid ;
gap : 7 px ;
}
strong { color : var ( -- student - text ) ; }
span { color : var ( -- student - text - secondary ) ; font - size : var ( -- student - fs - body - sm ) ; }
button {
width : max - content ;
padding : 0 ;
border : 0 ;
color : var ( -- student - danger ) ;
background : transparent ;
font : inherit ;
cursor : pointer ;
& : hover { text - decoration : underline ; }
}
}
/* ===== 词云 / 数据预览 ===== */
. data - preview ,
. word - cloud - card ,
. top - issues ,
. root - cause {
margin - top : 14 px ;
padding : 14 px ;
border : 1 px solid var ( -- student - panel - border ) ;
border - radius : 10 px ;
background : # ffffff ;
}
. data - preview > div ,
. word - cloud - title {
display : flex ;
justify - content : space - between ;
gap : 12 px ;
color : var ( -- student - text ) ;
font - weight : 600 ;
span , small {
color : var ( -- student - text - secondary ) ;
font - size : var ( -- student - fs - body - sm ) ;
font - weight : 400 ;
}
}
. data - preview ul {
max - height : 130 px ;
margin : 12 px 0 0 ;
padding - left : 20 px ;
overflow : auto ;
color : var ( -- student - text ) ;
line - height : 1.7 ;
text - align : left ;
}
. word - cloud { height : 280 px ; }
. top - issues > p ,
. root - cause > p {
margin : 0 0 10 px ;
color : var ( -- student - text ) ;
font - size : var ( -- student - fs - body ) ;
line - height : 1.6 ;
}
. top - issues > div {
display : grid ;
grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr ) ) ;
gap : 12 px ;
}
. top - issues label {
display : grid ;
gap : 6 px ;
color : var ( -- student - text ) ;
font - weight : 600 ;
font - size : var ( -- student - fs - body - sm ) ;
}
. sentiment - actions {
display : flex ;
align - items : center ;
gap : 14 px ;
margin - bottom : 14 px ;
> span {
color : var ( -- student - text - secondary ) ;
font - size : var ( -- student - fs - body ) ;
}
}
. visual - section ,
. diagnosis - section { margin - top : 14 px ; }
/* ===== Element Plus 输入框换肤 ===== */
. diagnosis - page {
: deep ( . el - input _ _wrapper ) ,
: deep ( . el - textarea _ _inner ) {
background : transparent ;
box - shadow : 0 0 0 1 px var ( -- student - panel - border ) inset ;
}
: deep ( . el - input _ _inner ) ,
: deep ( . el - textarea _ _inner ) {
color : var ( -- student - text ) ;
}
: deep ( . el - textarea _ _inner ) {
line - height : 1.6 ;
}
: deep ( . el - input _ _wrapper . is - focus ) ,
: deep ( . el - textarea _ _inner : focus ) {
box - shadow : 0 0 0 1 px var ( -- student - accent ) inset , 0 0 0 2 px rgba ( 31 , 41 , 55 , 0.15 ) ! important ;
}
}
. visually - hidden {
position : absolute ;
width : 1 px ;
height : 1 px ;
overflow : hidden ;
clip : rect ( 0 0 0 0 ) ;
white - space : nowrap ;
clip - path : inset ( 50 % ) ;
}
/* ===== 响应式 ===== */
@ media ( max - width : 900 px ) {
. diagnosis - page { padding : 12 px ; }
. top - issues > div { grid - template - columns : 1 fr ; }
. chart - preview {
align - items : flex - start ;
flex - direction : column ;
}
. chart - preview : deep ( . el - image ) { width : 100 % ; height : 170 px ; }
}
@ media ( max - width : 720 px ) {
. task - header { flex - direction : column ; align - items : flex - start ; }
. diagnosis - toolbar { width : 100 % ; justify - content : flex - start ; }
. table - toolbar ,
. sentiment - actions {
align - items : stretch ;
flex - direction : column ;
}
. actions {
flex - direction : column ;
. secondary - button ,
. submit - button { width : 100 % ; }
}
}
< / style >
< style lang = "scss" >
/* ===== 步骤导航:按钮切换风 ===== */
# app . studentLayout section . app - main : has ( . diagnosis - page ) . step - indicator {
display : flex ! important ;
flex - wrap : nowrap ! important ;
align - items : stretch ! important ;
justify - content : center ! important ;
gap : 10 px ! important ;
width : 100 % ! important ;
margin : 4 px 0 16 px ! important ;
padding : 0 ! important ;
border : none ! important ;
border - radius : 0 ! important ;
background : transparent ! important ;
box - shadow : none ! important ;
. step - connector { display : none ! important ; }
. step - tab {
flex : 0 0 auto ! important ;
min - width : auto ! important ;
max - width : none ! important ;
display : flex ! important ;
flex - direction : row ! important ;
align - items : center ! important ;
justify - content : center ! important ;
gap : 8 px ! important ;
height : auto ! important ;
min - height : 42 px ! important ;
margin : 0 ! important ;
padding : 8 px 14 px ! important ;
border : 1 px solid # e5e7eb ! important ;
border - radius : 8 px ! important ;
background : # ffffff ! important ;
box - shadow : none ! important ;
color : # 6 b7280 ! important ;
font - size : 13 px ! important ;
font - weight : 600 ! important ;
line - height : 1.3 ! important ;
letter - spacing : 0 ! important ;
white - space : nowrap ! important ;
text - align : center ! important ;
cursor : pointer ;
transition : background - color 0.16 s ease , color 0.16 s ease , border - color 0.16 s ease ! important ;
. step - marker {
display : inline - flex ! important ;
align - items : center ! important ;
justify - content : center ! important ;
flex : 0 0 auto ! important ;
min - width : 22 px ! important ;
width : auto ! important ;
height : 22 px ! important ;
padding : 0 5 px ! important ;
border : none ! important ;
border - radius : 6 px ! important ;
background : # f3f4f6 ! important ;
color : # 6 b7280 ! important ;
font - family : ui - monospace , "SF Mono" , Consolas , monospace ! important ;
font - size : 12 px ! important ;
font - weight : 700 ! important ;
line - height : 1 ! important ;
transition : background - color 0.16 s ease , color 0.16 s ease ! important ;
}
. step - copy {
display : flex ! important ;
flex - direction : column ! important ;
gap : 0 ! important ;
min - width : 0 ! important ;
}
. step - name {
overflow : hidden ! important ;
text - overflow : ellipsis ! important ;
white - space : normal ! important ;
line - height : 1.3 ! important ;
color : inherit ! important ;
font - size : 13 px ! important ;
font - weight : 600 ! important ;
}
. step - action { display : none ! important ; }
/* 未到达项 hover */
& : hover : not ( . active ) : not ( . complete ) {
border - color : # 1 f2937 ! important ;
background : # f9fafb ! important ;
color : # 1 f2937 ! important ;
. step - marker { background : # 1 f2937 ! important ; color : # ffffff ! important ; }
}
/* 已完成 */
& . complete : not ( . active ) {
border - color : # dce9e2 ! important ;
color : # 374151 ! important ;
. step - marker { background : rgba ( 74 , 157 , 110 , 0.12 ) ! important ; color : # 3 f8a5f ! important ; }
& : hover {
border - color : # 4 a9d6e ! important ;
background : # f9fafb ! important ;
. step - marker { background : # 4 a9d6e ! important ; color : # ffffff ! important ; }
}
}
/* 当前步骤 */
& . active {
border - color : # 1 f2937 ! important ;
background : # 1 f2937 ! important ;
color : # ffffff ! important ;
font - weight : 700 ! important ;
. step - marker { background : # ffffff ! important ; color : # 1 f2937 ! important ; }
& : hover {
background : # 111827 ! important ;
border - color : # 111827 ! important ;
}
}
}
}
/* ===== 窄屏 ===== */
@ media ( max - width : 900 px ) {
# app . studentLayout section . app - main : has ( . diagnosis - page ) . step - indicator {
justify - content : flex - start ! important ;
gap : 8 px ! important ;
overflow - x : auto ! important ;
}
# app . studentLayout section . app - main : has ( . diagnosis - page ) . step - indicator . step - tab {
flex : 0 0 auto ! important ;
max - width : none ! important ;
}
}
/* ===== 顶部任务工具栏换肤 ===== */
. diagnosis - page . training - task - toolbar { gap : 8 px ; }
. diagnosis - page . task - toolbar - button . el - button ,
. diagnosis - page . material - button . el - button {
display : inline - flex ;
align - items : center ;
justify - content : center ;
gap : 6 px ;
min - height : 36 px ;
height : 36 px ;
padding : 0 14 px ;
border : 1 px solid # d1d5db ;
border - radius : 8 px ;
color : # 374151 ;
background : # ffffff ;
box - shadow : none ;
text - shadow : none ;
font - size : 13 px ;
font - weight : 600 ;
transition : border - color 0.16 s ease , background 0.16 s ease , color 0.16 s ease , box - shadow 0.16 s ease ;
. el - icon {
font - size : 14 px ;
color : inherit ;
}
& : hover , & : focus {
border - color : # 1 f2937 ;
color : # 1 f2937 ;
background : # f9fafb ;
box - shadow : 0 2 px 6 px rgba ( 17 , 24 , 39 , 0.1 ) ;
}
}
. diagnosis - page . task - toolbar - button -- submit . el - button {
border - color : # 1 f2937 ;
color : # ffffff ;
background : # 1 f2937 ;
box - shadow : 0 2 px 8 px rgba ( 17 , 24 , 39 , 0.22 ) ;
& : hover , & : focus {
border - color : # 111827 ;
color : # ffffff ;
background : # 111827 ;
box - shadow : 0 4 px 12 px rgba ( 17 , 24 , 39 , 0.28 ) ;
}
}
. diagnosis - page . task - toolbar - button . el - button . is - loading {
border - color : # d1d5db ;
color : # 374151 ;
background : # ffffff ;
}
< / style >