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

372 lines
59 KiB
Vue

4 months ago
<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 格式,图片不超过 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.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,文件不超过 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><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 格式,图片不超过 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" />
4 months ago
</div>
</template>
4 months ago
<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(); });
4 months ago
</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}
.funnel-channel-toolbar>span{display:block}.funnel-table .fixed-column,.funnel-table tbody th{min-width:145px}.funnel-table .channel-column,.funnel-table tbody td{min-width:180px}.channel-heading{display:flex;align-items:center;gap:7px}.channel-heading :deep(.el-input){min-width:120px}.channel-heading :deep(.el-input__wrapper){background:rgba(2,45,66,.58);box-shadow:0 0 0 1px 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 24px;width:24px;height:24px;place-items:center;padding:0;border:1px solid rgba(255,164,172,.58);border-radius:6px;color:#ffd4d7;background:rgba(130,35,53,.32);font:inherit;font-size:17px;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:6px;width:100%;white-space:nowrap}.rate-input :deep(.el-input-number){flex:1 1 auto;width:calc(100% - 22px);min-width:0}.rate-input .unit{display:inline-flex;flex:0 0 auto;align-items:center;height:32px;margin-left:0;white-space:nowrap}
.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}
@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>