< 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" / >
< section class = "workbench" >
< div class = "workbench-top" > < div > < p > 产品问题诊断与迭代 < / p > < h2 > { { currentStep === 1 ? '全链路转化漏斗诊断' : currentStep === 2 ? '用户评论根因分析' : 'AB 测试验证方案' } } < / h2 > < / div > < TrainingMaterialButton :material-name ="taskConfig?.materialName" :material-url ="taskConfig?.materialUrl" / > < / div >
< nav class = "step-tabs" aria -label = " 产 品 问 题 诊 断 步 骤 " > < button type = "button" : class = "{ active: currentStep === 1 }" @ click = "currentStep = 1" > < b > 01 < / b > < span > 全链路漏斗诊断 < small > 转化率与异常节点 < / small > < / span > < / button > < i > < / i > < button type = "button" : class = "{ active: currentStep === 2 }" @ click = "currentStep = 2" > < b > 02 < / b > < span > 用户评论根因分析 < small > 词云与情感倾向 < / small > < / span > < / button > < i > < / i > < button type = "button" : class = "{ active: currentStep === 3 }" @ click = "currentStep = 3" > < b > 03 < / b > < span > AB 测试验证 < small > 改进方案有效性 < / small > < / span > < / button > < / nav >
< template v-if ="currentStep === 1" >
< p class = "lead" > 从曝光到复购逐层计算渠道转化率 , 并与行业均值比较 , 定位产品运营环节中的异常流失 。 < / p >
< section class = "section-block" > < div class = "section-heading" > < span > 一 < / span > < div > < h3 > 计算各节点转化率 < / h3 > < p > 根据案例数据填写各渠道链路节点转化率 , 数值以百分比 ( % ) 填写 。 < / p > < / div > < / div > < div class = "table-wrap" > < table class = "funnel-table" > < thead > < tr > < th > 链路节点 < / th > < th > 天猫 < / th > < th > 京东 < / th > < th > 抖音 < / th > < th > 拼多多 < / th > < th > 行业均值 < / th > < / tr > < / thead > < tbody > < tr v-for ="row in conversionRates" :key="row.linkNode"><th>{{ row.linkNode }}</th><td v-for="field in rateFields" :key ="field" > < el -input -number v-model ="row[field]" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit" > % < / span > < / td > < / tr > < / tbody > < / table > < / div > < / section >
< section class = "section-block visual-section" > < div class = "section-heading" > < span > 二 < / span > < div > < h3 > 全链路转化漏斗可视化 < / h3 > < p > 根据上述转化率绘制各渠道与行业均值的节点转化漏斗对比图 。 < / p > < / div > < / div > < input ref = "chartInput" class = "visually-hidden" type = "file" accept = "image/png,image/jpeg,image/webp" @change ="handleChartUpload" / > < div class = "chart-upload-area" > < template v-if ="funnelChart.url" > < div class = "chart-preview" > < el -image :src ="funnelChart.url" :preview-src-list ="[funnelChart.url]" fit = "cover" preview -teleported / > < div > < strong > { { funnelChart . name || '节点转化漏斗对比图' } } < / strong > < span > 点击缩略图可放大预览 < / span > < button type = "button" @click ="clearChart" > 移 除 图 片 < / button > < / div > < / div > < button type = "button" class = "secondary-button" :disabled ="uploading" @click ="chartInput?.click()" > {{ uploading ? ' 上 传 中 … ' : ' 更 换 对 比 图 ' }} < / button > < / template > < template v-else > < button type = "button" class = "upload-button" :disabled ="uploading" @click ="chartInput?.click()" > {{ uploading ? ' 上 传 中 … ' : ' 上 传 节 点 转 化 漏 斗 对 比 图 ' }} < / button > < p > 支持 PNG 、 JPG 、 WEBP 格式 , 图片不超过 5 MB ; 上传后可点击预览 。 < / p > < / template > < / div > < / section >
< section class = "section-block diagnosis-section" > < div class = "section-heading" > < span > 三 < / span > < div > < h3 > 定位异常节点 < / h3 > < p > 对比行业均值数据 , 定位异常节点并填写分析与判定 。 < / p > < / div > < / div > < div class = "table-toolbar" > < span > 默认 1 行 , 最多 5 行 ; 渠道必须来源于上表 。 < / span > < div > < button type = "button" class = "table-button" : disabled = "anomalyRows.length >= 5" @click ="addAnomalyRow" > 增 加 一 行 < / button > < button type = "button" class = "table-button ghost" : disabled = "anomalyRows.length <= 1" @click ="removeAnomalyRow" > 删 除 一 行 < / button > < / div > < / div > < div class = "table-wrap" > < table class = "diagnosis-table" > < thead > < tr > < th > 渠道 < / th > < th > 链路节点 < / th > < th > 店铺转化率 < / th > < th > 行业均值转化率 < / th > < th > 异常分析与判定 < / th > < / tr > < / thead > < tbody > < tr v-for ="(row, index) in anomalyRows" :key ="index" > < td > < el -select v-model ="row.channel" placeholder="请选择"><el-option v-for="channel in channels" :key="channel.key" :label="channel.label" :value="channel.label" /></el-select></td><td><el-select v-model="row.linkNode" placeholder="请选择"><el-option v-for="node in nodes" :key="node" :label="node" :value="node" /></el-select></td><td><el-input-number v-model="row.storeConversionRate" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit">%</span></td><td><el-input-number v-model="row.industryAverageConversionRate" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit" > % < / span > < / td > < td > < el -input v -model = " row.anomalyAnalysis " type = "textarea" :rows ="2" maxlength = "300" show -word -limit placeholder = "请填写异常原因或正常判定" / > < / td > < / tr > < / tbody > < / table > < / div > < / section >
< div class = "actions" > < button type = "button" class = "secondary-button" :disabled ="saving" @click ="saveStepOneDraft" > 保 存 草 稿 < / button > < button type = "button" class = "submit-button" : disabled = "saving || uploading" @click ="validateStepOne" > {{ saving ? ' 校 验 中 … ' : ' 校 验 并 进 入 第 2 步 ' }} < / button > < / div >
< / template >
< template v -else -if = " currentStep = = = 2 " >
< p class = "lead" > 漏斗只能定位 “ 哪个环节出了问题 ” , 本步骤通过词云和情感倾向分析 , 从真实用户评价中提取高频问题 , 验证异常背后的根本原因 。 < / p >
< section class = "section-block" > < div class = "section-heading" > < span > 一 < / span > < div > < h3 > 词云分析 < / h3 > < p > 导入评论数据 , 系统自动生成高频问题词云 。 < / p > < / div > < / div > < input ref = "commentInput" class = "visually-hidden" type = "file" accept = ".csv,.txt,.xls,.xlsx,text/csv,text/plain,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" @change ="handleCommentImport" / > < div class = "comment-import" > < button type = "button" class = "upload-button" :disabled ="analyzing" @click ="commentInput?.click()" > {{ analyzing ? ' 正 在 生 成 词 云 … ' : ' 导 入 评 论 数 据 生 成 词 云 ' }} < / button > < p > 支持 CSV 、 TXT 、 XLS 、 XLSX , 文件不超过 5 MB 。 < / p > < div v-if ="commentPreview.fileName" class="data-preview"><div><strong>{{ commentPreview.fileName }}</strong><span>已解析 {{ commentPreview.totalComments }} 条评论 · 数据预览</span></div><ul><li v-for="(comment, index) in commentPreview.comments" :key="index">{{ comment }}</li></ul></div></div><div v-if="wordCloud.length" class="word-cloud-card"><div class="word-cloud-title"><span>高频问题词云</span><small>基于导入评论自动生成</small></div><div ref="wordCloudRef" class="word-cloud" aria-label="评论高频问题词云"></div></div><div class="top-issues"><p>请根据词云图填写词云中出现频次最高的 TOP3 问题。</p><div><label v-for="(_, index) in topIssues" :key ="index" > TOP {{ index + 1 }} < el -input v -model = " topIssues [ index ] " : placeholder = "`填写第 ${index + 1} 个高频问题`" / > < / label > < / div > < / div > < / section >
< section class = "section-block" > < div class = "section-heading" > < span > 二 < / span > < div > < h3 > 情感倾向分析 < / h3 > < p > 点击分析后 , 系统自动填充评论数量 ; 请自行计算占比 , 保留两位小数 。 < / p > < / div > < / div > < div class = "sentiment-actions" > < button type = "button" class = "upload-button" :disabled ="!analysisResult" @click ="runSentimentAnalysis" > 情 感 倾 向 分 析 < / button > < span > 评论数量由系统填充 ; 系统仅校验学生填写的占比 。 < / span > < / div > < div class = "table-wrap" > < table class = "sentiment-table" > < thead > < tr > < th > 情感类型 < / th > < th > 评论数量 ( 条 ) < / th > < th > 占比 < / th > < / tr > < / thead > < tbody > < tr v-for ="row in sentiments" :key="row.type"><th>{{ row.type }}</th><td><span class="auto-count">{{ row.count ?? '—' }}</span></td><td><el-input-number v-model="row.percentage" :min="0" :max="100" :precision="2" :controls="false" :disabled="row.count === null" placeholder="请输入" /><span class="unit" > % < / span > < / td > < / tr > < / tbody > < / table > < / div > < / section >
< section class = "section-block root-cause" > < p > 请结合词云 TOP3 与情感分析结果 , 说明本案例的核心问题根本原因 ; 说明高频问题与情感数据是如何验证步骤一漏斗异常结论的 。 < / p > < el -input v -model = " rootCauseAnalysis " type = "textarea" :rows ="6" maxlength = "1000" show -word -limit placeholder = "请填写根因分析与对步骤一异常结论的验证说明" / > < / section >
< div class = "actions" > < button type = "button" class = "secondary-button" :disabled ="saving" @click ="saveStepTwoDraft" > 保 存 草 稿 < / button > < button type = "button" class = "submit-button" : disabled = "saving || analyzing" @click ="validateStepTwo" > {{ saving ? ' 校 验 中 … ' : ' 校 验 并 保 存 第 2 步 ' }} < / button > < / div >
< / template >
< template v-else >
< p class = "lead" > 找到根因后 , 还需要通过 AB 测试验证改进方案是否真正有效 : 将用户随机分为维持原方案的对照组和应用改进方案的实验组 , 用关键指标差异判断方案是否值得落地 。 < / p >
< section class = "section-block" > < div class = "section-heading" > < span > 一 < / 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 > 二 < / span > < div > < h3 > AB 测试数据填报 < / h3 > < p > 填写案例资料中的对照组与实验组数据 , 并逐项给出判定与分析 。 < / p > < / div > < / div > < div class = "table-wrap" > < table class = "ab-table" > < thead > < tr > < th > 测试指标 < / th > < th > 对照组数据 < / th > < th > 实验组数据 < / th > < th > 判定与分析 < / th > < / tr > < / thead > < tbody > < tr v-for ="row in abRows" :key="row.metric"><th>{{ row.metric }}</th><td><el-input-number v-model="row.controlValue" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit">%</span></td><td><el-input-number v-model="row.experimentValue" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit" > % < / span > < / td > < td > < el -input v -model = " row.analysis " type = "textarea" :rows ="2" maxlength = "300" show -word -limit placeholder = "如:提升 8pp, 显著提升, 建议采用" / > < / td > < / tr > < / tbody > < / table > < / div > < / section >
< section class = "section-block" > < div class = "section-heading" > < span > 三 < / span > < div > < h3 > 可视化呈现 < / h3 > < p > 绘制 AB 测试结果对比图并上传 , 作为改进方案的可视化证据 。 < / p > < / div > < / div > < input ref = "abChartInput" class = "visually-hidden" type = "file" accept = "image/png,image/jpeg,image/webp" @change ="handleAbChartUpload" / > < div class = "chart-upload-area" > < template v-if ="abChart.url" > < div class = "chart-preview" > < el -image :src ="abChart.url" :preview-src-list ="[abChart.url]" fit = "cover" preview -teleported / > < div > < strong > { { abChart . name || 'AB 测试结果对比图' } } < / strong > < span > 点击缩略图可放大预览 < / span > < button type = "button" @click ="clearAbChart" > 移 除 图 片 < / button > < / div > < / div > < button type = "button" class = "secondary-button" :disabled ="uploading" @click ="abChartInput?.click()" > {{ uploading ? ' 上 传 中 … ' : ' 更 换 对 比 图 ' }} < / button > < / template > < template v-else > < button type = "button" class = "upload-button" :disabled ="uploading" @click ="abChartInput?.click()" > {{ uploading ? ' 上 传 中 … ' : ' 上 传 AB 测 试 结 果 对 比 图 ' }} < / button > < p > 支持 PNG 、 JPG 、 WEBP 格式 , 图片不超过 5 MB ; 上传后可点击预览 。 < / p > < / template > < / div > < / section >
< section class = "section-block" > < div class = "section-heading" > < span > 四 < / 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 >
< div class = "actions" > < button type = "button" class = "secondary-button" :disabled ="saving" @click ="saveStepThreeDraft" > 保 存 草 稿 < / button > < button type = "button" class = "submit-button" : disabled = "saving || uploading" @click ="validateStepThree" > {{ saving ? ' 校 验 中 … ' : ' 校 验 并 保 存 第 3 步 ' }} < / button > < / div >
< / template >
< / section >
< / main >
< TrainingAiSidebar : study -tip = " currentStep = = = 1 ? ' 先 从 案 例 资 料 提 取 各 渠 道 漏 斗 数 据 , 计 算 转 化 率 并 与 行 业 均 值 逐 层 对 比 。 ' : currentStep = = = 2 ? ' 先 导 入 案 例 评 论 数 据 生 成 词 云 , 再 点 击 情 感 倾 向 分 析 , 并 用 高 频 问 题 与 情 感 占 比 解 释 漏 斗 异 常 。 ' : ' 基 于 前 两 步 结 论 设 计 随 机 分 流 的 AB 测 试 , 用 五 项 指 标 的 组 间 差 异 验 证 改 进 方 案 。 ' " :progress-items ="progressItems" / >
< / div >
< / template >
< script setup >
import { computed , getCurrentInstance , nextTick , onBeforeUnmount , onMounted , ref } from "vue" ;
import * as echarts from "echarts" ;
import "echarts-wordcloud" ;
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue" ;
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue" ;
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue" ;
import { getTrainingTaskByKey } from "@/api/trainingTask" ;
import { analyzeProductDiagnosisIterationStepTwo , getStudentTrainingAnswer , saveStudentTrainingAnswer , uploadStudentTrainingFile , validateProductDiagnosisIterationStepOne , validateProductDiagnosisIterationStepTwo , validateProductDiagnosisIterationStepThree } from "@/api/studentTrainingAnswer" ;
import { parseTrainingArray } from "@/views/training/taskKeyMap" ;
import { isStudentDemo } from "@/utils/studentDemo" ;
const TASK _KEY = "product-diagnosis-iteration" ; const nodes = [ "曝光-点击" , "点击-加购" , "加购-下单" , "下单-好评" , "好评-复购" ] ; const channels = [ { key : "tmall" , label : "天猫" } , { key : "jd" , label : "京东" } , { key : "douyin" , label : "抖音" } , { key : "pinduoduo" , label : "拼多多" } ] ; const rateFields = [ ... channels . map ( ( item ) => item . key ) , "industryAverage" ] ;
const { proxy } = getCurrentInstance ( ) ; const taskConfig = ref ( null ) ; const currentStep = ref ( 1 ) ; const saving = ref ( false ) ; const uploading = ref ( false ) ; const analyzing = ref ( false ) ; const chartInput = ref ( null ) ; const commentInput = ref ( null ) ; const abChartInput = ref ( null ) ; const wordCloudRef = ref ( null ) ; let wordCloudChart = null ;
const conversionRates = ref ( createConversionRates ( ) ) ; const anomalyRows = ref ( [ createAnomalyRow ( ) ] ) ; const funnelChart = ref ( { url : "" , name : "" } ) ; const analysisResult = ref ( null ) ; const commentPreview = ref ( { fileName : "" , totalComments : 0 , comments : [ ] } ) ; const wordCloud = ref ( [ ] ) ; const topIssues = ref ( [ "" , "" , "" ] ) ; const sentiments = ref ( createSentiments ( ) ) ; const rootCauseAnalysis = ref ( "" ) ; const abTestPlan = ref ( "" ) ; const abRows = ref ( createAbRows ( ) ) ; const abChart = ref ( { url : "" , name : "" } ) ; const iterationRecommendation = ref ( "" ) ;
const taskTitle = computed ( ( ) => taskConfig . value ? . taskName || "产品问题诊断与迭代" ) ; const taskBackground = computed ( ( ) => taskConfig . value ? . background || "从曝光到复购的完整数据漏斗与用户评价出发,定位异常流失并形成迭代方向。" ) ; const taskRequirement = computed ( ( ) => taskConfig . value ? . requirements || "完成转化漏斗诊断、用户评论词云和情感根因分析。" ) ; const taskGoals = computed ( ( ) => parseTrainingArray ( taskConfig . value ? . objectives , [ "掌握全链路转化率计算方法" , "能够将店铺数据与行业均值进行对比" , "从用户评价中定位问题根因" ] ) ) ;
const progressItems = computed ( ( ) => currentStep . value === 1 ? [ { text : "填写五个节点的渠道转化率" , status : conversionRates . value . every ( ( row ) => rateFields . every ( ( field ) => row [ field ] !== null && row [ field ] !== "" && Number . isFinite ( Number ( row [ field ] ) ) ) ) ? "done" : "doing" } , { text : "上传节点转化漏斗对比图" , status : funnelChart . value . url ? "done" : "" } , { text : "完成异常节点分析" , status : anomalyRows . value . every ( ( row ) => row . anomalyAnalysis ? . trim ( ) ) ? "done" : "" } ] : currentStep . value === 2 ? [ { text : "导入评论数据并生成词云" , status : analysisResult . value ? "done" : "doing" } , { text : "完成情感倾向分析" , status : sentiments . value . every ( ( row ) => row . count !== null ) ? "done" : "" } , { text : "填写根因分析" , status : rootCauseAnalysis . value . trim ( ) ? "done" : "" } ] : [ { text : "设计 AB 测试方案" , status : abTestPlan . value . trim ( ) ? "done" : "doing" } , { text : "填写五项对照数据" , status : abRows . value . every ( ( row ) => row . controlValue !== null && row . experimentValue !== null && row . analysis ? . trim ( ) ) ? "done" : "" } , { text : "上传 AB 对比图" , status : abChart . value . url ? "done" : "" } , { text : "提交迭代优化建议" , status : iterationRecommendation . value . trim ( ) ? "done" : "" } ] ) ;
function createConversionRates ( ) { return nodes . map ( ( linkNode ) => ( { linkNode , tmall : null , jd : null , douyin : null , pinduoduo : null , industryAverage : null } ) ) ; } function createAnomalyRow ( ) { return { channel : "" , linkNode : "" , storeConversionRate : null , industryAverageConversionRate : null , anomalyAnalysis : "" } ; } function createSentiments ( ) { return [ "正面" , "中性" , "负面" ] . map ( ( type ) => ( { type , count : null , percentage : null } ) ) ; } function createAbRows ( ) { return [ "点击-加购转化率" , "加购-下单转化率" , "下单-好评转化率" , "详情页跳出率" , "退货率" ] . map ( ( metric ) => ( { metric , controlValue : null , experimentValue : null , analysis : "" } ) ) ; }
function buildStepOne ( ) { return { conversionRates : conversionRates . value . map ( ( row ) => ( { ... row } ) ) , funnelChartUrl : funnelChart . value . url , funnelChartName : funnelChart . value . name , anomalyRows : anomalyRows . value . map ( ( row ) => ( { ... row } ) ) } ; } function buildStepTwo ( ) { return { sourceFileName : commentPreview . value . fileName , topIssues : [ ... topIssues . value ] , sentiments : sentiments . value . map ( ( row ) => ( { ... row } ) ) , rootCauseAnalysis : rootCauseAnalysis . value , wordCloud : wordCloud . value , previewComments : commentPreview . value . comments } ; } function buildStepThree ( ) { return { testPlan : abTestPlan . value , rows : abRows . value . map ( ( row ) => ( { ... row } ) ) , comparisonChartUrl : abChart . value . url , comparisonChartName : abChart . value . name , iterationRecommendation : iterationRecommendation . value } ; }
function addAnomalyRow ( ) { if ( anomalyRows . value . length < 5 ) anomalyRows . value . push ( createAnomalyRow ( ) ) ; } function removeAnomalyRow ( ) { if ( anomalyRows . value . length > 1 ) anomalyRows . value . pop ( ) ; } function clearChart ( ) { if ( funnelChart . value . url . startsWith ( "blob:" ) ) URL . revokeObjectURL ( funnelChart . value . url ) ; funnelChart . value = { url : "" , name : "" } ; }
async function handleChartUpload ( event ) { const file = event . target ? . files ? . [ 0 ] ; if ( ! file ) return ; const accepted = [ "image/jpeg" , "image/png" , "image/webp" ] ; if ( ! accepted . includes ( file . type ) || file . size > 5 * 1024 * 1024 ) { proxy ? . $modal ? . msgError ? . ( "请上传不超过 5MB 的 PNG、JPG 或 WEBP 图片" ) ; event . target . value = "" ; return ; } uploading . value = true ; try { let url = "" ; if ( isStudentDemo ( ) ) url = URL . createObjectURL ( file ) ; else { const data = new FormData ( ) ; data . append ( "file" , file ) ; const response = await uploadStudentTrainingFile ( data ) ; url = response ? . url || response ? . data ? . url || "" ; } if ( ! url ) throw new Error ( "上传成功但未返回图片地址" ) ; clearChart ( ) ; funnelChart . value = { url , name : file . name } ; proxy ? . $modal ? . msgSuccess ? . ( "漏斗对比图上传成功,可点击图片预览" ) ; } catch ( error ) { proxy ? . $modal ? . msgError ? . ( error ? . message || "图片上传失败,请重试" ) ; } finally { uploading . value = false ; event . target . value = "" ; } }
async function saveStepOneDraft ( ) { await persistStepOne ( false ) ; } async function validateStepOne ( ) { if ( saving . value ) return ; saving . value = true ; try { const data = buildStepOne ( ) ; await validateProductDiagnosisIterationStepOne ( data ) ; await saveStudentTrainingAnswer ( TASK _KEY , { step1Answer : JSON . stringify ( data ) , currentStep : 2 , submitted : false , saveAction : "SAVE" } ) ; currentStep . value = 2 ; proxy ? . $modal ? . msgSuccess ? . ( "第 1 步校验通过,已进入第 2 步" ) ; } catch ( error ) { proxy ? . $modal ? . msgError ? . ( error ? . message || "请检查第 1 步填写内容" ) ; } finally { saving . value = false ; } } async function persistStepOne ( showMessage ) { if ( saving . value ) return ; saving . value = true ; try { await saveStudentTrainingAnswer ( TASK _KEY , { step1Answer : JSON . stringify ( buildStepOne ( ) ) , currentStep : 1 , submitted : false , saveAction : "SAVE" } ) ; if ( showMessage !== false ) proxy ? . $modal ? . msgSuccess ? . ( "草稿已保存" ) ; } catch ( error ) { proxy ? . $modal ? . msgError ? . ( error ? . message || "保存失败,请重试" ) ; } finally { saving . value = false ; } }
async function handleCommentImport ( event ) { const file = event . target ? . files ? . [ 0 ] ; if ( ! file ) return ; if ( ! /\.(csv|txt|xls|xlsx)$/i . test ( file . name ) || file . size > 5 * 1024 * 1024 ) { proxy ? . $modal ? . msgError ? . ( "请上传不超过 5MB 的 CSV、TXT、XLS 或 XLSX 评论文件" ) ; event . target . value = "" ; return ; } analyzing . value = true ; try { const result = ( await analyzeProductDiagnosisIterationStepTwo ( file ) ) ? . data ; if ( ! result ) throw new Error ( "未获得评论分析结果" ) ; analysisResult . value = result ; commentPreview . value = { fileName : result . sourceFileName || file . name , totalComments : result . totalComments || 0 , comments : result . previewComments || [ ] } ; wordCloud . value = result . wordCloud || [ ] ; topIssues . value = [ 0 , 1 , 2 ] . map ( ( index ) => result . topIssues ? . [ index ] || "" ) ; sentiments . value = createSentiments ( ) ; await nextTick ( ) ; renderWordCloud ( ) ; proxy ? . $modal ? . msgSuccess ? . ( "评论数据已导入,词云已生成" ) ; } catch ( error ) { proxy ? . $modal ? . msgError ? . ( error ? . message || "评论数据分析失败,请检查文件格式" ) ; } finally { analyzing . value = false ; event . target . value = "" ; } }
function renderWordCloud ( ) { if ( ! wordCloudRef . value || ! wordCloud . value . length ) return ; wordCloudChart ? . dispose ( ) ; wordCloudChart = echarts . init ( wordCloudRef . value ) ; wordCloudChart . setOption ( { backgroundColor : "transparent" , tooltip : { show : true } , series : [ { type : "wordCloud" , shape : "circle" , gridSize : 5 , sizeRange : [ 16 , 58 ] , rotationRange : [ - 35 , 35 ] , textStyle : { color : ( ) => [ "#53e2ff" , "#6ae1bb" , "#ffd06a" , "#91b8ff" , "#ff9e88" ] [ Math . floor ( Math . random ( ) * 5 ) ] } , data : wordCloud . value } ] } ) ; }
function runSentimentAnalysis ( ) { if ( ! analysisResult . value ) return ; sentiments . value = [ { type : "正面" , count : analysisResult . value . positiveCount , percentage : null } , { type : "中性" , count : analysisResult . value . neutralCount , percentage : null } , { type : "负面" , count : analysisResult . value . negativeCount , percentage : null } ] ; proxy ? . $modal ? . msgSuccess ? . ( "评论数量已自动填充,请计算并填写各类情感占比" ) ; }
async function saveStepTwoDraft ( ) { if ( saving . value ) return ; saving . value = true ; try { await saveStudentTrainingAnswer ( TASK _KEY , { step2Answer : JSON . stringify ( buildStepTwo ( ) ) , currentStep : 2 , submitted : false , saveAction : "SAVE" } ) ; proxy ? . $modal ? . msgSuccess ? . ( "草稿已保存" ) ; } catch ( error ) { proxy ? . $modal ? . msgError ? . ( error ? . message || "保存失败,请重试" ) ; } finally { saving . value = false ; } } async function validateStepTwo ( ) { if ( saving . value ) return ; saving . value = true ; try { const data = buildStepTwo ( ) ; await validateProductDiagnosisIterationStepTwo ( data ) ; await saveStudentTrainingAnswer ( TASK _KEY , { step2Answer : JSON . stringify ( data ) , currentStep : 2 , submitted : false , saveAction : "SAVE" } ) ; proxy ? . $modal ? . msgSuccess ? . ( "第 2 步校验通过,已保存" ) ; } catch ( error ) { proxy ? . $modal ? . msgError ? . ( error ? . message || "请检查词云、情感占比和根因分析" ) ; } 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 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 ; } }
function restore ( answer ) { try { const stepOne = JSON . parse ( answer ? . step1Answer || "{}" ) ; if ( Array . isArray ( stepOne . conversionRates ) && stepOne . conversionRates . length === nodes . length ) conversionRates . value = nodes . map ( ( node ) => ( { ... createConversionRates ( ) . find ( ( row ) => row . linkNode === node ) , ... ( stepOne . conversionRates . find ( ( row ) => row ? . linkNode === node ) || { } ) } ) ) ; if ( Array . isArray ( stepOne . anomalyRows ) && stepOne . anomalyRows . length ) anomalyRows . value = stepOne . anomalyRows . slice ( 0 , 5 ) . map ( ( row ) => ( { ... createAnomalyRow ( ) , ... row } ) ) ; if ( stepOne . funnelChartUrl ) funnelChart . value = { url : stepOne . funnelChartUrl , name : stepOne . funnelChartName || "节点转化漏斗对比图" } ; const stepTwo = JSON . parse ( answer ? . step2Answer || "{}" ) ; if ( stepTwo . sourceFileName ) { commentPreview . value = { fileName : stepTwo . sourceFileName , totalComments : 0 , comments : stepTwo . previewComments || [ ] } ; wordCloud . value = stepTwo . wordCloud || [ ] ; topIssues . value = [ 0 , 1 , 2 ] . map ( ( index ) => stepTwo . topIssues ? . [ index ] || "" ) ; sentiments . value = ( stepTwo . sentiments || createSentiments ( ) ) . map ( ( row ) => ( { ... createSentiments ( ) . find ( ( item ) => item . type === row . type ) , ... row } ) ) ; rootCauseAnalysis . value = stepTwo . rootCauseAnalysis || "" ; nextTick ( renderWordCloud ) ; } const stepThree = JSON . parse ( answer ? . step3Answer || "{}" ) ; if ( stepThree . testPlan || stepThree . comparisonChartUrl || stepThree . iterationRecommendation ) { abTestPlan . value = stepThree . testPlan || "" ; if ( Array . isArray ( stepThree . rows ) && stepThree . rows . length === createAbRows ( ) . length ) abRows . value = createAbRows ( ) . map ( ( row ) => ( { ... row , ... ( stepThree . rows . find ( ( item ) => item ? . metric === row . metric ) || { } ) } ) ) ; if ( stepThree . comparisonChartUrl ) abChart . value = { url : stepThree . comparisonChartUrl , name : stepThree . comparisonChartName || "AB 测试结果对比图" } ; iterationRecommendation . value = stepThree . iterationRecommendation || "" ; } currentStep . value = [ 1 , 2 , 3 ] . includes ( Number ( answer ? . currentStep ) ) ? Number ( answer . currentStep ) : 1 ; } catch ( error ) { } }
onMounted ( async ( ) => { try { taskConfig . value = ( await getTrainingTaskByKey ( TASK _KEY ) ) ? . data || null ; } catch ( error ) { taskConfig . value = null ; } try { restore ( ( await getStudentTrainingAnswer ( TASK _KEY ) ) ? . data ) ; } catch ( error ) { } } ) ; onBeforeUnmount ( ( ) => { if ( funnelChart . value . url . startsWith ( "blob:" ) ) URL . revokeObjectURL ( funnelChart . value . url ) ; if ( abChart . value . url . startsWith ( "blob:" ) ) URL . revokeObjectURL ( abChart . value . url ) ; wordCloudChart ? . dispose ( ) ; } ) ;
< / script >
< style scoped lang = "scss" >
. ab - table { width : 100 % ; min - width : 860 px ; border - collapse : collapse } . ab - table th , . ab - table td { border : 1 px solid rgba ( 139 , 213 , 241 , .45 ) } . ab - table thead th { padding : 14 px 10 px ; color : # fff ; background : # 079 ed9 ; font - size : 16 px } . ab - table tbody th { width : 25 % ; padding : 14 px 10 px ; color : # dff8ff ; background : rgba ( 6 , 67 , 94 , .34 ) ; text - align : center } . ab - table td { padding : 9 px 11 px ; text - align : center } . ab - table td : nth - child ( 2 ) , . ab - table td : nth - child ( 3 ) { width : 18 % } . ab - table td : last - child { width : 39 % }
< / style >