< template >
< div class = "diagnosis-page student-training-shell" >
< main class = "diagnosis-core" >
< section class = "task-header" > < span > 核心实训 · 产品上线与运营推广 < / span > < h1 > { { taskTitle } } < / h1 > < / section >
< TrainingTaskBrief :background ="taskBackground" :goals ="taskGoals" :requirements ="taskRequirement" :show-material ="false" :show-toolbar ="false" / >
< 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" :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 >
< 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 * as echarts from "echarts" ;
import "echarts-wordcloud" ;
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue" ;
import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue" ;
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.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 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 - materials { display : grid ; gap : 10 px ; margin : 14 px 0 } . diagnosis - materials _ _item { display : flex ; align - items : center ; justify - content : space - between ; gap : 20 px ; padding : 12 px 14 px ; border : 1 px solid rgba ( 76 , 202 , 242 , .32 ) ; border - radius : 10 px ; background : rgba ( 3 , 42 , 61 , .45 ) } . diagnosis - materials _ _item strong , . diagnosis - materials _ _item span { display : block } . diagnosis - materials _ _item strong { color : # e5faff ; font - size : 15 px } . diagnosis - materials _ _item span { margin - top : 4 px ; color : # 93 baca ; font - size : 13 px } . diagnosis - materials _ _actions { display : flex ; flex - wrap : wrap ; justify - content : flex - end ; gap : 8 px } . diagnosis - step - one - notice { margin : 12 px 0 0 ; color : # ff7178 ; font - size : 13 px ; line - height : 1.6 } @ media ( max - width : 860 px ) { . diagnosis - materials _ _item { align - items : flex - start ; flex - direction : column } . diagnosis - materials _ _actions { justify - content : flex - start } }
. diagnosis - materials _ _ai { margin : 0 ; padding : 3 px 2 px ; color : # cce8f2 ; font - size : 14 px ; line - height : 1.75 } . diagnosis - section . table - toolbar > span { display : none }
. diagnosis - step - one - notice { display : none }
. section - heading > span { flex : 0 0 auto ; min - width : 30 px ; width : auto ; padding : 0 5 px }
. step - tabs { padding : 18 px 22 px ; border - radius : 22 px } . step - tabs . step - tab { display : flex ; flex : 1 1 0 ; align - items : center ; min - width : 145 px ; min - height : 66 px ; gap : 10 px ; padding : 8 px 12 px ; border : 1 px solid rgba ( 68 , 141 , 177 , .35 ) ; border - radius : 12 px ; color : # 9 ab3d0 ; background : rgba ( 3 , 27 , 43 , .52 ) ; font : inherit ; text - align : left ; cursor : pointer ; transition : .2 s ease } . step - tabs . step - tab : hover { border - color : rgba ( 89 , 223 , 255 , .82 ) ; color : # fff ; background : rgba ( 8 , 92 , 131 , .52 ) ; transform : translateY ( - 1 px ) } . step - tabs . step - tab : focus - visible { outline : 2 px solid # 72 e8ff ; outline - offset : 3 px } . step - tabs . step - marker { display : grid ; flex : 0 0 38 px ; width : 38 px ; height : 38 px ; place - items : center ; border : 1 px solid rgba ( 101 , 176 , 208 , .6 ) ; border - radius : 50 % ; color : # a6c4d9 ; background : rgba ( 3 , 25 , 40 , .8 ) ; font - size : 12 px ; font - weight : 900 } . step - tabs . step - copy { display : grid ; min - width : 0 ; gap : 4 px } . step - tabs . step - copy b { overflow : hidden ; font - size : 14 px ; font - weight : 800 ; text - overflow : ellipsis ; white - space : nowrap } . step - tabs . step - copy small { color : # 66 b6d4 ; font - size : 10 px ; font - weight : 800 ; letter - spacing : .06 em } . step - tabs . step - tab . active { border - color : # 53 d8fb ; color : # fff ; background : linear - gradient ( 135 deg , rgba ( 7 , 156 , 207 , .85 ) , rgba ( 13 , 105 , 161 , .92 ) ) ; box - shadow : inset 0 0 24 px rgba ( 77 , 213 , 255 , .22 ) } . step - tabs . step - tab . active . step - marker { border - color : # 72 e8ff ; color : # fff ; background : linear - gradient ( 135 deg , # 08 b7e8 , # 1375 d0 ) ; box - shadow : 0 0 0 5 px rgba ( 25 , 179 , 237 , .14 ) , 0 0 18 px rgba ( 46 , 208 , 255 , .58 ) } . step - tabs . step - tab . complete { color : # dffbff } . step - tabs . step - tab . complete . step - marker { border - color : rgba ( 84 , 230 , 221 , .85 ) ; color : # 062 b37 ; background : # 6 ef0e1 } . step - tabs . step - connector { position : relative ; flex : 0 0 24 px ; height : 2 px ; background : rgba ( 86 , 198 , 235 , .48 ) } . step - tabs . step - connector : : after { position : absolute ; top : 50 % ; right : 0 ; width : 7 px ; height : 7 px ; border - top : 1 px solid # 73 b1ce ; border - right : 1 px solid # 73 b1ce ; content : '' ; transform : translateY ( - 50 % ) rotate ( 45 deg ) }
. step - tabs . step - copy b { display : block ; flex : none ; width : auto ; height : auto ; place - items : initial ; border : 0 ; border - radius : 0 ; color : inherit ; background : transparent ; box - shadow : none }
@ media ( max - width : 720 px ) { . step - tabs . step - connector { flex : 0 0 18 px ; width : 2 px ; height : 18 px ; margin - left : 18 px } . step - tabs . step - connector : : after { top : auto ; right : auto ; bottom : 0 ; left : 50 % ; transform : translateX ( - 50 % ) rotate ( 135 deg ) } }
. ab - materials { display : flex ; align - items : center ; justify - content : space - between ; gap : 20 px ; margin - top : 18 px ; padding : 14 px 16 px ; border - left : 3 px solid # 19 c9ff ; background : rgba ( 3 , 42 , 61 , .45 ) ; color : # cde9f2 } . ab - materials ol { margin : 0 ; padding - left : 20 px ; line - height : 1.8 } . ab - materials _ _actions { display : grid ; flex : 0 0 178 px ; gap : 8 px } @ media ( max - width : 860 px ) { . ab - materials { align - items : stretch ; flex - direction : column } . ab - materials _ _actions { grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; flex - basis : auto } }
. ab - table { width : 100 % ; min - width : 860 px ; border - collapse : collapse } . ab - table th , . ab - table td { border : 1 px solid rgba ( 139 , 213 , 241 , .45 ) } . ab - table thead th { padding : 14 px 10 px ; color : # fff ; background : # 079 ed9 ; font - size : 16 px } . ab - table tbody th { width : 25 % ; padding : 14 px 10 px ; color : # dff8ff ; background : rgba ( 6 , 67 , 94 , .34 ) ; text - align : center } . ab - table td { padding : 9 px 11 px ; text - align : center } . ab - table td : nth - child ( 2 ) , . ab - table td : nth - child ( 3 ) { width : 18 % } . ab - table td : last - child { width : 39 % }
. diagnosis - page { -- diagnosis - ink : # edfaff ; -- diagnosis - muted : # 9 bc1d1 ; -- diagnosis - line : rgba ( 97 , 209 , 247 , .3 ) ; -- diagnosis - accent : # 36 d2fb ; display : grid ; grid - template - columns : minmax ( 0 , 1 fr ) 340 px ; gap : 24 px ; min - height : calc ( 100 vh - 76 px ) ; padding : 24 px ; color : var ( -- diagnosis - ink ) ; background : radial - gradient ( circle at 7 % 0 % , rgba ( 16 , 155 , 204 , .17 ) , transparent 32 rem ) , linear - gradient ( 135 deg , # 061923 , # 082633 48 % , # 061923 ) }
. diagnosis - core { min - width : 0 ; padding : 28 px ; border : 1 px solid var ( -- diagnosis - line ) ; border - radius : 28 px ; background : rgba ( 4 , 24 , 37 , .82 ) ; box - shadow : 0 22 px 48 px rgba ( 0 , 0 , 0 , .22 ) }
. task - header { margin - bottom : 20 px } . task - header > span { display : inline - flex ; padding : 5 px 12 px ; border : 1 px solid rgba ( 99 , 217 , 255 , .52 ) ; border - radius : 999 px ; color : # dff5ff ; background : rgba ( 17 , 126 , 178 , .3 ) ; font - size : 12 px ; font - weight : 800 ; letter - spacing : .05 em } . task - header h1 { margin : 10 px 0 0 ; color : # fff ; font - size : 30 px ; line - height : 1.25 }
. workbench { margin - top : 24 px ; padding : 24 px ; border : 1 px solid var ( -- diagnosis - line ) ; border - radius : 22 px ; background : rgba ( 2 , 20 , 32 , .62 ) }
. workbench - top { display : flex ; align - items : flex - start ; justify - content : space - between ; gap : 16 px ; padding - bottom : 18 px ; border - bottom : 1 px solid rgba ( 97 , 209 , 247 , .22 ) } . workbench - top p { margin : 0 ; color : # 6 de0ff ; font - size : 12 px ; font - weight : 900 ; letter - spacing : .12 em ; text - transform : uppercase } . workbench - top h2 { margin : 6 px 0 0 ; color : # fff ; font - size : 25 px ; line - height : 1.3 }
. step - tabs { display : flex ; align - items : center ; gap : 8 px ; margin : 20 px 0 26 px ; padding : 14 px ; overflow - x : auto ; border : 1 px solid rgba ( 67 , 183 , 227 , .3 ) ; border - radius : 18 px ; background : rgba ( 1 , 18 , 31 , .58 ) } . step - tabs button { display : flex ; flex : 1 1 0 ; align - items : center ; min - width : 170 px ; gap : 10 px ; padding : 10 px 12 px ; border : 1 px solid rgba ( 76 , 164 , 205 , .38 ) ; border - radius : 12 px ; color : # a6c8d8 ; background : rgba ( 3 , 27 , 43 , .55 ) ; font : inherit ; text - align : left ; cursor : pointer ; transition : .2 s ease } . step - tabs button : hover { border - color : # 72 e8ff ; color : # fff ; background : rgba ( 8 , 92 , 131 , .52 ) ; transform : translateY ( - 1 px ) } . step - tabs button : focus - visible { outline : 2 px solid # 72 e8ff ; outline - offset : 3 px } . step - tabs button b { display : grid ; flex : 0 0 34 px ; width : 34 px ; height : 34 px ; place - items : center ; border : 1 px solid rgba ( 101 , 176 , 208 , .65 ) ; border - radius : 50 % ; color : # bceeff ; background : rgba ( 3 , 25 , 40 , .86 ) ; font - size : 12 px } . step - tabs button span { display : grid ; min - width : 0 ; gap : 3 px ; font - size : 14 px ; font - weight : 800 } . step - tabs button small { color : # 6 db7d3 ; font - size : 10 px ; font - weight : 700 ; letter - spacing : .04 em } . step - tabs button . active { border - color : # 53 d8fb ; color : # fff ; background : linear - gradient ( 135 deg , rgba ( 7 , 156 , 207 , .85 ) , rgba ( 13 , 105 , 161 , .92 ) ) ; box - shadow : inset 0 0 24 px rgba ( 77 , 213 , 255 , .22 ) } . step - tabs button . active b { border - color : # 72 e8ff ; color : # fff ; background : linear - gradient ( 135 deg , # 08 b7e8 , # 1375 d0 ) ; box - shadow : 0 0 0 5 px rgba ( 25 , 179 , 237 , .14 ) } . step - tabs i { flex : 0 0 18 px ; height : 2 px ; background : rgba ( 86 , 198 , 235 , .48 ) }
. lead { margin : 0 0 20 px ; color : # d4edf7 ; font - size : 15 px ; line - height : 1.9 } . section - block { margin - top : 20 px ; padding : 20 px ; border : 1 px solid rgba ( 97 , 209 , 247 , .25 ) ; border - radius : 16 px ; background : rgba ( 3 , 28 , 43 , .42 ) } . section - heading { display : flex ; align - items : flex - start ; gap : 12 px ; margin - bottom : 16 px } . section - heading > span { display : grid ; flex : 0 0 30 px ; width : 30 px ; height : 30 px ; place - items : center ; border - radius : 8 px ; color : # eaffff ; background : linear - gradient ( 135 deg , # 11 b8e4 , # 1373 c4 ) ; font - size : 14 px ; font - weight : 900 } . section - heading h3 { margin : 1 px 0 4 px ; color : # f0fbff ; font - size : 18 px } . section - heading p { margin : 0 ; color : var ( -- diagnosis - muted ) ; font - size : 13 px ; line - height : 1.65 }
. secondary - button , . table - button , . upload - button , . submit - button { min - height : 36 px ; padding : 0 13 px ; border : 1 px solid rgba ( 99 , 203 , 239 , .58 ) ; border - radius : 8 px ; color : # e7fbff ; background : rgba ( 12 , 111 , 153 , .46 ) ; font : inherit ; font - size : 13 px ; font - weight : 800 ; cursor : pointer ; transition : .2 s ease } . secondary - button : hover , . table - button : hover , . upload - button : hover { border - color : # 7 deaff ; background : rgba ( 19 , 145 , 192 , .62 ) } . submit - button { border - color : # 72 e8ff ; background : linear - gradient ( 100 deg , # 0 d80b7 , # 09638 f ) } . submit - button : hover { filter : brightness ( 1.12 ) } button : disabled { cursor : not - allowed ; opacity : .45 } . table - button . ghost { border - color : rgba ( 163 , 197 , 211 , .36 ) ; color : # bed6e0 ; background : rgba ( 75 , 101 , 112 , .25 ) }
. table - wrap { overflow - x : auto ; border : 1 px solid rgba ( 99 , 183 , 222 , .43 ) ; border - radius : 12 px } . funnel - table , . diagnosis - table , . sentiment - table { width : 100 % ; border - collapse : collapse } . funnel - table { min - width : 900 px } . diagnosis - table { min - width : 1060 px } . sentiment - table { min - width : 640 px } . funnel - table th , . funnel - table td , . diagnosis - table th , . diagnosis - table td , . sentiment - table th , . sentiment - table td { padding : 11 px ; border - right : 1 px solid rgba ( 99 , 183 , 222 , .31 ) ; border - bottom : 1 px solid rgba ( 99 , 183 , 222 , .31 ) ; vertical - align : middle ; text - align : center } . funnel - table thead th , . diagnosis - table thead th , . sentiment - table thead th { color : # effaff ; background : linear - gradient ( 100 deg , # 0 a96cc , # 087 daf ) ; font - size : 14 px ; white - space : nowrap } . funnel - table tbody th , . sentiment - table tbody th { color : # dff8ff ; background : rgba ( 8 , 72 , 99 , .34 ) ; font - weight : 800 } . funnel - table td , . diagnosis - table td , . sentiment - table td { background : rgba ( 2 , 18 , 29 , .34 ) } . 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 : # 74 ddf6 ; font - size : 12 px ; font - weight : 800 } . auto - count { color : # 82 e7ff ; font - size : 16 px ; font - weight : 900 }
. 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 } . channel - heading : deep ( . el - input ) { min - width : 120 px } . channel - heading : deep ( . el - input _ _wrapper ) { background : rgba ( 2 , 45 , 66 , .58 ) ; box - shadow : 0 0 0 1 px rgba ( 198 , 242 , 255 , .42 ) inset } . channel - heading : deep ( . el - input _ _inner ) { color : # fff ; text - align : center ; font - weight : 800 } . 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 ( 255 , 164 , 172 , .58 ) ; border - radius : 6 px ; color : # ffd4d7 ; background : rgba ( 130 , 35 , 53 , .32 ) ; font : inherit ; font - size : 17 px ; line - height : 1 ; cursor : pointer } . channel - remove : hover , . channel - remove : focus - visible { border - color : # ff98a2 ; color : # fff ; background : rgba ( 176 , 48 , 67 , .5 ) ; outline : none } . rate - input { display : flex ; align - items : center ; gap : 6 px ; width : 100 % ; white - space : nowrap } . rate - input : deep ( . el - input - number ) { flex : 1 1 auto ; width : calc ( 100 % - 22 px ) ; min - width : 0 } . rate - input . 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 ( -- diagnosis - muted ) ; font - size : 13 px } . table - toolbar > div , . actions , . diagnosis - materials _ _actions , . ab - materials _ _actions { display : flex ; flex - wrap : wrap ; gap : 8 px } . actions { justify - content : center ; margin - top : 24 px } . actions . secondary - button , . actions . submit - button { min - width : 142 px ; min - height : 42 px }
. chart - upload - area , . comment - import { display : grid ; place - items : center ; gap : 10 px ; min - height : 190 px ; padding : 22 px ; border : 1 px dashed rgba ( 113 , 223 , 255 , .66 ) ; border - radius : 13 px ; background : linear - gradient ( 135 deg , rgba ( 5 , 47 , 69 , .66 ) , rgba ( 4 , 23 , 35 , .52 ) ) ; text - align : center } . chart - upload - area p , . comment - import > p { margin : 0 ; color : var ( -- diagnosis - muted ) ; font - size : 13 px } . 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 } . chart - preview : deep ( . el - image ) { width : 180 px ; height : 110 px ; border : 1 px solid var ( -- diagnosis - line ) ; border - radius : 8 px } . chart - preview > div { display : grid ; gap : 7 px } . chart - preview strong { color : # e8fbff } . chart - preview span { color : var ( -- diagnosis - muted ) ; font - size : 13 px } . chart - preview button { width : max - content ; padding : 0 ; border : 0 ; color : # ffadb1 ; background : transparent ; font : inherit ; cursor : pointer }
. data - preview , . word - cloud - card , . top - issues , . root - cause { margin - top : 16 px ; padding : 16 px ; border : 1 px solid rgba ( 99 , 183 , 222 , .31 ) ; border - radius : 12 px ; background : rgba ( 3 , 25 , 39 , .58 ) } . data - preview > div , . word - cloud - title { display : flex ; justify - content : space - between ; gap : 12 px ; color : # e6f9ff } . data - preview span , . word - cloud - title small { color : var ( -- diagnosis - muted ) ; font - size : 12 px } . data - preview ul { max - height : 130 px ; margin : 12 px 0 0 ; padding - left : 20 px ; overflow : auto ; color : # c8e5f1 ; line - height : 1.7 ; text - align : left } . word - cloud { height : 280 px } . top - issues > p , . root - cause > p { margin : 0 0 13 px ; color : # c8e5f1 ; line - height : 1.7 } . top - issues > div { display : grid ; grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr ) ) ; gap : 12 px } . top - issues label { display : grid ; gap : 7 px ; color : # 8 bdff5 ; font - weight : 800 } . sentiment - actions { display : flex ; align - items : center ; gap : 14 px ; margin - bottom : 16 px } . sentiment - actions > span { color : var ( -- diagnosis - muted ) ; font - size : 13 px } . visual - section , . diagnosis - section { margin - top : 20 px }
. diagnosis - page : deep ( . el - input _ _wrapper ) , . diagnosis - page : deep ( . el - textarea _ _inner ) { background : rgba ( 1 , 15 , 25 , .78 ) ; box - shadow : 0 0 0 1 px rgba ( 94 , 213 , 245 , .28 ) inset } . diagnosis - page : deep ( . el - input _ _inner ) , . diagnosis - page : deep ( . el - textarea _ _inner ) { color : # f0fbff } . diagnosis - page : deep ( . el - textarea _ _inner ) { line - height : 1.65 } . 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 % ) }
/* 标准实训页步骤条:仅当前步骤使用胶囊底色,其余步骤保持轻量。 */
. workbench . step - tabs { gap : 14 px ; margin : 20 px 0 26 px ; padding : 6 px 14 px ; border - color : rgba ( 56 , 172 , 222 , .5 ) ; border - radius : 999 px ; background : rgba ( 1 , 19 , 32 , .72 ) }
. workbench . step - tabs . step - tab { flex : 1 1 0 ; min - width : 0 ; min - height : 48 px ; gap : 12 px ; padding : 7 px 14 px ; border : 1 px solid transparent ; border - radius : 999 px ; background : transparent ; color : # c0d4e2 ; transform : none }
. workbench . step - tabs . step - tab : hover { border - color : rgba ( 82 , 207 , 247 , .45 ) ; background : rgba ( 9 , 78 , 111 , .3 ) ; transform : none }
. workbench . step - tabs . step - marker { flex : 0 0 38 px ; width : 38 px ; height : 38 px ; border - color : rgba ( 87 , 182 , 220 , .66 ) ; color : # b7d2e2 ; background : transparent }
. workbench . step - tabs . step - copy { gap : 2 px } . workbench . step - tabs . step - copy b { display : block ; flex : none ; width : auto ; height : auto ; overflow : visible ; border : 0 ; border - radius : 0 ; color : inherit ; background : transparent ; box - shadow : none ; font - size : 14 px ; font - weight : 800 ; text - overflow : clip ; white - space : nowrap } . workbench . step - tabs . step - copy small { color : # 74 b8d5 ; font - size : 10 px ; line - height : 1.2 }
. workbench . step - tabs . step - tab . active { border - color : # 53 d8fb ; background : linear - gradient ( 100 deg , rgba ( 9 , 164 , 214 , .92 ) , rgba ( 10 , 105 , 163 , .9 ) ) ; box - shadow : inset 0 0 18 px rgba ( 114 , 232 , 255 , .2 ) }
. workbench . step - tabs . step - tab . active . step - marker { border - color : # 72 e8ff ; background : linear - gradient ( 135 deg , # 10 bee9 , # 1579 d2 ) ; box - shadow : 0 0 0 4 px rgba ( 43 , 198 , 246 , .14 ) , 0 0 15 px rgba ( 46 , 208 , 255 , .52 ) }
. workbench . step - tabs . step - tab . complete . step - marker { border - color : # 79 eee4 ; color : # 083342 ; background : # 6 deee1 ; box - shadow : none }
. workbench . step - tabs . step - connector { flex : 0 0 30 px ; width : 30 px ; height : 1 px ; margin : 0 ; background : rgba ( 94 , 193 , 229 , .58 ) }
. workbench . step - tabs . step - connector : : after { width : 8 px ; height : 8 px ; border - color : # 73 c8e7 }
@ media ( max - width : 1100 px ) { . diagnosis - page { grid - template - columns : 1 fr } } @ media ( max - width : 720 px ) { . diagnosis - page { padding : 12 px } . diagnosis - core { padding : 16 px ; border - radius : 20 px } . workbench { padding : 16 px } . workbench - top , . table - toolbar , . sentiment - actions { align - items : stretch ; flex - direction : column } . step - tabs { align - items : stretch ; flex - direction : column } . step - tabs button { flex : 0 0 auto ; width : 100 % } . step - tabs i { flex : 0 0 12 px ; width : 2 px ; height : 12 px ; margin - left : 17 px } . diagnosis - materials _ _item , . ab - materials { align - items : stretch ; flex - direction : column } . 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 } }
. diagnosis - toolbar { align - self : center ; justify - content : flex - end ; max - width : 100 % }
@ media ( max - width : 720 px ) { . diagnosis - toolbar { width : 100 % ; justify - content : flex - start } }
< / style >