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

1271 lines
64 KiB
Vue

4 months ago
<template>
<div class="diagnosis-page student-training-shell">
<main class="diagnosis-core">
<section class="task-header">
<div class="task-head-main">
<span class="task-badge"><el-icon><DataAnalysis /></el-icon> 核心实训 · 产品上线与运营推广</span>
<h1 class="task-title">{{ taskTitle }}</h1>
<p class="task-brief">
<span class="task-brief-text">{{ taskBackground }}</span>
<button type="button" class="task-brief-toggle" @click="briefOpen = !briefOpen">
{{ briefOpen ? "收起" : "展开目标与要求" }}
</button>
<span v-if="briefOpen" class="task-brief-more">
<b>实训目标</b>
<ul><li v-for="goal in taskGoals" :key="goal">{{ goal }}</li></ul>
<b>实训要求</b>
<p>{{ taskRequirement }}</p>
</span>
</p>
</div>
<TrainingTaskToolbar class="diagnosis-toolbar" :task-key="TASK_KEY" :on-submit="submitTask" :on-reset="resetTraining" :on-export="exportTrainingOutcome" />
</section>
<section class="workbench">
<nav class="step-indicator" aria-label="产品问题诊断步骤"><button type="button" :class="['step-tab', { active: currentStep === 1, complete: currentStep > 1 }]" :aria-current="currentStep === 1 ? 'step' : undefined" @click="currentStep = 1"><span class="step-marker">01</span><span class="step-copy"><b class="step-name">全链路漏斗诊断</b><small class="step-action">{{ currentStep === 1 ? '当前步骤 →' : '点击切换 →' }}</small></span></button><span class="step-connector" aria-hidden="true" /><button type="button" :class="['step-tab', { active: currentStep === 2, complete: currentStep > 2 }]" :aria-current="currentStep === 2 ? 'step' : undefined" @click="currentStep = 2"><span class="step-marker">02</span><span class="step-copy"><b class="step-name">用户评论根因分析</b><small class="step-action">{{ currentStep === 2 ? '当前步骤 →' : '点击切换 →' }}</small></span></button><span class="step-connector" aria-hidden="true" /><button type="button" :class="['step-tab', { active: currentStep === 3 }]" :aria-current="currentStep === 3 ? 'step' : undefined" @click="currentStep = 3"><span class="step-marker">03</span><span class="step-copy"><b class="step-name">AB测试验证</b><small class="step-action">{{ currentStep === 3 ? '当前步骤 →' : '点击切换 →' }}</small></span></button></nav>
<template v-if="currentStep === 1">
<p class="lead">从用户触达产品的全路径出发,搭建从曝光到复购的完整数据漏斗,逐层计算各环节转化率并对比行业均值,定位产品在哪个环节出现了明显异常流失,精准锁定问题所在的运营环节。</p>
<div class="diagnosis-materials" aria-label="第 1 步参考资料">
<div class="diagnosis-materials__item"><strong>1.下载案例数据表,使用Excel计算各节点的转化率,参考《实训操作说明》。</strong><div class="diagnosis-materials__actions"><button type="button" class="secondary-button" @click="downloadNodeGuide">下载实训操作说明</button></div></div>
<div class="diagnosis-materials__item"><strong>2.下载Excel模版,填写关键信息,自动计算各环节转化率。提供自动化模版。</strong><div class="diagnosis-materials__actions"><button type="button" class="secondary-button" @click="downloadNodeTemplate">下载Excel模板</button></div></div>
<p class="diagnosis-materials__ai">3.导出数据表上传到通用AI大模型,通过AI大模型辅助完成计算,参考步骤及提示词如下:请根据表格数据,计算从曝光到复购链路节点转化率。</p>
</div>
<section class="section-block">
<div class="section-heading"><span>一</span><div><h3>计算各节点转化率</h3><p>根据案例数据计算各个渠道链路节点转化率,参考方法及工具如下:</p></div></div>
<div class="table-toolbar funnel-channel-toolbar">
<span>链路节点和行业均值固定,中间渠道可按案例数据增加或删除。</span>
<button type="button" class="table-button" @click="addFunnelChannel">增加渠道</button>
</div>
<div class="table-wrap">
<table class="funnel-table">
<thead>
<tr>
<th class="fixed-column">链路节点</th>
<th v-for="channel in channels" :key="channel.id" class="channel-column">
<div class="channel-heading">
<el-input v-model="channel.name" maxlength="20" placeholder="填写渠道名称" />
<button type="button" class="channel-remove" :aria-label="`删除${channel.name || '未命名'}渠道`" @click="removeFunnelChannel(channel.id)">×</button>
</div>
</th>
<th class="fixed-column">行业均值</th>
</tr>
</thead>
<tbody>
<tr v-for="row in conversionRates" :key="row.linkNode">
<th>{{ row.linkNode }}</th>
<td v-for="(channel, channelIndex) in channels" :key="channel.id">
<div class="rate-input">
<el-input-number v-model="row.channelRates[channel.id]" :min="0" :max="100" :precision="2" :controls="false" :placeholder="row.linkNode === '曝光-点击' && channelIndex === 0 ? '示例:11.5' : '请输入'" />
<span class="unit">%</span>
</div>
</td>
<td><div class="rate-input"><el-input-number v-model="row.industryAverage" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit">%</span></div></td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="section-block visual-section"><div class="section-heading"><span>二</span><div><h3>全链路转化漏斗可视化</h3><p>根据节点转化率计算结果,绘制各渠道转化率与行业均值的转化漏斗对比图,可视化呈现多环节情况,参考方法及工具如下:</p></div></div><div class="diagnosis-materials" aria-label="第 2 步参考资料"><div class="diagnosis-materials__item"><strong>1.使用Excel绘图功能,自主完成转化漏斗对比图,可参考《实训操作说明》。</strong><div class="diagnosis-materials__actions"><button type="button" class="secondary-button" @click="downloadFunnelGuide">下载实训操作说明</button></div></div><div class="diagnosis-materials__item"><strong>2.下载Excel模版,填写关键信息,自动计算各环节转化率并生成转化漏斗对比图。</strong><div class="diagnosis-materials__actions"><button type="button" class="secondary-button" @click="downloadFunnelTemplate">下载Excel模板</button></div></div><p class="diagnosis-materials__ai">3.导出数据表上传到通用AI大模型,通过AI大模型辅助完成,参考步骤及提示词如下:请根据表格数据绘制漏斗图,并与行业均值漏斗图进行对比。</p></div><input ref="chartInput" class="visually-hidden" type="file" accept="image/png,image/jpeg,image/webp" @change="handleChartUpload" /><div class="chart-upload-area"><template v-if="funnelChart.url"><div class="chart-preview"><el-image :src="funnelChart.url" :preview-src-list="[funnelChart.url]" fit="cover" preview-teleported /><div><strong>{{ funnelChart.name || '节点转化漏斗对比图' }}</strong><span>点击缩略图可放大预览</span><button type="button" @click="clearChart">移除图片</button></div></div><button type="button" class="secondary-button" :disabled="uploading" @click="chartInput?.click()">{{ uploading ? '上传中…' : '更换对比图' }}</button></template><template v-else><button type="button" class="upload-button" :disabled="uploading" @click="chartInput?.click()">{{ uploading ? '上传中…' : '上传节点转化漏斗对比图' }}</button><p>支持 PNG、JPG、WEBP 格式,图片不超过 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 { DataAnalysis } from "@element-plus/icons-vue";
import * as echarts from "echarts";
import "echarts-wordcloud";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue";
import { getTrainingTaskByKey } from "@/api/trainingTask";
import { analyzeProductDiagnosisIterationStepTwo, deleteStudentTrainingAnswer, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile, validateProductDiagnosisIterationStepOne, validateProductDiagnosisIterationStepTwo, validateProductDiagnosisIterationStepThree } from "@/api/studentTrainingAnswer";
import { parseTrainingArray } from "@/views/training/taskKeyMap";
import { isStudentDemo } from "@/utils/studentDemo";
const NODE_GUIDE_URL = "/file/training-materials/product-diagnosis-iteration/23.%E5%AE%9E%E8%AE%AD%E6%93%8D%E4%BD%9C%E8%AF%B4%E6%98%8E1.docx";
const NODE_TEMPLATE_URL = "/file/training-materials/product-diagnosis-iteration/23.Excel%E6%A8%A1%E7%89%881.xlsx";
const FUNNEL_GUIDE_URL = "/file/training-materials/product-diagnosis-iteration/23.%E5%AE%9E%E8%AE%AD%E6%93%8D%E4%BD%9C%E8%AF%B4%E6%98%8E2.docx";
const FUNNEL_TEMPLATE_URL = "/file/training-materials/product-diagnosis-iteration/23.Excel%E6%A8%A1%E7%89%882.xlsx";
const AB_TEST_GUIDE_URL = "/file/training-materials/product-diagnosis-iteration/23.%E5%AE%9E%E8%AE%AD%E6%93%8D%E4%BD%9C%E8%AF%B4%E6%98%8E3.docx";
const AB_TEST_TEMPLATE_URL = "/file/training-materials/product-diagnosis-iteration/23.Excel%E6%A8%A1%E7%89%883.xlsx";
function downloadBuiltInFile(url) { window.open(url, "_blank", "noopener"); }
function downloadNodeGuide() { downloadBuiltInFile(NODE_GUIDE_URL); }
function downloadNodeTemplate() { downloadBuiltInFile(NODE_TEMPLATE_URL); }
function downloadFunnelGuide() { downloadBuiltInFile(FUNNEL_GUIDE_URL); }
function downloadFunnelTemplate() { downloadBuiltInFile(FUNNEL_TEMPLATE_URL); }
function downloadAbGuide() { downloadBuiltInFile(AB_TEST_GUIDE_URL); }
function downloadAbTemplate() { downloadBuiltInFile(AB_TEST_TEMPLATE_URL); }
const briefOpen = ref(false);
const TASK_KEY = "product-diagnosis-iteration";
const nodes = ["曝光-点击", "点击-加购", "加购-下单", "下单-好评", "好评-复购"];
const defaultChannels = [{ id: "tmall", name: "天猫" }, { id: "jd", name: "京东" }, { id: "douyin", name: "抖音" }, { id: "pinduoduo", name: "拼多多" }];
let channelSerial = 0;
const { proxy } = getCurrentInstance(); const taskConfig = ref(null); const currentStep = ref(1); const saving = ref(false); const uploading = ref(false); const analyzing = ref(false); const chartInput = ref(null); const commentInput = ref(null); const abChartInput = ref(null); const wordCloudRef = ref(null); let wordCloudChart = null;
const channels = ref(createDefaultChannels());
const conversionRates = ref(createConversionRates()); const anomalyRows = ref([createAnomalyRow()]); const funnelChart = ref({ url: "", name: "" }); const analysisResult = ref(null); const commentPreview = ref({ fileName: "", totalComments: 0, comments: [] }); const wordCloud = ref([]); const topIssues = ref(["", "", ""]); const sentiments = ref(createSentiments()); const rootCauseAnalysis = ref(""); const abTestPlan = ref(""); const abRows = ref(createAbRows()); const abChart = ref({ url: "", name: "" }); const iterationRecommendation = ref("");
const taskTitle = computed(() => taskConfig.value?.taskName || "产品问题诊断与迭代"); const taskBackground = computed(() => taskConfig.value?.background || "从曝光到复购的完整数据漏斗与用户评价出发,定位异常流失并形成迭代方向。"); const taskRequirement = computed(() => taskConfig.value?.requirements || "完成转化漏斗诊断、用户评论词云和情感根因分析。"); const taskGoals = computed(() => parseTrainingArray(taskConfig.value?.objectives, ["掌握全链路转化率计算方法", "能够将店铺数据与行业均值进行对比", "从用户评价中定位问题根因"]));
const availableChannels = computed(() => channels.value.filter((channel) => channel.name.trim()));
const funnelRatesComplete = computed(() => {
const names = availableChannels.value.map((channel) => channel.name.trim());
if (!channels.value.length || names.length !== channels.value.length || new Set(names).size !== names.length) return false;
return conversionRates.value.every((row) => isRate(row.industryAverage) && channels.value.every((channel) => isRate(row.channelRates[channel.id])));
});
const progressItems = computed(() => currentStep.value === 1 ? [{ text: "填写五个节点的渠道转化率", status: funnelRatesComplete.value ? "done" : "doing" }, { text: "上传节点转化漏斗对比图", status: funnelChart.value.url ? "done" : "" }, { text: "完成异常节点分析", status: anomalyRows.value.every((row) => row.anomalyAnalysis?.trim()) ? "done" : "" }] : currentStep.value === 2 ? [{ text: "导入评论数据并生成词云", status: analysisResult.value ? "done" : "doing" }, { text: "完成情感倾向分析", status: sentiments.value.every((row) => row.count !== null) ? "done" : "" }, { text: "填写根因分析", status: rootCauseAnalysis.value.trim() ? "done" : "" }] : [{ text: "设计 AB 测试方案", status: abTestPlan.value.trim() ? "done" : "doing" }, { text: "填写五项对照数据", status: abRows.value.every((row) => row.controlValue !== null && row.experimentValue !== null && row.analysis?.trim()) ? "done" : "" }, { text: "上传 AB 对比图", status: abChart.value.url ? "done" : "" }, { text: "提交迭代优化建议", status: iterationRecommendation.value.trim() ? "done" : "" }]);
function createDefaultChannels() { return defaultChannels.map((channel) => ({ ...channel })); }
function createConversionRates(channelList = channels.value) { return nodes.map((linkNode) => ({ linkNode, channelRates: Object.fromEntries(channelList.map((channel) => [channel.id, null])), industryAverage: null })); }
function createAnomalyRow() { return { channelId: "", channel: "", linkNode: "", storeConversionRate: null, industryAverageConversionRate: null, anomalyAnalysis: "" }; }
function createSentiments() { return ["正面", "中性", "负面"].map((type) => ({ type, count: null, percentage: null })); }
function createAbRows() { return ["点击-加购转化率", "加购-下单转化率", "下单-好评转化率", "详情页跳出率", "退货率"].map((metric) => ({ metric, controlValue: null, experimentValue: null, analysis: "" })); }
function isRate(value) { return value !== null && value !== "" && Number.isFinite(Number(value)); }
function diagnosisErrorMessage(error, fallback) {
const serverMessage = String(error?.response?.data?.msg || "").trim();
const message = serverMessage || String(error?.message || "").trim();
return /Request failed with status code|系统接口\d{3}异常/i.test(message) ? fallback : message || fallback;
}
function buildStepOne() {
const channelSnapshot = channels.value.map((channel) => ({ id: channel.id, name: channel.name.trim() }));
return {
channels: channelSnapshot,
conversionRates: conversionRates.value.map((row) => ({ linkNode: row.linkNode, channelRates: { ...row.channelRates }, industryAverage: row.industryAverage })),
funnelChartUrl: funnelChart.value.url,
funnelChartName: funnelChart.value.name,
anomalyRows: anomalyRows.value.map((row) => ({ ...row, channel: channelSnapshot.find((channel) => channel.id === row.channelId)?.name || row.channel || "" })),
};
}
function buildStepTwo() {
return {
sourceFileName: commentPreview.value.fileName,
totalComments: commentPreview.value.totalComments,
topIssues: [...topIssues.value],
sentiments: sentiments.value.map((row) => ({ ...row })),
rootCauseAnalysis: rootCauseAnalysis.value,
wordCloud: wordCloud.value,
previewComments: commentPreview.value.comments,
analysisResult: analysisResult.value ? { ...analysisResult.value } : null,
};
}
function buildStepTwoValidationPayload(stepTwo = buildStepTwo()) {
return {
sourceFileName: stepTwo.sourceFileName,
topIssues: stepTwo.topIssues,
sentiments: stepTwo.sentiments,
rootCauseAnalysis: stepTwo.rootCauseAnalysis,
};
}
function buildStepThree() { return { testPlan: abTestPlan.value, rows: abRows.value.map((row) => ({ ...row })), comparisonChartUrl: abChart.value.url, comparisonChartName: abChart.value.name, iterationRecommendation: iterationRecommendation.value }; }
function addFunnelChannel() {
channelSerial += 1;
const channel = { id: `channel-${Date.now()}-${channelSerial}`, name: "" };
channels.value.push(channel);
conversionRates.value.forEach((row) => { row.channelRates[channel.id] = null; });
}
function removeFunnelChannel(channelId) {
channels.value = channels.value.filter((channel) => channel.id !== channelId);
conversionRates.value.forEach((row) => { delete row.channelRates[channelId]; });
anomalyRows.value.forEach((row) => {
if (row.channelId === channelId) {
row.channelId = "";
row.channel = "";
row.storeConversionRate = null;
}
});
}
function syncAnomalyChannel(row) { row.channel = channels.value.find((channel) => channel.id === row.channelId)?.name.trim() || ""; }
function addAnomalyRow() { if (anomalyRows.value.length < 5) anomalyRows.value.push(createAnomalyRow()); }
function removeAnomalyRow() { if (anomalyRows.value.length > 1) anomalyRows.value.pop(); }
function clearChart() { if (funnelChart.value.url.startsWith("blob:")) URL.revokeObjectURL(funnelChart.value.url); funnelChart.value = { url: "", name: "" }; }
async function handleChartUpload(event) { const file = event.target?.files?.[0]; if (!file) return; const accepted = ["image/jpeg", "image/png", "image/webp"]; if (!accepted.includes(file.type) || file.size > 5 * 1024 * 1024) { proxy?.$modal?.msgError?.("请上传不超过 5MB 的 PNG、JPG 或 WEBP 图片"); event.target.value = ""; return; } uploading.value = true; try { let url = ""; if (isStudentDemo()) url = URL.createObjectURL(file); else { const data = new FormData(); data.append("file", file); const response = await uploadStudentTrainingFile(data); url = response?.url || response?.data?.url || ""; } if (!url) throw new Error("上传成功但未返回图片地址"); clearChart(); funnelChart.value = { url, name: file.name }; proxy?.$modal?.msgSuccess?.("漏斗对比图上传成功,可点击图片预览"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "图片上传失败,请重试"); } finally { uploading.value = false; event.target.value = ""; } }
async function saveStepOneDraft() { await persistStepOne(false); } async function validateStepOne() { if (saving.value) return; saving.value = true; try { const data = buildStepOne(); await validateProductDiagnosisIterationStepOne(data); await saveStudentTrainingAnswer(TASK_KEY, { step1Answer: JSON.stringify(data), currentStep: 2, submitted: false, saveAction: "SAVE" }); currentStep.value = 2; proxy?.$modal?.msgSuccess?.("第 1 步校验通过,已进入第 2 步"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请检查第 1 步填写内容"); } finally { saving.value = false; } } async function persistStepOne(showMessage) { if (saving.value) return; saving.value = true; try { await saveStudentTrainingAnswer(TASK_KEY, { step1Answer: JSON.stringify(buildStepOne()), currentStep: 1, submitted: false, saveAction: "SAVE" }); if (showMessage !== false) proxy?.$modal?.msgSuccess?.("草稿已保存"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "保存失败,请重试"); } finally { saving.value = false; } }
async function handleCommentImport(event) { const file = event.target?.files?.[0]; if (!file) return; if (!/\.(csv|txt|xls|xlsx)$/i.test(file.name) || file.size > 5 * 1024 * 1024) { proxy?.$modal?.msgError?.("请上传不超过 5MB 的 CSV、TXT、XLS 或 XLSX 评论文件"); event.target.value = ""; return; } analyzing.value = true; try { const result = (await analyzeProductDiagnosisIterationStepTwo(file))?.data; if (!result) throw new Error("未获得评论分析结果"); analysisResult.value = result; commentPreview.value = { fileName: result.sourceFileName || file.name, totalComments: result.totalComments || 0, comments: result.previewComments || [] }; wordCloud.value = result.wordCloud || []; topIssues.value = [0, 1, 2].map((index) => result.topIssues?.[index] || ""); sentiments.value = createSentiments(); await nextTick(); renderWordCloud(); proxy?.$modal?.msgSuccess?.("评论数据已导入,词云已生成"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "评论数据分析失败,请检查文件格式"); } finally { analyzing.value = false; event.target.value = ""; } }
function renderWordCloud() { if (!wordCloudRef.value || !wordCloud.value.length) return; wordCloudChart?.dispose(); wordCloudChart = echarts.init(wordCloudRef.value); wordCloudChart.setOption({ backgroundColor: "transparent", tooltip: { show: true }, series: [{ type: "wordCloud", shape: "circle", gridSize: 5, sizeRange: [16, 58], rotationRange: [-35, 35], textStyle: { color: () => ["#53e2ff", "#6ae1bb", "#ffd06a", "#91b8ff", "#ff9e88"][Math.floor(Math.random() * 5)] }, data: wordCloud.value }] }); }
function runSentimentAnalysis() {
if (!analysisResult.value) return;
const counts = [analysisResult.value.positiveCount, analysisResult.value.neutralCount, analysisResult.value.negativeCount].map((value) => Number(value) || 0);
const total = counts.reduce((sum, value) => sum + value, 0);
sentiments.value = ["正面", "中性", "负面"].map((type, index) => ({
type,
count: counts[index],
percentage: total > 0 ? Number((counts[index] * 100 / total).toFixed(2)) : null,
}));
proxy?.$modal?.msgSuccess?.("情感评论数量与占比已自动计算");
}
async function saveStepTwoDraft() { if (saving.value) return false; saving.value = true; try { await saveStudentTrainingAnswer(TASK_KEY, { step2Answer: JSON.stringify(buildStepTwo()), currentStep: 2, submitted: false, saveAction: "SAVE" }); proxy?.$modal?.msgSuccess?.("当前步骤已保存"); return true; } catch (error) { proxy?.$modal?.msgError?.(error?.message || "保存失败,请重试"); return false; } finally { saving.value = false; } } async function validateStepTwo() { if (saving.value) return false; saving.value = true; try { const data = buildStepTwo(); await validateProductDiagnosisIterationStepTwo(buildStepTwoValidationPayload(data)); await saveStudentTrainingAnswer(TASK_KEY, { step2Answer: JSON.stringify(data), currentStep: 2, submitted: false, saveAction: "SAVE" }); proxy?.$modal?.msgSuccess?.("第 2 步校验通过,已保存"); return true; } catch (error) { proxy?.$modal?.msgError?.(diagnosisErrorMessage(error, "请检查词云、情感占比和根因分析")); return false; } finally { saving.value = false; } }
function clearAbChart() { if (abChart.value.url.startsWith("blob:")) URL.revokeObjectURL(abChart.value.url); abChart.value = { url: "", name: "" }; }
async function handleAbChartUpload(event) { const file = event.target?.files?.[0]; if (!file) return; const accepted = ["image/jpeg", "image/png", "image/webp"]; if (!accepted.includes(file.type) || file.size > 5 * 1024 * 1024) { proxy?.$modal?.msgError?.("请上传不超过 5MB 的 PNG、JPG 或 WEBP 图片"); event.target.value = ""; return; } uploading.value = true; try { let url = ""; if (isStudentDemo()) url = URL.createObjectURL(file); else { const data = new FormData(); data.append("file", file); const response = await uploadStudentTrainingFile(data); url = response?.url || response?.data?.url || ""; } if (!url) throw new Error("上传成功但未返回图片地址"); clearAbChart(); abChart.value = { url, name: file.name }; proxy?.$modal?.msgSuccess?.("AB 测试结果对比图上传成功,可点击图片预览"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "图片上传失败,请重试"); } finally { uploading.value = false; event.target.value = ""; } }
async function saveStepThreeDraft() { if (saving.value) return; saving.value = true; try { await saveStudentTrainingAnswer(TASK_KEY, { step3Answer: JSON.stringify(buildStepThree()), currentStep: 3, submitted: false, saveAction: "SAVE" }); proxy?.$modal?.msgSuccess?.("草稿已保存"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "保存失败,请重试"); } finally { saving.value = false; } }
async function saveCurrentProgress() {
if (currentStep.value === 1) return persistStepOne(true);
if (currentStep.value === 2) return saveStepTwoDraft();
return saveStepThreeDraft();
}
async function nextStep() {
if (currentStep.value === 1) return validateStepOne();
if (currentStep.value === 2 && await validateStepTwo()) currentStep.value = 3;
}
async function validateStepThree() { if (saving.value) return; saving.value = true; try { const data = buildStepThree(); await validateProductDiagnosisIterationStepThree(data); await saveStudentTrainingAnswer(TASK_KEY, { step3Answer: JSON.stringify(data), currentStep: 3, submitted: false, saveAction: "SAVE" }); proxy?.$modal?.msgSuccess?.("第 3 步校验通过,已保存"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请检查 AB 测试方案、指标数据和对比图"); } finally { saving.value = false; } }
async function submitTask() {
if (saving.value || uploading.value || analyzing.value) return false;
saving.value = true;
try {
const stepOne = buildStepOne();
await validateProductDiagnosisIterationStepOne(stepOne);
await saveStudentTrainingAnswer(TASK_KEY, { step1Answer: JSON.stringify(stepOne), currentStep: 1, submitted: false, saveAction: "SAVE" });
const stepTwo = buildStepTwo();
await validateProductDiagnosisIterationStepTwo(buildStepTwoValidationPayload(stepTwo));
await saveStudentTrainingAnswer(TASK_KEY, { step2Answer: JSON.stringify(stepTwo), currentStep: 2, submitted: false, saveAction: "SAVE" });
const stepThree = buildStepThree();
await validateProductDiagnosisIterationStepThree(stepThree);
await saveStudentTrainingAnswer(TASK_KEY, { step3Answer: JSON.stringify(stepThree), currentStep: 3, submitted: true, saveAction: "SUBMIT" });
currentStep.value = 3;
proxy?.$modal?.msgSuccess?.("实训任务已提交");
return true;
} catch (error) {
proxy?.$modal?.msgError?.(diagnosisErrorMessage(error, "提交失败,请检查三个步骤的填写内容"));
return false;
} finally {
saving.value = false;
}
}
async function resetTraining() {
if (saving.value || uploading.value || analyzing.value) return false;
try {
await deleteStudentTrainingAnswer(TASK_KEY);
clearChart(); clearAbChart(); wordCloudChart?.dispose(); wordCloudChart = null;
channels.value = createDefaultChannels(); conversionRates.value = createConversionRates(); anomalyRows.value = [createAnomalyRow()]; analysisResult.value = null;
commentPreview.value = { fileName: "", totalComments: 0, comments: [] }; wordCloud.value = []; topIssues.value = ["", "", ""]; sentiments.value = createSentiments(); rootCauseAnalysis.value = "";
abTestPlan.value = ""; abRows.value = createAbRows(); iterationRecommendation.value = ""; currentStep.value = 1;
proxy?.$modal?.msgSuccess?.("已清空全部实训步骤,可以重新开始");
return true;
} catch (error) {
proxy?.$modal?.msgError?.(error?.message || "重新实训失败,请稍后重试");
return false;
}
}
function exportTrainingOutcome() {
const outcome = { taskName: taskTitle.value, exportedAt: new Date().toISOString(), step1: buildStepOne(), step2: buildStepTwo(), step3: buildStepThree() };
const url = URL.createObjectURL(new Blob([JSON.stringify(outcome, null, 2)], { type: "application/json;charset=utf-8" }));
const link = document.createElement("a"); link.href = url; link.download = "产品问题诊断与迭代-实训成果.json"; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url);
proxy?.$modal?.msgSuccess?.("实训成果已导出");
}
function restoreStepOne(stepOne) {
const hasSavedChannels = Array.isArray(stepOne.channels);
const seenIds = new Set();
const restoredChannels = hasSavedChannels
? stepOne.channels.map((channel) => ({ id: String(channel?.id || "").trim(), name: String(channel?.name || "").trim() })).filter((channel) => {
if (!channel.id || seenIds.has(channel.id)) return false;
seenIds.add(channel.id);
return true;
})
: createDefaultChannels();
channels.value = restoredChannels;
if (Array.isArray(stepOne.conversionRates) && stepOne.conversionRates.length === nodes.length) {
conversionRates.value = nodes.map((linkNode) => {
const saved = stepOne.conversionRates.find((row) => row?.linkNode === linkNode) || {};
return {
linkNode,
channelRates: Object.fromEntries(restoredChannels.map((channel) => [channel.id, saved.channelRates?.[channel.id] ?? saved[channel.id] ?? null])),
industryAverage: saved.industryAverage ?? null,
};
});
} else {
conversionRates.value = createConversionRates(restoredChannels);
}
if (Array.isArray(stepOne.anomalyRows) && stepOne.anomalyRows.length) {
anomalyRows.value = stepOne.anomalyRows.slice(0, 5).map((row) => {
const channelId = String(row?.channelId || restoredChannels.find((channel) => channel.name === row?.channel)?.id || "");
return { ...createAnomalyRow(), ...row, channelId };
});
}
if (stepOne.funnelChartUrl) funnelChart.value = { url: stepOne.funnelChartUrl, name: stepOne.funnelChartName || "节点转化漏斗对比图" };
}
function parseSavedStep(value) {
if (!value) return {};
if (typeof value === "object") return value;
try { return JSON.parse(value); } catch (error) { return {}; }
}
function restoreStepTwo(stepTwo) {
if (!stepTwo || typeof stepTwo !== "object" || !Object.keys(stepTwo).length) return;
const savedAnalysis = stepTwo.analysisResult && typeof stepTwo.analysisResult === "object" ? stepTwo.analysisResult : {};
const restoredWordCloud = Array.isArray(stepTwo.wordCloud) ? stepTwo.wordCloud : Array.isArray(savedAnalysis.wordCloud) ? savedAnalysis.wordCloud : [];
const restoredComments = Array.isArray(stepTwo.previewComments) ? stepTwo.previewComments : Array.isArray(savedAnalysis.previewComments) ? savedAnalysis.previewComments : [];
const restoredTopIssues = [0, 1, 2].map((index) => stepTwo.topIssues?.[index] || savedAnalysis.topIssues?.[index] || "");
const savedSentiments = Array.isArray(stepTwo.sentiments) ? stepTwo.sentiments : [];
sentiments.value = createSentiments().map((row) => ({ ...row, ...(savedSentiments.find((item) => item?.type === row.type) || {}) }));
const count = (type, analysisKey) => {
const analysisCount = Number(savedAnalysis[analysisKey]);
if (Number.isFinite(analysisCount)) return analysisCount;
const sentimentCount = Number(sentiments.value.find((row) => row.type === type)?.count);
return Number.isFinite(sentimentCount) ? sentimentCount : 0;
};
const positiveCount = count("正面", "positiveCount");
const neutralCount = count("中性", "neutralCount");
const negativeCount = count("负面", "negativeCount");
const sourceFileName = String(stepTwo.sourceFileName || savedAnalysis.sourceFileName || "");
const inferredTotal = positiveCount + neutralCount + negativeCount;
const savedTotal = Number(stepTwo.totalComments ?? savedAnalysis.totalComments);
const totalComments = Number.isFinite(savedTotal) ? savedTotal : inferredTotal;
commentPreview.value = { fileName: sourceFileName, totalComments, comments: restoredComments };
wordCloud.value = restoredWordCloud;
topIssues.value = restoredTopIssues;
rootCauseAnalysis.value = stepTwo.rootCauseAnalysis || "";
if (sourceFileName || restoredWordCloud.length || restoredComments.length || restoredTopIssues.some(Boolean)) {
analysisResult.value = { sourceFileName, totalComments, positiveCount, neutralCount, negativeCount, topIssues: restoredTopIssues, wordCloud: restoredWordCloud, previewComments: restoredComments };
}
nextTick(renderWordCloud);
}
function restore(answer) {
const stepOne = parseSavedStep(answer?.step1Answer);
restoreStepOne(stepOne);
restoreStepTwo(parseSavedStep(answer?.step2Answer));
const stepThree = parseSavedStep(answer?.step3Answer);
if (stepThree.testPlan || stepThree.comparisonChartUrl || stepThree.iterationRecommendation) { abTestPlan.value = stepThree.testPlan || ""; if (Array.isArray(stepThree.rows) && stepThree.rows.length === createAbRows().length) abRows.value = createAbRows().map((row) => ({ ...row, ...(stepThree.rows.find((item) => item?.metric === row.metric) || {}) })); if (stepThree.comparisonChartUrl) abChart.value = { url: stepThree.comparisonChartUrl, name: stepThree.comparisonChartName || "AB 测试结果对比图" }; iterationRecommendation.value = stepThree.iterationRecommendation || ""; }
currentStep.value = [1, 2, 3].includes(Number(answer?.currentStep)) ? Number(answer.currentStep) : 1;
}
onMounted(async () => { try { taskConfig.value = (await getTrainingTaskByKey(TASK_KEY))?.data || null; } catch (error) { taskConfig.value = null; } try { restore((await getStudentTrainingAnswer(TASK_KEY))?.data); } catch (error) {} }); onBeforeUnmount(() => { if (funnelChart.value.url.startsWith("blob:")) URL.revokeObjectURL(funnelChart.value.url); if (abChart.value.url.startsWith("blob:")) URL.revokeObjectURL(abChart.value.url); wordCloudChart?.dispose(); });
4 months ago
</script>
<style scoped lang="scss">
/* ===== 页面级令牌(墨黑极简主题) ===== */
.diagnosis-page {
--student-accent: #2563eb;
--student-accent-deep: #1d4ed8;
--student-accent-light: #3b82f6;
--student-accent-soft: #eff6ff;
--student-danger: #ef4444;
--student-success: #2ba471;
--student-layer-1: #1d4ed8;
--student-layer-2: #3b82f6;
--student-layer-3: #93c5fd;
--student-panel-border: #e5e7eb;
--student-card-bg: #ffffff;
--student-panel-bg: #fafbfc;
--student-text: #1f2937;
--student-text-secondary: #6b7280;
--student-fs-display: 17px;
--student-fs-h1: 15px;
--student-fs-h2: 14px;
--student-fs-h3: 13px;
--student-fs-body: 13px;
--student-fs-body-sm: 12px;
--student-fs-caption: 11px;
--student-fs-button: 13px;
--student-fs-button-lg: 13px;
--student-fs-data: 14px;
/* 不设 grid-template-columns,由全局 .student-training-shell 单列布局接管 */
min-width: 0;
color: var(--student-text);
background: #ffffff;
padding: 24px;
}
/* 对齐 foundation 的 .training-core:只设 gap,无边框/背景/阴影 */
.diagnosis-core {
display: grid;
gap: 10px;
align-content: start;
min-width: 0;
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
}
/* 任务头:独立卡片 */
.task-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin: 0;
padding: 16px;
border: 1px solid #e7e9ed;
border-radius: 16px;
background: #ffffff;
}
.task-head-main {
display: grid;
justify-items: start;
gap: 8px;
min-width: 0;
flex: 1 1 auto;
}
.task-badge {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 4px 12px;
border: 1px solid var(--student-panel-border);
border-radius: 999px;
color: var(--student-accent-deep);
background: var(--student-accent-soft);
font-size: 12px;
font-weight: 700;
}
.task-title {
margin: 0;
color: var(--student-text);
font-size: var(--student-fs-display);
font-weight: 700;
line-height: 1.35;
}
.task-brief {
margin: 0;
color: var(--student-text-secondary);
font-size: 12px;
line-height: 1.6;
}
.task-brief-text {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.task-brief-toggle {
padding: 0;
border: 0;
color: var(--student-accent-deep);
background: transparent;
font: inherit;
font-weight: 700;
cursor: pointer;
&:hover { text-decoration: underline; }
}
.task-brief-more {
display: grid;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--student-panel-border);
b { color: var(--student-text); font-size: 12px; }
ul { display: grid; gap: 4px; margin: 0; padding-left: 18px; }
p { margin: 0; }
}
.diagnosis-toolbar {
flex: 0 0 auto;
justify-content: flex-end;
max-width: 100%;
align-self: center;
}
/* workbench:透明容器,去掉旧的 border/bg */
.workbench {
display: grid;
gap: 10px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
margin-top: 0;
}
/* lead 描述 */
.lead {
margin: 0 0 14px;
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
line-height: 1.6;
}
/* section-block:透明化 */
.section-block {
margin-top: 14px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.section-heading {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 10px;
> span {
display: grid;
flex: 0 0 28px;
width: 28px;
height: 28px;
place-items: center;
border-radius: 6px;
color: #ffffff;
background: var(--student-accent);
font-size: 13px;
font-weight: 700;
}
h3 {
margin: 0;
color: var(--student-text);
font-size: var(--student-fs-h1);
font-weight: 700;
}
p {
margin: 4px 0 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
line-height: 1.6;
}
}
/* ===== 按钮(墨黑极简) ===== */
.secondary-button,
.table-button,
.upload-button,
.submit-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid rgba(37, 99, 235, 0.45);
border-radius: 8px;
color: var(--student-text);
background: #ffffff;
font: inherit;
font-size: var(--student-fs-button);
font-weight: 700;
cursor: pointer;
text-shadow: none;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, transform 0.16s ease, box-shadow 0.16s ease;
&:hover:not(:disabled) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
color: var(--student-accent);
transform: translateY(-1px);
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.12);
}
}
.submit-button {
border-color: var(--student-accent);
color: #ffffff;
background: var(--student-accent);
font-weight: 700;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.28);
&:hover:not(:disabled) {
border-color: var(--student-accent-deep);
background: var(--student-accent-deep);
color: #ffffff;
transform: translateY(-1px);
box-shadow: 0 5px 14px rgba(29, 78, 216, 0.32);
}
}
.table-button.ghost {
border-color: rgba(37, 99, 235, 0.45);
color: var(--student-text-secondary);
background: #ffffff;
font-weight: 600;
}
button:disabled,
button.is-disabled {
cursor: not-allowed;
opacity: 0.5;
transform: none;
box-shadow: none;
}
/* ===== 表格 ===== */
.table-wrap {
overflow-x: auto;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
}
.funnel-table,
.diagnosis-table,
.sentiment-table,
.ab-table {
width: 100%;
border-collapse: collapse;
}
.funnel-table { min-width: 900px; }
.diagnosis-table { min-width: 1060px; }
.sentiment-table { min-width: 640px; }
.ab-table { min-width: 860px; }
.funnel-table th, .funnel-table td,
.diagnosis-table th, .diagnosis-table td,
.sentiment-table th, .sentiment-table td,
.ab-table th, .ab-table td {
padding: 10px 12px;
border-right: 1px solid var(--student-panel-border);
border-bottom: 1px solid var(--student-panel-border);
vertical-align: middle;
text-align: center;
}
.funnel-table thead th,
.diagnosis-table thead th,
.sentiment-table thead th,
.ab-table thead th {
color: var(--student-text);
background: #fafbfc;
font-size: var(--student-fs-body-sm);
font-weight: 800;
white-space: nowrap;
}
.funnel-table tbody th,
.sentiment-table tbody th,
.ab-table tbody th {
color: var(--student-text);
background: transparent;
font-weight: 700;
text-align: center;
}
.funnel-table td,
.diagnosis-table td,
.sentiment-table td,
.ab-table td {
background: transparent;
}
.ab-table thead th { padding: 12px 10px; font-size: var(--student-fs-h2); }
.ab-table tbody th { width: 25%; padding: 12px 10px; }
.ab-table td { padding: 10px 11px; }
.ab-table td:nth-child(2), .ab-table td:nth-child(3) { width: 18%; }
.ab-table td:last-child { width: 39%; }
/* 表格内组件 */
.funnel-table :deep(.el-input-number),
.diagnosis-table :deep(.el-input-number),
.sentiment-table :deep(.el-input-number),
.diagnosis-table :deep(.el-select) { width: 100%; }
.unit {
margin-left: 5px;
color: var(--student-text-secondary);
font-size: 12px;
font-weight: 700;
}
.auto-count {
color: var(--student-accent);
font-size: 16px;
font-weight: 800;
}
.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;
:deep(.el-input) { min-width: 120px; }
:deep(.el-input__wrapper) {
background: #ffffff;
box-shadow: 0 0 0 1px var(--student-panel-border) inset;
}
:deep(.el-input__inner) {
color: var(--student-text);
text-align: center;
font-weight: 600;
}
}
.channel-remove {
display: inline-grid;
flex: 0 0 24px;
width: 24px;
height: 24px;
place-items: center;
padding: 0;
border: 1px solid rgba(217, 85, 76, 0.4);
border-radius: 6px;
color: var(--student-danger);
background: rgba(217, 85, 76, 0.08);
font: inherit;
font-size: 17px;
line-height: 1;
cursor: pointer;
&:hover, &:focus-visible {
border-color: var(--student-danger);
color: #ffffff;
background: var(--student-danger);
outline: none;
}
}
.rate-input {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
white-space: nowrap;
:deep(.el-input-number) {
flex: 1 1 auto;
width: calc(100% - 22px);
min-width: 0;
}
.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(--student-text-secondary);
font-size: var(--student-fs-body);
> div,
.actions,
.diagnosis-materials__actions,
.ab-materials__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
}
.actions {
justify-content: center;
margin-top: 20px;
.secondary-button,
.submit-button {
min-width: 142px;
min-height: 36px;
height: 36px;
}
}
/* ===== 参考资料区 ===== */
.diagnosis-materials {
display: grid;
gap: 10px;
margin: 14px 0;
&__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 12px 14px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: var(--student-accent-soft);
strong, span { display: block; }
strong {
color: var(--student-text);
font-size: 13px;
font-weight: 600;
}
span {
margin-top: 4px;
color: var(--student-text-secondary);
font-size: 12px;
}
}
&__ai {
margin: 0;
padding: 10px 14px;
border-left: 3px solid var(--student-accent);
background: var(--student-accent-soft);
color: var(--student-text-secondary);
font-size: 13px;
line-height: 1.7;
border-radius: 0 8px 8px 0;
}
&__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
}
.diagnosis-section .table-toolbar > span { display: none; }
.diagnosis-step-one-notice { display: none; }
@media (max-width: 860px) {
.diagnosis-materials__item {
align-items: flex-start;
flex-direction: column;
}
.diagnosis-materials__actions { justify-content: flex-start; }
}
/* ===== AB 测试资料区 ===== */
.ab-materials {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-top: 14px;
padding: 12px 14px;
border-left: 3px solid var(--student-accent);
background: var(--student-accent-soft);
border-radius: 0 8px 8px 0;
color: var(--student-text-secondary);
ol {
margin: 0;
padding-left: 20px;
line-height: 1.75;
font-size: var(--student-fs-body);
}
&__actions {
display: flex;
flex-wrap: wrap;
flex: 0 0 auto;
gap: 8px;
}
}
@media (max-width: 860px) {
.ab-materials {
align-items: stretch;
flex-direction: column;
}
}
/* ===== 上传区 ===== */
.chart-upload-area,
.comment-import {
display: grid;
place-items: center;
gap: 10px;
min-height: 180px;
padding: 22px;
border: 1px dashed var(--student-panel-border);
border-radius: 12px;
background: var(--student-panel-bg);
text-align: center;
p {
margin: 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
}
}
.upload-button {
min-height: 36px;
height: 36px;
padding: 0 14px;
border-radius: 8px;
}
.chart-preview {
display: flex;
align-items: center;
gap: 16px;
width: 100%;
text-align: left;
:deep(.el-image) {
width: 180px;
height: 110px;
border: 1px solid var(--student-panel-border);
border-radius: 8px;
}
> div {
display: grid;
gap: 7px;
}
strong { color: var(--student-text); }
span { color: var(--student-text-secondary); font-size: var(--student-fs-body-sm); }
button {
width: max-content;
padding: 0;
border: 0;
color: var(--student-danger);
background: transparent;
font: inherit;
cursor: pointer;
&:hover { text-decoration: underline; }
}
}
/* ===== 词云 / 数据预览 ===== */
.data-preview,
.word-cloud-card,
.top-issues,
.root-cause {
margin-top: 14px;
padding: 14px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: #ffffff;
}
.data-preview > div,
.word-cloud-title {
display: flex;
justify-content: space-between;
gap: 12px;
color: var(--student-text);
font-weight: 600;
span, small {
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
font-weight: 400;
}
}
.data-preview ul {
max-height: 130px;
margin: 12px 0 0;
padding-left: 20px;
overflow: auto;
color: var(--student-text);
line-height: 1.7;
text-align: left;
}
.word-cloud { height: 280px; }
.top-issues > p,
.root-cause > p {
margin: 0 0 10px;
color: var(--student-text);
font-size: var(--student-fs-body);
line-height: 1.6;
}
.top-issues > div {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.top-issues label {
display: grid;
gap: 6px;
color: var(--student-text);
font-weight: 600;
font-size: var(--student-fs-body-sm);
}
.sentiment-actions {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 14px;
> span {
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
}
}
.visual-section,
.diagnosis-section { margin-top: 14px; }
/* ===== Element Plus 输入框换肤 ===== */
.diagnosis-page {
:deep(.el-input__wrapper),
:deep(.el-textarea__inner) {
background: transparent;
box-shadow: 0 0 0 1px var(--student-panel-border) inset;
}
:deep(.el-input__inner),
:deep(.el-textarea__inner) {
color: var(--student-text);
}
:deep(.el-textarea__inner) {
line-height: 1.6;
}
:deep(.el-input__wrapper.is-focus),
:deep(.el-textarea__inner:focus) {
box-shadow: 0 0 0 1px var(--student-accent) inset, 0 0 0 2px rgba(37, 99, 235, 0.15) !important;
}
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
clip-path: inset(50%);
}
/* ===== 响应式 ===== */
@media (max-width: 900px) {
.diagnosis-page { padding: 12px; }
.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; }
}
@media (max-width: 720px) {
.task-header { flex-direction: column; align-items: flex-start; }
.diagnosis-toolbar { width: 100%; justify-content: flex-start; }
.table-toolbar,
.sentiment-actions {
align-items: stretch;
flex-direction: column;
}
.actions {
flex-direction: column;
.secondary-button,
.submit-button { width: 100%; }
}
}
</style>
<style lang="scss">
/* ===== 步骤导航:按钮切换风 ===== */
#app .studentLayout section.app-main:has(.diagnosis-page) .step-indicator {
display: flex !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: center !important;
gap: 10px !important;
width: 100% !important;
margin: 4px 0 16px !important;
padding: 0 !important;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
.step-connector { display: none !important; }
.step-tab {
flex: 0 0 auto !important;
min-width: auto !important;
max-width: none !important;
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
height: auto !important;
min-height: 42px !important;
margin: 0 !important;
padding: 8px 14px !important;
border: 1px solid #e5e7eb !important;
border-radius: 8px !important;
background: #ffffff !important;
box-shadow: none !important;
color: #6b7280 !important;
font-size: 13px !important;
font-weight: 600 !important;
line-height: 1.3 !important;
letter-spacing: 0 !important;
white-space: nowrap !important;
text-align: center !important;
cursor: pointer;
transition: background-color 0.16s ease, color 0.16s ease, border-color 0.16s ease !important;
.step-marker {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
flex: 0 0 auto !important;
min-width: 22px !important;
width: auto !important;
height: 22px !important;
padding: 0 5px !important;
border: none !important;
border-radius: 6px !important;
background: #f3f4f6 !important;
color: #6b7280 !important;
font-family: ui-monospace, "SF Mono", Consolas, monospace !important;
font-size: 12px !important;
font-weight: 700 !important;
line-height: 1 !important;
transition: background-color 0.16s ease, color 0.16s ease !important;
}
.step-copy {
display: flex !important;
flex-direction: column !important;
gap: 0 !important;
min-width: 0 !important;
}
.step-name {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: normal !important;
line-height: 1.3 !important;
color: inherit !important;
font-size: 13px !important;
font-weight: 600 !important;
}
.step-action { display: none !important; }
/* 未到达项 hover:教育蓝边浅底 */
&:hover:not(.active):not(.complete) {
border-color: #2563eb !important;
background: #eff6ff !important;
color: #2563eb !important;
.step-marker { background: #2563eb !important; color: #ffffff !important; }
}
/* 已完成:绿色编号徽章 */
&.complete:not(.active) {
border-color: #e5e7eb !important;
color: #374151 !important;
.step-marker { background: rgba(43, 164, 113, 0.12) !important; color: #16915f !important; }
&:hover {
border-color: #2ba471 !important;
background: #f0fdf6 !important;
.step-marker { background: #2ba471 !important; color: #ffffff !important; }
}
}
/* 当前步骤:教育蓝实底 */
&.active {
border-color: #2563eb !important;
background: #2563eb !important;
color: #ffffff !important;
font-weight: 700 !important;
.step-marker { background: #ffffff !important; color: #2563eb !important; }
&:hover {
background: #1d4ed8 !important;
border-color: #1d4ed8 !important;
}
}
}
}
/* ===== 窄屏 ===== */
@media (max-width: 900px) {
#app .studentLayout section.app-main:has(.diagnosis-page) .step-indicator {
justify-content: flex-start !important;
gap: 8px !important;
overflow-x: auto !important;
}
#app .studentLayout section.app-main:has(.diagnosis-page) .step-indicator .step-tab {
flex: 0 0 auto !important;
max-width: none !important;
}
}
/* ===== 顶部任务工具栏换肤 ===== */
.diagnosis-page .training-task-toolbar { gap: 8px; }
.diagnosis-page .task-toolbar-button.el-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid rgba(37, 99, 235, 0.45);
border-radius: 8px;
color: #2563eb;
background: #ffffff;
box-shadow: none;
text-shadow: none;
font-size: 13px;
font-weight: 600;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease;
.el-icon {
font-size: 14px;
color: inherit;
}
&:hover, &:focus {
border-color: #2563eb;
color: #2563eb;
background: #eff6ff;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12);
}
}
.diagnosis-page .task-toolbar-button--submit.el-button {
border-color: #2563eb;
color: #ffffff;
background: #2563eb;
box-shadow: 0 1px 2px rgba(37, 99, 235, 0.35), 0 3px 10px rgba(37, 99, 235, 0.22);
&:hover, &:focus {
border-color: #1d4ed8;
color: #ffffff;
background: #1d4ed8;
box-shadow: 0 2px 4px rgba(37, 99, 235, 0.4), 0 5px 14px rgba(29, 78, 216, 0.28);
}
}
.diagnosis-page .task-toolbar-button.el-button.is-loading {
border-color: rgba(37, 99, 235, 0.45);
color: #2563eb;
background: #ffffff;
}
</style>