You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
dianshang-qianduan/src/views/assessment/diagnosis.vue

194 lines
51 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<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-wrap"><table class="funnel-table"><thead><tr><th>链路节点</th><th>天猫</th><th>京东</th><th>抖音</th><th>拼多多</th><th>行业均值</th></tr></thead><tbody><tr v-for="row in conversionRates" :key="row.linkNode"><th>{{ row.linkNode }}</th><td v-for="field in rateFields" :key="field"><el-input-number v-model="row[field]" :min="0" :max="100" :precision="2" :controls="false" :placeholder="row.linkNode === '曝光-点击' && field === 'tmall' ? '示例:11.5%' : '请输入'" /><span class="unit">%</span></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 格式,图片不超过 5MB;上传后可点击预览。</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.channel" placeholder="请选择"><el-option v-for="channel in channels" :key="channel.key" :label="channel.label" :value="channel.label" /></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><el-input-number v-model="row.storeConversionRate" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit">%</span></td><td><el-input-number v-model="row.industryAverageConversionRate" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit">%</span></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,文件不超过 5MB。</p><div v-if="commentPreview.fileName" class="data-preview"><div><strong>{{ commentPreview.fileName }}</strong><span>已解析 {{ commentPreview.totalComments }} 条评论 · 数据预览</span></div><ul><li v-for="(comment, index) in commentPreview.comments" :key="index">{{ comment }}</li></ul></div></div><div v-if="wordCloud.length" class="word-cloud-card"><div class="word-cloud-title"><span>高频问题词云</span><small>基于导入评论自动生成</small></div><div ref="wordCloudRef" class="word-cloud" aria-label="评论高频问题词云"></div></div><div class="top-issues"><p>请根据词云图填写词云中出现频次最高的TOP3问题。</p><div><label v-for="(_, index) in topIssues" :key="index">TOP{{ index + 1 }}:<el-input v-model="topIssues[index]" :placeholder="`填写第 ${index + 1} 个高频问题`" /></label></div></div></section>
<section class="section-block"><div class="section-heading"><span>二</span><div><h3>情感倾向分析</h3></div></div><div class="sentiment-actions"><span>点击情感倾向分析后,系统会自动计算正面、中性、负面评论数量及占比,并按校验所需精度保留两位小数。</span><button type="button" class="upload-button" :disabled="!analysisResult" @click="runSentimentAnalysis">情感倾向分析</button></div><div class="table-wrap"><table class="sentiment-table"><thead><tr><th>情感类型</th><th>评论数量(条)</th><th>占比</th></tr></thead><tbody><tr v-for="row in sentiments" :key="row.type"><th>{{ row.type }}</th><td><span class="auto-count">{{ row.count ?? '—' }}</span></td><td><el-input-number v-model="row.percentage" :min="0" :max="100" :precision="2" :controls="false" disabled placeholder="系统自动计算" /><span class="unit">%</span></td></tr></tbody></table></div></section>
<section class="section-block root-cause"><p>请结合词云TOP3与情感分析结果,说明本案例的核心问题根本原因;说明高频问题与情感数据是如何验证步骤一漏斗异常结论的</p><el-input v-model="rootCauseAnalysis" type="textarea" :rows="6" maxlength="1000" show-word-limit placeholder="请填写根因分析与对步骤一异常结论的验证说明" /></section>
</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><el-input-number v-model="row.controlValue" :min="0" :max="100" :precision="2" :controls="false" placeholder="示例:56%" /><span class="unit">%</span></td><td><el-input-number v-model="row.experimentValue" :min="0" :max="100" :precision="2" :controls="false" placeholder="示例:64%" /><span class="unit">%</span></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 格式,图片不超过 5MB;上传后可点击预览。</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 channels = [{ key: "tmall", label: "天猫" }, { key: "jd", label: "京东" }, { key: "douyin", label: "抖音" }, { key: "pinduoduo", label: "拼多多" }]; const rateFields = [...channels.map((item) => item.key), "industryAverage"];
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 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 progressItems = computed(() => currentStep.value === 1 ? [{ text: "填写五个节点的渠道转化率", status: conversionRates.value.every((row) => rateFields.every((field) => row[field] !== null && row[field] !== "" && Number.isFinite(Number(row[field])))) ? "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 createConversionRates() { return nodes.map((linkNode) => ({ linkNode, tmall: null, jd: null, douyin: null, pinduoduo: null, industryAverage: null })); } function createAnomalyRow() { return { 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 buildStepOne() { return { conversionRates: conversionRates.value.map((row) => ({ ...row })), funnelChartUrl: funnelChart.value.url, funnelChartName: funnelChart.value.name, anomalyRows: anomalyRows.value.map((row) => ({ ...row })) }; } function buildStepTwo() { return { sourceFileName: commentPreview.value.fileName, topIssues: [...topIssues.value], sentiments: sentiments.value.map((row) => ({ ...row })), rootCauseAnalysis: rootCauseAnalysis.value, wordCloud: wordCloud.value, previewComments: commentPreview.value.comments }; } function buildStepThree() { return { testPlan: abTestPlan.value, rows: abRows.value.map((row) => ({ ...row })), comparisonChartUrl: abChart.value.url, comparisonChartName: abChart.value.name, iterationRecommendation: iterationRecommendation.value }; }
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(data); await saveStudentTrainingAnswer(TASK_KEY, { step2Answer: JSON.stringify(data), currentStep: 2, submitted: false, saveAction: "SAVE" }); proxy?.$modal?.msgSuccess?.("第 2 步校验通过,已保存"); return true; } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请检查词云、情感占比和根因分析"); return false; } finally { saving.value = false; } }
function clearAbChart() { if (abChart.value.url.startsWith("blob:")) URL.revokeObjectURL(abChart.value.url); abChart.value = { url: "", name: "" }; }
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(stepTwo);
await saveStudentTrainingAnswer(TASK_KEY, { step2Answer: JSON.stringify(stepTwo), currentStep: 2, submitted: false, saveAction: "SAVE" });
const stepThree = buildStepThree();
await validateProductDiagnosisIterationStepThree(stepThree);
await saveStudentTrainingAnswer(TASK_KEY, { step3Answer: JSON.stringify(stepThree), currentStep: 3, submitted: true, saveAction: "SUBMIT" });
currentStep.value = 3;
proxy?.$modal?.msgSuccess?.("实训任务已提交");
return true;
} catch (error) {
proxy?.$modal?.msgError?.(error?.message || "提交失败,请检查三个步骤的填写内容");
return false;
} finally {
saving.value = false;
}
}
async function resetTraining() {
if (saving.value || uploading.value || analyzing.value) return false;
try {
await deleteStudentTrainingAnswer(TASK_KEY);
clearChart(); clearAbChart(); wordCloudChart?.dispose(); wordCloudChart = null;
conversionRates.value = createConversionRates(); anomalyRows.value = [createAnomalyRow()]; analysisResult.value = null;
commentPreview.value = { fileName: "", totalComments: 0, comments: [] }; wordCloud.value = []; topIssues.value = ["", "", ""]; sentiments.value = createSentiments(); rootCauseAnalysis.value = "";
abTestPlan.value = ""; abRows.value = createAbRows(); iterationRecommendation.value = ""; currentStep.value = 1;
proxy?.$modal?.msgSuccess?.("已清空全部实训步骤,可以重新开始");
return true;
} catch (error) {
proxy?.$modal?.msgError?.(error?.message || "重新实训失败,请稍后重试");
return false;
}
}
function exportTrainingOutcome() {
const outcome = { taskName: taskTitle.value, exportedAt: new Date().toISOString(), step1: buildStepOne(), step2: buildStepTwo(), step3: buildStepThree() };
const url = URL.createObjectURL(new Blob([JSON.stringify(outcome, null, 2)], { type: "application/json;charset=utf-8" }));
const link = document.createElement("a"); link.href = url; link.download = "产品问题诊断与迭代-实训成果.json"; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url);
proxy?.$modal?.msgSuccess?.("实训成果已导出");
}
function restore(answer) { try { const stepOne = JSON.parse(answer?.step1Answer || "{}"); if (Array.isArray(stepOne.conversionRates) && stepOne.conversionRates.length === nodes.length) conversionRates.value = nodes.map((node) => ({ ...createConversionRates().find((row) => row.linkNode === node), ...(stepOne.conversionRates.find((row) => row?.linkNode === node) || {}) })); if (Array.isArray(stepOne.anomalyRows) && stepOne.anomalyRows.length) anomalyRows.value = stepOne.anomalyRows.slice(0, 5).map((row) => ({ ...createAnomalyRow(), ...row })); if (stepOne.funnelChartUrl) funnelChart.value = { url: stepOne.funnelChartUrl, name: stepOne.funnelChartName || "节点转化漏斗对比图" }; const stepTwo = JSON.parse(answer?.step2Answer || "{}"); if (stepTwo.sourceFileName) { commentPreview.value = { fileName: stepTwo.sourceFileName, totalComments: 0, comments: stepTwo.previewComments || [] }; wordCloud.value = stepTwo.wordCloud || []; topIssues.value = [0, 1, 2].map((index) => stepTwo.topIssues?.[index] || ""); sentiments.value = (stepTwo.sentiments || createSentiments()).map((row) => ({ ...createSentiments().find((item) => item.type === row.type), ...row })); rootCauseAnalysis.value = stepTwo.rootCauseAnalysis || ""; nextTick(renderWordCloud); } const stepThree = JSON.parse(answer?.step3Answer || "{}"); if (stepThree.testPlan || stepThree.comparisonChartUrl || stepThree.iterationRecommendation) { abTestPlan.value = stepThree.testPlan || ""; if (Array.isArray(stepThree.rows) && stepThree.rows.length === createAbRows().length) abRows.value = createAbRows().map((row) => ({ ...row, ...(stepThree.rows.find((item) => item?.metric === row.metric) || {}) })); if (stepThree.comparisonChartUrl) abChart.value = { url: stepThree.comparisonChartUrl, name: stepThree.comparisonChartName || "AB 测试结果对比图" }; iterationRecommendation.value = stepThree.iterationRecommendation || ""; } currentStep.value = [1, 2, 3].includes(Number(answer?.currentStep)) ? Number(answer.currentStep) : 1; } catch (error) {} }
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:10px;margin:14px 0}.diagnosis-materials__item{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:12px 14px;border:1px solid rgba(76,202,242,.32);border-radius:10px;background:rgba(3,42,61,.45)}.diagnosis-materials__item strong,.diagnosis-materials__item span{display:block}.diagnosis-materials__item strong{color:#e5faff;font-size:15px}.diagnosis-materials__item span{margin-top:4px;color:#93baca;font-size:13px}.diagnosis-materials__actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px}.diagnosis-step-one-notice{margin:12px 0 0;color:#ff7178;font-size:13px;line-height:1.6}@media (max-width:860px){.diagnosis-materials__item{align-items:flex-start;flex-direction:column}.diagnosis-materials__actions{justify-content:flex-start}}
.diagnosis-materials__ai{margin:0;padding:3px 2px;color:#cce8f2;font-size:14px;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:30px;width:auto;padding:0 5px}
.step-tabs{padding:18px 22px;border-radius:22px}.step-tabs .step-tab{display:flex;flex:1 1 0;align-items:center;min-width:145px;min-height:66px;gap:10px;padding:8px 12px;border:1px solid rgba(68,141,177,.35);border-radius:12px;color:#9ab3d0;background:rgba(3,27,43,.52);font:inherit;text-align:left;cursor:pointer;transition:.2s ease}.step-tabs .step-tab:hover{border-color:rgba(89,223,255,.82);color:#fff;background:rgba(8,92,131,.52);transform:translateY(-1px)}.step-tabs .step-tab:focus-visible{outline:2px solid #72e8ff;outline-offset:3px}.step-tabs .step-marker{display:grid;flex:0 0 38px;width:38px;height:38px;place-items:center;border:1px solid rgba(101,176,208,.6);border-radius:50%;color:#a6c4d9;background:rgba(3,25,40,.8);font-size:12px;font-weight:900}.step-tabs .step-copy{display:grid;min-width:0;gap:4px}.step-tabs .step-copy b{overflow:hidden;font-size:14px;font-weight:800;text-overflow:ellipsis;white-space:nowrap}.step-tabs .step-copy small{color:#66b6d4;font-size:10px;font-weight:800;letter-spacing:.06em}.step-tabs .step-tab.active{border-color:#53d8fb;color:#fff;background:linear-gradient(135deg,rgba(7,156,207,.85),rgba(13,105,161,.92));box-shadow:inset 0 0 24px rgba(77,213,255,.22)}.step-tabs .step-tab.active .step-marker{border-color:#72e8ff;color:#fff;background:linear-gradient(135deg,#08b7e8,#1375d0);box-shadow:0 0 0 5px rgba(25,179,237,.14),0 0 18px 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:#062b37;background:#6ef0e1}.step-tabs .step-connector{position:relative;flex:0 0 24px;height:2px;background:rgba(86,198,235,.48)}.step-tabs .step-connector::after{position:absolute;top:50%;right:0;width:7px;height:7px;border-top:1px solid #73b1ce;border-right:1px solid #73b1ce;content:'';transform:translateY(-50%) rotate(45deg)}
.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:720px){.step-tabs .step-connector{flex:0 0 18px;width:2px;height:18px;margin-left:18px}.step-tabs .step-connector::after{top:auto;right:auto;bottom:0;left:50%;transform:translateX(-50%) rotate(135deg)}}
.ab-materials{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:18px;padding:14px 16px;border-left:3px solid #19c9ff;background:rgba(3,42,61,.45);color:#cde9f2}.ab-materials ol{margin:0;padding-left:20px;line-height:1.8}.ab-materials__actions{display:grid;flex:0 0 178px;gap:8px}@media (max-width:860px){.ab-materials{align-items:stretch;flex-direction:column}.ab-materials__actions{grid-template-columns:repeat(2,minmax(0,1fr));flex-basis:auto}}
.ab-table{width:100%;min-width:860px;border-collapse:collapse}.ab-table th,.ab-table td{border:1px solid rgba(139,213,241,.45)}.ab-table thead th{padding:14px 10px;color:#fff;background:#079ed9;font-size:16px}.ab-table tbody th{width:25%;padding:14px 10px;color:#dff8ff;background:rgba(6,67,94,.34);text-align:center}.ab-table td{padding:9px 11px;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:#9bc1d1;--diagnosis-line:rgba(97,209,247,.3);--diagnosis-accent:#36d2fb;display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:24px;min-height:calc(100vh - 76px);padding:24px;color:var(--diagnosis-ink);background:radial-gradient(circle at 7% 0%,rgba(16,155,204,.17),transparent 32rem),linear-gradient(135deg,#061923,#082633 48%,#061923)}
.diagnosis-core{min-width:0;padding:28px;border:1px solid var(--diagnosis-line);border-radius:28px;background:rgba(4,24,37,.82);box-shadow:0 22px 48px rgba(0,0,0,.22)}
.task-header{margin-bottom:20px}.task-header>span{display:inline-flex;padding:5px 12px;border:1px solid rgba(99,217,255,.52);border-radius:999px;color:#dff5ff;background:rgba(17,126,178,.3);font-size:12px;font-weight:800;letter-spacing:.05em}.task-header h1{margin:10px 0 0;color:#fff;font-size:30px;line-height:1.25}
.workbench{margin-top:24px;padding:24px;border:1px solid var(--diagnosis-line);border-radius:22px;background:rgba(2,20,32,.62)}
.workbench-top{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding-bottom:18px;border-bottom:1px solid rgba(97,209,247,.22)}.workbench-top p{margin:0;color:#6de0ff;font-size:12px;font-weight:900;letter-spacing:.12em;text-transform:uppercase}.workbench-top h2{margin:6px 0 0;color:#fff;font-size:25px;line-height:1.3}
.step-tabs{display:flex;align-items:center;gap:8px;margin:20px 0 26px;padding:14px;overflow-x:auto;border:1px solid rgba(67,183,227,.3);border-radius:18px;background:rgba(1,18,31,.58)}.step-tabs button{display:flex;flex:1 1 0;align-items:center;min-width:170px;gap:10px;padding:10px 12px;border:1px solid rgba(76,164,205,.38);border-radius:12px;color:#a6c8d8;background:rgba(3,27,43,.55);font:inherit;text-align:left;cursor:pointer;transition:.2s ease}.step-tabs button:hover{border-color:#72e8ff;color:#fff;background:rgba(8,92,131,.52);transform:translateY(-1px)}.step-tabs button:focus-visible{outline:2px solid #72e8ff;outline-offset:3px}.step-tabs button b{display:grid;flex:0 0 34px;width:34px;height:34px;place-items:center;border:1px solid rgba(101,176,208,.65);border-radius:50%;color:#bceeff;background:rgba(3,25,40,.86);font-size:12px}.step-tabs button span{display:grid;min-width:0;gap:3px;font-size:14px;font-weight:800}.step-tabs button small{color:#6db7d3;font-size:10px;font-weight:700;letter-spacing:.04em}.step-tabs button.active{border-color:#53d8fb;color:#fff;background:linear-gradient(135deg,rgba(7,156,207,.85),rgba(13,105,161,.92));box-shadow:inset 0 0 24px rgba(77,213,255,.22)}.step-tabs button.active b{border-color:#72e8ff;color:#fff;background:linear-gradient(135deg,#08b7e8,#1375d0);box-shadow:0 0 0 5px rgba(25,179,237,.14)}.step-tabs i{flex:0 0 18px;height:2px;background:rgba(86,198,235,.48)}
.lead{margin:0 0 20px;color:#d4edf7;font-size:15px;line-height:1.9}.section-block{margin-top:20px;padding:20px;border:1px solid rgba(97,209,247,.25);border-radius:16px;background:rgba(3,28,43,.42)}.section-heading{display:flex;align-items:flex-start;gap:12px;margin-bottom:16px}.section-heading>span{display:grid;flex:0 0 30px;width:30px;height:30px;place-items:center;border-radius:8px;color:#eaffff;background:linear-gradient(135deg,#11b8e4,#1373c4);font-size:14px;font-weight:900}.section-heading h3{margin:1px 0 4px;color:#f0fbff;font-size:18px}.section-heading p{margin:0;color:var(--diagnosis-muted);font-size:13px;line-height:1.65}
.secondary-button,.table-button,.upload-button,.submit-button{min-height:36px;padding:0 13px;border:1px solid rgba(99,203,239,.58);border-radius:8px;color:#e7fbff;background:rgba(12,111,153,.46);font:inherit;font-size:13px;font-weight:800;cursor:pointer;transition:.2s ease}.secondary-button:hover,.table-button:hover,.upload-button:hover{border-color:#7deaff;background:rgba(19,145,192,.62)}.submit-button{border-color:#72e8ff;background:linear-gradient(100deg,#0d80b7,#09638f)}.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:1px solid rgba(99,183,222,.43);border-radius:12px}.funnel-table,.diagnosis-table,.sentiment-table{width:100%;border-collapse:collapse}.funnel-table{min-width:900px}.diagnosis-table{min-width:1060px}.sentiment-table{min-width:640px}.funnel-table th,.funnel-table td,.diagnosis-table th,.diagnosis-table td,.sentiment-table th,.sentiment-table td{padding:11px;border-right:1px solid rgba(99,183,222,.31);border-bottom:1px 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(100deg,#0a96cc,#087daf);font-size:14px;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:5px;color:#74ddf6;font-size:12px;font-weight:800}.auto-count{color:#82e7ff;font-size:16px;font-weight:900}
.table-toolbar{display:flex;align-items:center;justify-content:space-between;gap:14px;margin:0 0 12px;color:var(--diagnosis-muted);font-size:13px}.table-toolbar>div,.actions,.diagnosis-materials__actions,.ab-materials__actions{display:flex;flex-wrap:wrap;gap:8px}.actions{justify-content:center;margin-top:24px}.actions .secondary-button,.actions .submit-button{min-width:142px;min-height:42px}
.chart-upload-area,.comment-import{display:grid;place-items:center;gap:10px;min-height:190px;padding:22px;border:1px dashed rgba(113,223,255,.66);border-radius:13px;background:linear-gradient(135deg,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:13px}.upload-button{min-height:42px;padding:0 18px}.chart-preview{display:flex;align-items:center;gap:16px;width:100%;text-align:left}.chart-preview :deep(.el-image){width:180px;height:110px;border:1px solid var(--diagnosis-line);border-radius:8px}.chart-preview>div{display:grid;gap:7px}.chart-preview strong{color:#e8fbff}.chart-preview span{color:var(--diagnosis-muted);font-size:13px}.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:16px;padding:16px;border:1px solid rgba(99,183,222,.31);border-radius:12px;background:rgba(3,25,39,.58)}.data-preview>div,.word-cloud-title{display:flex;justify-content:space-between;gap:12px;color:#e6f9ff}.data-preview span,.word-cloud-title small{color:var(--diagnosis-muted);font-size:12px}.data-preview ul{max-height:130px;margin:12px 0 0;padding-left:20px;overflow:auto;color:#c8e5f1;line-height:1.7;text-align:left}.word-cloud{height:280px}.top-issues>p,.root-cause>p{margin:0 0 13px;color:#c8e5f1;line-height:1.7}.top-issues>div{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.top-issues label{display:grid;gap:7px;color:#8bdff5;font-weight:800}.sentiment-actions{display:flex;align-items:center;gap:14px;margin-bottom:16px}.sentiment-actions>span{color:var(--diagnosis-muted);font-size:13px}.visual-section,.diagnosis-section{margin-top:20px}
.diagnosis-page :deep(.el-input__wrapper),.diagnosis-page :deep(.el-textarea__inner){background:rgba(1,15,25,.78);box-shadow:0 0 0 1px 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:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;clip-path:inset(50%)}
/* 标准实训页步骤条:仅当前步骤使用胶囊底色,其余步骤保持轻量。 */
.workbench .step-tabs{gap:14px;margin:20px 0 26px;padding:6px 14px;border-color:rgba(56,172,222,.5);border-radius:999px;background:rgba(1,19,32,.72)}
.workbench .step-tabs .step-tab{flex:1 1 0;min-width:0;min-height:48px;gap:12px;padding:7px 14px;border:1px solid transparent;border-radius:999px;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 38px;width:38px;height:38px;border-color:rgba(87,182,220,.66);color:#b7d2e2;background:transparent}
.workbench .step-tabs .step-copy{gap:2px}.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:14px;font-weight:800;text-overflow:clip;white-space:nowrap}.workbench .step-tabs .step-copy small{color:#74b8d5;font-size:10px;line-height:1.2}
.workbench .step-tabs .step-tab.active{border-color:#53d8fb;background:linear-gradient(100deg,rgba(9,164,214,.92),rgba(10,105,163,.9));box-shadow:inset 0 0 18px rgba(114,232,255,.2)}
.workbench .step-tabs .step-tab.active .step-marker{border-color:#72e8ff;background:linear-gradient(135deg,#10bee9,#1579d2);box-shadow:0 0 0 4px rgba(43,198,246,.14),0 0 15px rgba(46,208,255,.52)}
.workbench .step-tabs .step-tab.complete .step-marker{border-color:#79eee4;color:#083342;background:#6deee1;box-shadow:none}
.workbench .step-tabs .step-connector{flex:0 0 30px;width:30px;height:1px;margin:0;background:rgba(94,193,229,.58)}
.workbench .step-tabs .step-connector::after{width:8px;height:8px;border-color:#73c8e7}
.sentiment-table td:last-child{white-space:nowrap}.sentiment-table td:last-child :deep(.el-input-number){display:inline-flex;width:calc(100% - 32px);vertical-align:middle}.sentiment-table td:last-child .unit{display:inline-flex;align-items:center;height:32px;margin-left:6px;vertical-align:middle}
@media(max-width:1100px){.diagnosis-page{grid-template-columns:1fr}}@media(max-width:720px){.diagnosis-page{padding:12px}.diagnosis-core{padding:16px;border-radius:20px}.workbench{padding:16px}.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 12px;width:2px;height:12px;margin-left:17px}.diagnosis-materials__item,.ab-materials{align-items:stretch;flex-direction:column}.top-issues>div{grid-template-columns:1fr}.chart-preview{align-items:flex-start;flex-direction:column}.chart-preview :deep(.el-image){width:100%;height:170px}}
.diagnosis-toolbar{align-self:center;justify-content:flex-end;max-width:100%}
@media(max-width:720px){.diagnosis-toolbar{width:100%;justify-content:flex-start}}
</style>