@ -14,9 +14,43 @@
< 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 >
< 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 >
< p class = "diagnosis-materials__ai" > 3. 导出数据表上传到通用AI大模型 , 通过AI大模型辅助完成计算 , 参考步骤及提示词如下 : 请根据表格数据 , 计算从曝光到复购链路节点转化率 。 < / p >
< / div >
< / div >
< section class = "section-block" > < div class = "section-heading" > < span > 一 < / span > < div > < h3 > 计算各节点转化率 < / h3 > < p > 根据案例数据计算各个渠道链路节点转化率 , 参考方法及工具如下 : < / p > < / div > < / div > < div class = "table-wrap" > < table class = "funnel-table" > < thead > < tr > < th > 链路节点 < / th > < th > 天猫 < / th > < th > 京东 < / th > < th > 抖音 < / th > < th > 拼多多 < / th > < th > 行业均值 < / th > < / tr > < / thead > < tbody > < tr v-for ="row in conversionRates" :key="row.linkNode"><th>{{ row.linkNode }}</th><td v-for="field in rateFields" :key ="field" > < el -input -number v-model ="row[field]" :min="0" :max="100" :precision="2" :controls="false" :placeholder="row.linkNode === '曝光-点击' && field === 'tmall' ? '示例: 11.5%' : '请输入'" /><span class="unit" > % < / span > < / td > < / tr > < / tbody > < / table > < / div > < / section >
< section class = "section-block" >
< div class = "section-heading" > < span > 一 < / span > < div > < h3 > 计算各节点转化率 < / h3 > < p > 根据案例数据计算各个渠道链路节点转化率 , 参考方法及工具如下 : < / p > < / div > < / div >
< div class = "table-toolbar funnel-channel-toolbar" >
< span > 链路节点和行业均值固定 , 中间渠道可按案例数据增加或删除 。 < / span >
< button type = "button" class = "table-button" @click ="addFunnelChannel" > 增 加 渠 道 < / button >
< / div >
< div class = "table-wrap" >
< table class = "funnel-table" >
< thead >
< tr >
< th class = "fixed-column" > 链路节点 < / th >
< th v-for ="channel in channels" :key="channel.id" class="channel-column" >
< div class = "channel-heading" >
< el -input v -model = " channel.name " maxlength = "20" placeholder = "填写渠道名称" / >
< button type = "button" class = "channel-remove" : aria -label = " ` 删 除 $ { channel.name | | ' 未 命 名 ' } 渠 道 ` " @click ="removeFunnelChannel(channel.id)" > × < / button >
< / div >
< / th >
< th class = "fixed-column" > 行业均值 < / th >
< / tr >
< / thead >
< tbody >
< tr v-for ="row in conversionRates" :key="row.linkNode" >
< th > { { row . linkNode } } < / th >
< td v-for ="(channel, channelIndex) in channels" :key="channel.id" >
< div class = "rate-input" >
< el -input -number v -model = " row.channelRates [ channel.id ] " :min ="0" :max ="100" :precision ="2" :controls ="false" : placeholder = "row.linkNode === '曝光-点击' && channelIndex === 0 ? '示例: 11.5' : '请输入'" / >
< span class = "unit" > % < / span >
< / div >
< / td >
< td > < div class = "rate-input" > < el -input -number v-model ="row.industryAverage" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit" > % < / span > < / div > < / td >
< / tr >
< / tbody >
< / table >
< / div >
< / section >
< section class = "section-block visual-section" > < div class = "section-heading" > < span > 二 < / span > < div > < h3 > 全链路转化漏斗可视化 < / h3 > < p > 根据节点转化率计算结果 , 绘制各渠道转化率与行业均值的转化漏斗对比图 , 可视化呈现多环节情况 , 参考方法及工具如下 : < / p > < / div > < / div > < div class = "diagnosis-materials" aria -label = " 第 2 步 参 考 资 料 " > < div class = "diagnosis-materials__item" > < strong > 1. 使用Excel绘图功能 , 自主完成转化漏斗对比图 , 可参考 《 实训操作说明 》 。 < / strong > < div class = "diagnosis-materials__actions" > < button type = "button" class = "secondary-button" @click ="downloadFunnelGuide" > 下 载 实 训 操 作 说 明 < / button > < / div > < / div > < div class = "diagnosis-materials__item" > < strong > 2. 下载Excel模版 , 填写关键信息 , 自动计算各环节转化率并生成转化漏斗对比图 。 < / strong > < div class = "diagnosis-materials__actions" > < button type = "button" class = "secondary-button" @click ="downloadFunnelTemplate" > 下 载 Excel 模 板 < / button > < / div > < / div > < p class = "diagnosis-materials__ai" > 3. 导出数据表上传到通用AI大模型 , 通过AI大模型辅助完成 , 参考步骤及提示词如下 : 请根据表格数据绘制漏斗图 , 并与行业均值漏斗图进行对比 。 < / p > < / div > < input ref = "chartInput" class = "visually-hidden" type = "file" accept = "image/png,image/jpeg,image/webp" @change ="handleChartUpload" / > < div class = "chart-upload-area" > < template v-if ="funnelChart.url" > < div class = "chart-preview" > < el -image :src ="funnelChart.url" :preview-src-list ="[funnelChart.url]" fit = "cover" preview -teleported / > < div > < strong > { { funnelChart . name || '节点转化漏斗对比图' } } < / strong > < span > 点击缩略图可放大预览 < / span > < button type = "button" @click ="clearChart" > 移 除 图 片 < / button > < / div > < / div > < button type = "button" class = "secondary-button" :disabled ="uploading" @click ="chartInput?.click()" > {{ uploading ? ' 上 传 中 … ' : ' 更 换 对 比 图 ' }} < / button > < / template > < template v-else > < button type = "button" class = "upload-button" :disabled ="uploading" @click ="chartInput?.click()" > {{ uploading ? ' 上 传 中 … ' : ' 上 传 节 点 转 化 漏 斗 对 比 图 ' }} < / button > < p > 支持 PNG 、 JPG 、 WEBP 格式 , 图片不超过 5 MB ; 上传后可点击预览 。 < / p > < / template > < / div > < / section >
< section class = "section-block visual-section" > < div class = "section-heading" > < span > 二 < / span > < div > < h3 > 全链路转化漏斗可视化 < / h3 > < p > 根据节点转化率计算结果 , 绘制各渠道转化率与行业均值的转化漏斗对比图 , 可视化呈现多环节情况 , 参考方法及工具如下 : < / p > < / div > < / div > < div class = "diagnosis-materials" aria -label = " 第 2 步 参 考 资 料 " > < div class = "diagnosis-materials__item" > < strong > 1. 使用Excel绘图功能 , 自主完成转化漏斗对比图 , 可参考 《 实训操作说明 》 。 < / strong > < div class = "diagnosis-materials__actions" > < button type = "button" class = "secondary-button" @click ="downloadFunnelGuide" > 下 载 实 训 操 作 说 明 < / button > < / div > < / div > < div class = "diagnosis-materials__item" > < strong > 2. 下载Excel模版 , 填写关键信息 , 自动计算各环节转化率并生成转化漏斗对比图 。 < / strong > < div class = "diagnosis-materials__actions" > < button type = "button" class = "secondary-button" @click ="downloadFunnelTemplate" > 下 载 Excel 模 板 < / button > < / div > < / div > < p class = "diagnosis-materials__ai" > 3. 导出数据表上传到通用AI大模型 , 通过AI大模型辅助完成 , 参考步骤及提示词如下 : 请根据表格数据绘制漏斗图 , 并与行业均值漏斗图进行对比 。 < / p > < / div > < input ref = "chartInput" class = "visually-hidden" type = "file" accept = "image/png,image/jpeg,image/webp" @change ="handleChartUpload" / > < div class = "chart-upload-area" > < template v-if ="funnelChart.url" > < div class = "chart-preview" > < el -image :src ="funnelChart.url" :preview-src-list ="[funnelChart.url]" fit = "cover" preview -teleported / > < div > < strong > { { funnelChart . name || '节点转化漏斗对比图' } } < / strong > < span > 点击缩略图可放大预览 < / span > < button type = "button" @click ="clearChart" > 移 除 图 片 < / button > < / div > < / div > < button type = "button" class = "secondary-button" :disabled ="uploading" @click ="chartInput?.click()" > {{ uploading ? ' 上 传 中 … ' : ' 更 换 对 比 图 ' }} < / button > < / template > < template v-else > < button type = "button" class = "upload-button" :disabled ="uploading" @click ="chartInput?.click()" > {{ uploading ? ' 上 传 中 … ' : ' 上 传 节 点 转 化 漏 斗 对 比 图 ' }} < / button > < p > 支持 PNG 、 JPG 、 WEBP 格式 , 图片不超过 5 MB ; 上传后可点击预览 。 < / p > < / template > < / div > < / section >
< section class = "section-block diagnosis-section" > < div class = "section-heading" > < span > 三 < / span > < div > < h3 > 定位异常节点 < / h3 > < p > 对比案例行业均值数据 , 定位异常节点 。 < / p > < / div > < / div > < div class = "table-toolbar" > < span > 对比案例行业均值数据 , 定位异常节点 。 < / span > < div > < button type = "button" class = "table-button" : disabled = "anomalyRows.length >= 5" @click ="addAnomalyRow" > 增 加 一 行 < / button > < button type = "button" class = "table-button ghost" : disabled = "anomalyRows.length <= 1" @click ="removeAnomalyRow" > 删 除 一 行 < / button > < / div > < / div > < div class = "table-wrap" > < table class = "diagnosis-table" > < thead > < tr > < th > 渠道 < / th > < th > 链路节点 < / th > < th > 店铺转化率 < / th > < th > 行业均值转化率 < / th > < th > 异常分析与判定 < / th > < / tr > < / thead > < tbody > < tr v-for ="(row, index) in anomalyRows" :key ="index" > < td > < el -select v-model ="row.channel" placeholder="请选择"><el-option v-for="channel in channels" :key="channel.key" :label="channel.label" :value="channel.label" /></el-select></td><td><el-select v-model="row.linkNode" placeholder="请选择"><el-option v-for="node in nodes" :key="node" :label="node" :value="node" /></el-select></td><td><el-input-number v-model="row.storeConversionRate" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit">%</span></td><td><el-input-number v-model="row.industryAverageConversionRate" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit">%</span></td><td><el-input v-model="row.anomalyAnalysis" type="textarea" :rows="2" maxlength="300" show-word-limit placeholder="请填写异常原因或正常判定" /></td></tr></tbody></table></div><p class="diagnosis-step-one-notice" > 说 明 : 这 个 表 格 默 认 展 示 一 行 可 填 写 , 最 少 一 行 , 可 以 增 加 行 , 最 多 5 行 , 这 里 填 写 的 渠 道 需 要 来 源 于 第 一 个 表 的 渠 道 。 < / p > < / section >
< section class = "section-block diagnosis-section" > < div class = "section-heading" > < span > 三 < / span > < div > < h3 > 定位异常节点 < / h3 > < p > 对比案例行业均值数据 , 定位异常节点 。 < / p > < / div > < / div > < div class = "table-toolbar" > < span > 对比案例行业均值数据 , 定位异常节点 。 < / span > < div > < button type = "button" class = "table-button" : disabled = "anomalyRows.length >= 5" @click ="addAnomalyRow" > 增 加 一 行 < / button > < button type = "button" class = "table-button ghost" : disabled = "anomalyRows.length <= 1" @click ="removeAnomalyRow" > 删 除 一 行 < / button > < / div > < / div > < div class = "table-wrap" > < table class = "diagnosis-table" > < thead > < tr > < th > 渠道 < / th > < th > 链路节点 < / th > < th > 店铺转化率 < / th > < th > 行业均值转化率 < / th > < th > 异常分析与判定 < / th > < / tr > < / thead > < tbody > < tr v-for ="(row, index) in anomalyRows" :key ="index" > < td > < el -select v-model ="row.channel Id" 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 >
< template v -else -if = " currentStep = = = 2 " >
< template v -else -if = " currentStep = = = 2 " >
@ -65,14 +99,60 @@ function downloadFunnelGuide() { downloadBuiltInFile(FUNNEL_GUIDE_URL); }
function downloadFunnelTemplate ( ) { downloadBuiltInFile ( FUNNEL _TEMPLATE _URL ) ; }
function downloadFunnelTemplate ( ) { downloadBuiltInFile ( FUNNEL _TEMPLATE _URL ) ; }
function downloadAbGuide ( ) { downloadBuiltInFile ( AB _TEST _GUIDE _URL ) ; }
function downloadAbGuide ( ) { downloadBuiltInFile ( AB _TEST _GUIDE _URL ) ; }
function downloadAbTemplate ( ) { downloadBuiltInFile ( AB _TEST _TEMPLATE _URL ) ; }
function downloadAbTemplate ( ) { downloadBuiltInFile ( AB _TEST _TEMPLATE _URL ) ; }
const TASK _KEY = "product-diagnosis-iteration" ; const nodes = [ "曝光-点击" , "点击-加购" , "加购-下单" , "下单-好评" , "好评-复购" ] ; const channels = [ { key : "tmall" , label : "天猫" } , { key : "jd" , label : "京东" } , { key : "douyin" , label : "抖音" } , { key : "pinduoduo" , label : "拼多多" } ] ; const rateFields = [ ... channels . map ( ( item ) => item . key ) , "industryAverage" ] ;
const 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 { 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 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 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" : "" } ] ) ;
const availableChannels = computed ( ( ) => channels . value . filter ( ( channel ) => channel . name . trim ( ) ) ) ;
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 : "" } ) ) ; }
const funnelRatesComplete = computed ( ( ) => {
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 } ; }
const names = availableChannels . value . map ( ( channel ) => channel . 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 : "" } ; }
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 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 , 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 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 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 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 = "" ; } }
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 = "" ; } }
@ -130,7 +210,7 @@ async function resetTraining() {
try {
try {
await deleteStudentTrainingAnswer ( TASK _KEY ) ;
await deleteStudentTrainingAnswer ( TASK _KEY ) ;
clearChart ( ) ; clearAbChart ( ) ; wordCloudChart ? . dispose ( ) ; wordCloudChart = null ;
clearChart ( ) ; clearAbChart ( ) ; wordCloudChart ? . dispose ( ) ; wordCloudChart = null ;
c onversionRates. value = createConversionRates ( ) ; anomalyRows . value = [ createAnomalyRow ( ) ] ; analysisResult . value = null ;
c hannels. value = createDefaultChannels ( ) ; c onversionRates. value = createConversionRates ( ) ; anomalyRows . value = [ createAnomalyRow ( ) ] ; analysisResult . value = null ;
commentPreview . value = { fileName : "" , totalComments : 0 , comments : [ ] } ; wordCloud . value = [ ] ; topIssues . value = [ "" , "" , "" ] ; sentiments . value = createSentiments ( ) ; rootCauseAnalysis . value = "" ;
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 ;
abTestPlan . value = "" ; abRows . value = createAbRows ( ) ; iterationRecommendation . value = "" ; currentStep . value = 1 ;
proxy ? . $modal ? . msgSuccess ? . ( "已清空全部实训步骤,可以重新开始" ) ;
proxy ? . $modal ? . msgSuccess ? . ( "已清空全部实训步骤,可以重新开始" ) ;
@ -146,7 +226,48 @@ function exportTrainingOutcome() {
const link = document . createElement ( "a" ) ; link . href = url ; link . download = "产品问题诊断与迭代-实训成果.json" ; document . body . appendChild ( link ) ; link . click ( ) ; document . body . removeChild ( link ) ; URL . revokeObjectURL ( url ) ;
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 ? . ( "实训成果已导出" ) ;
proxy ? . $modal ? . msgSuccess ? . ( "实训成果已导出" ) ;
}
}
function restore ( answer ) { try { const stepOne = JSON . parse ( answer ? . step1Answer || "{}" ) ; if ( Array . isArray ( stepOne . conversionRates ) && stepOne . conversionRates . length === nodes . length ) conversionRates . value = nodes . map ( ( node ) => ( { ... createConversionRates ( ) . find ( ( row ) => row . linkNode === node ) , ... ( stepOne . conversionRates . find ( ( row ) => row ? . linkNode === node ) || { } ) } ) ) ; if ( Array . isArray ( stepOne . anomalyRows ) && stepOne . anomalyRows . length ) anomalyRows . value = stepOne . anomalyRows . slice ( 0 , 5 ) . map ( ( row ) => ( { ... createAnomalyRow ( ) , ... row } ) ) ; if ( stepOne . funnelChartUrl ) funnelChart . value = { url : stepOne . funnelChartUrl , name : stepOne . funnelChartName || "节点转化漏斗对比图" } ; const stepTwo = JSON . parse ( answer ? . step2Answer || "{}" ) ; if ( stepTwo . sourceFileName ) { commentPreview . value = { fileName : stepTwo . sourceFileName , totalComments : 0 , comments : stepTwo . previewComments || [ ] } ; wordCloud . value = stepTwo . wordCloud || [ ] ; topIssues . value = [ 0 , 1 , 2 ] . map ( ( index ) => stepTwo . topIssues ? . [ index ] || "" ) ; sentiments . value = ( stepTwo . sentiments || createSentiments ( ) ) . map ( ( row ) => ( { ... createSentiments ( ) . find ( ( item ) => item . type === row . type ) , ... row } ) ) ; rootCauseAnalysis . value = stepTwo . rootCauseAnalysis || "" ; nextTick ( renderWordCloud ) ; } const stepThree = JSON . parse ( answer ? . step3Answer || "{}" ) ; if ( stepThree . testPlan || stepThree . comparisonChartUrl || stepThree . iterationRecommendation ) { abTestPlan . value = stepThree . testPlan || "" ; if ( Array . isArray ( stepThree . rows ) && stepThree . rows . length === createAbRows ( ) . length ) abRows . value = createAbRows ( ) . map ( ( row ) => ( { ... row , ... ( stepThree . rows . find ( ( item ) => item ? . metric === row . metric ) || { } ) } ) ) ; if ( stepThree . comparisonChartUrl ) abChart . value = { url : stepThree . comparisonChartUrl , name : stepThree . comparisonChartName || "AB 测试结果对比图" } ; iterationRecommendation . value = stepThree . iterationRecommendation || "" ; } currentStep . value = [ 1 , 2 , 3 ] . includes ( Number ( answer ? . currentStep ) ) ? Number ( answer . currentStep ) : 1 ; } catch ( error ) { } }
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 restore ( answer ) {
try {
const stepOne = JSON . parse ( answer ? . step1Answer || "{}" ) ;
restoreStepOne ( stepOne ) ;
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 ( ) ; } ) ;
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 >
< / script >
@ -170,6 +291,7 @@ onMounted(async () => { try { taskConfig.value = (await getTrainingTaskByKey(TAS
. lead { margin : 0 0 20 px ; color : # d4edf7 ; font - size : 15 px ; line - height : 1.9 } . section - block { margin - top : 20 px ; padding : 20 px ; border : 1 px solid rgba ( 97 , 209 , 247 , .25 ) ; border - radius : 16 px ; background : rgba ( 3 , 28 , 43 , .42 ) } . section - heading { display : flex ; align - items : flex - start ; gap : 12 px ; margin - bottom : 16 px } . section - heading > span { display : grid ; flex : 0 0 30 px ; width : 30 px ; height : 30 px ; place - items : center ; border - radius : 8 px ; color : # eaffff ; background : linear - gradient ( 135 deg , # 11 b8e4 , # 1373 c4 ) ; font - size : 14 px ; font - weight : 900 } . section - heading h3 { margin : 1 px 0 4 px ; color : # f0fbff ; font - size : 18 px } . section - heading p { margin : 0 ; color : var ( -- diagnosis - muted ) ; font - size : 13 px ; line - height : 1.65 }
. lead { margin : 0 0 20 px ; color : # d4edf7 ; font - size : 15 px ; line - height : 1.9 } . section - block { margin - top : 20 px ; padding : 20 px ; border : 1 px solid rgba ( 97 , 209 , 247 , .25 ) ; border - radius : 16 px ; background : rgba ( 3 , 28 , 43 , .42 ) } . section - heading { display : flex ; align - items : flex - start ; gap : 12 px ; margin - bottom : 16 px } . section - heading > span { display : grid ; flex : 0 0 30 px ; width : 30 px ; height : 30 px ; place - items : center ; border - radius : 8 px ; color : # eaffff ; background : linear - gradient ( 135 deg , # 11 b8e4 , # 1373 c4 ) ; font - size : 14 px ; font - weight : 900 } . section - heading h3 { margin : 1 px 0 4 px ; color : # f0fbff ; font - size : 18 px } . section - heading p { margin : 0 ; color : var ( -- diagnosis - muted ) ; font - size : 13 px ; line - height : 1.65 }
. secondary - button , . table - button , . upload - button , . submit - button { min - height : 36 px ; padding : 0 13 px ; border : 1 px solid rgba ( 99 , 203 , 239 , .58 ) ; border - radius : 8 px ; color : # e7fbff ; background : rgba ( 12 , 111 , 153 , .46 ) ; font : inherit ; font - size : 13 px ; font - weight : 800 ; cursor : pointer ; transition : .2 s ease } . secondary - button : hover , . table - button : hover , . upload - button : hover { border - color : # 7 deaff ; background : rgba ( 19 , 145 , 192 , .62 ) } . submit - button { border - color : # 72 e8ff ; background : linear - gradient ( 100 deg , # 0 d80b7 , # 09638 f ) } . submit - button : hover { filter : brightness ( 1.12 ) } button : disabled { cursor : not - allowed ; opacity : .45 } . table - button . ghost { border - color : rgba ( 163 , 197 , 211 , .36 ) ; color : # bed6e0 ; background : rgba ( 75 , 101 , 112 , .25 ) }
. secondary - button , . table - button , . upload - button , . submit - button { min - height : 36 px ; padding : 0 13 px ; border : 1 px solid rgba ( 99 , 203 , 239 , .58 ) ; border - radius : 8 px ; color : # e7fbff ; background : rgba ( 12 , 111 , 153 , .46 ) ; font : inherit ; font - size : 13 px ; font - weight : 800 ; cursor : pointer ; transition : .2 s ease } . secondary - button : hover , . table - button : hover , . upload - button : hover { border - color : # 7 deaff ; background : rgba ( 19 , 145 , 192 , .62 ) } . submit - button { border - color : # 72 e8ff ; background : linear - gradient ( 100 deg , # 0 d80b7 , # 09638 f ) } . submit - button : hover { filter : brightness ( 1.12 ) } button : disabled { cursor : not - allowed ; opacity : .45 } . table - button . ghost { border - color : rgba ( 163 , 197 , 211 , .36 ) ; color : # bed6e0 ; background : rgba ( 75 , 101 , 112 , .25 ) }
. table - wrap { overflow - x : auto ; border : 1 px solid rgba ( 99 , 183 , 222 , .43 ) ; border - radius : 12 px } . funnel - table , . diagnosis - table , . sentiment - table { width : 100 % ; border - collapse : collapse } . funnel - table { min - width : 900 px } . diagnosis - table { min - width : 1060 px } . sentiment - table { min - width : 640 px } . funnel - table th , . funnel - table td , . diagnosis - table th , . diagnosis - table td , . sentiment - table th , . sentiment - table td { padding : 11 px ; border - right : 1 px solid rgba ( 99 , 183 , 222 , .31 ) ; border - bottom : 1 px solid rgba ( 99 , 183 , 222 , .31 ) ; vertical - align : middle ; text - align : center } . funnel - table thead th , . diagnosis - table thead th , . sentiment - table thead th { color : # effaff ; background : linear - gradient ( 100 deg , # 0 a96cc , # 087 daf ) ; font - size : 14 px ; white - space : nowrap } . funnel - table tbody th , . sentiment - table tbody th { color : # dff8ff ; background : rgba ( 8 , 72 , 99 , .34 ) ; font - weight : 800 } . funnel - table td , . diagnosis - table td , . sentiment - table td { background : rgba ( 2 , 18 , 29 , .34 ) } . funnel - table : deep ( . el - input - number ) , . diagnosis - table : deep ( . el - input - number ) , . sentiment - table : deep ( . el - input - number ) , . diagnosis - table : deep ( . el - select ) { width : 100 % } . unit { margin - left : 5 px ; color : # 74 ddf6 ; font - size : 12 px ; font - weight : 800 } . auto - count { color : # 82 e7ff ; font - size : 16 px ; font - weight : 900 }
. table - wrap { overflow - x : auto ; border : 1 px solid rgba ( 99 , 183 , 222 , .43 ) ; border - radius : 12 px } . funnel - table , . diagnosis - table , . sentiment - table { width : 100 % ; border - collapse : collapse } . funnel - table { min - width : 900 px } . diagnosis - table { min - width : 1060 px } . sentiment - table { min - width : 640 px } . funnel - table th , . funnel - table td , . diagnosis - table th , . diagnosis - table td , . sentiment - table th , . sentiment - table td { padding : 11 px ; border - right : 1 px solid rgba ( 99 , 183 , 222 , .31 ) ; border - bottom : 1 px solid rgba ( 99 , 183 , 222 , .31 ) ; vertical - align : middle ; text - align : center } . funnel - table thead th , . diagnosis - table thead th , . sentiment - table thead th { color : # effaff ; background : linear - gradient ( 100 deg , # 0 a96cc , # 087 daf ) ; font - size : 14 px ; white - space : nowrap } . funnel - table tbody th , . sentiment - table tbody th { color : # dff8ff ; background : rgba ( 8 , 72 , 99 , .34 ) ; font - weight : 800 } . funnel - table td , . diagnosis - table td , . sentiment - table td { background : rgba ( 2 , 18 , 29 , .34 ) } . funnel - table : deep ( . el - input - number ) , . diagnosis - table : deep ( . el - input - number ) , . sentiment - table : deep ( . el - input - number ) , . diagnosis - table : deep ( . el - select ) { width : 100 % } . unit { margin - left : 5 px ; color : # 74 ddf6 ; font - size : 12 px ; font - weight : 800 } . auto - count { color : # 82 e7ff ; font - size : 16 px ; font - weight : 900 }
. funnel - channel - toolbar > span { display : block } . funnel - table . fixed - column , . funnel - table tbody th { min - width : 145 px } . funnel - table . channel - column , . funnel - table tbody td { min - width : 180 px } . channel - heading { display : flex ; align - items : center ; gap : 7 px } . channel - heading : deep ( . el - input ) { min - width : 120 px } . channel - heading : deep ( . el - input _ _wrapper ) { background : rgba ( 2 , 45 , 66 , .58 ) ; box - shadow : 0 0 0 1 px rgba ( 198 , 242 , 255 , .42 ) inset } . channel - heading : deep ( . el - input _ _inner ) { color : # fff ; text - align : center ; font - weight : 800 } . channel - remove { display : inline - grid ; flex : 0 0 24 px ; width : 24 px ; height : 24 px ; place - items : center ; padding : 0 ; border : 1 px solid rgba ( 255 , 164 , 172 , .58 ) ; border - radius : 6 px ; color : # ffd4d7 ; background : rgba ( 130 , 35 , 53 , .32 ) ; font : inherit ; font - size : 17 px ; line - height : 1 ; cursor : pointer } . channel - remove : hover , . channel - remove : focus - visible { border - color : # ff98a2 ; color : # fff ; background : rgba ( 176 , 48 , 67 , .5 ) ; outline : none } . rate - input { display : flex ; align - items : center ; gap : 6 px ; width : 100 % ; white - space : nowrap } . rate - input : deep ( . el - input - number ) { flex : 1 1 auto ; width : calc ( 100 % - 22 px ) ; min - width : 0 } . rate - input . unit { display : inline - flex ; flex : 0 0 auto ; align - items : center ; height : 32 px ; margin - left : 0 ; white - space : nowrap }
. table - toolbar { display : flex ; align - items : center ; justify - content : space - between ; gap : 14 px ; margin : 0 0 12 px ; color : var ( -- diagnosis - muted ) ; font - size : 13 px } . table - toolbar > div , . actions , . diagnosis - materials _ _actions , . ab - materials _ _actions { display : flex ; flex - wrap : wrap ; gap : 8 px } . actions { justify - content : center ; margin - top : 24 px } . actions . secondary - button , . actions . submit - button { min - width : 142 px ; min - height : 42 px }
. table - toolbar { display : flex ; align - items : center ; justify - content : space - between ; gap : 14 px ; margin : 0 0 12 px ; color : var ( -- diagnosis - muted ) ; font - size : 13 px } . table - toolbar > div , . actions , . diagnosis - materials _ _actions , . ab - materials _ _actions { display : flex ; flex - wrap : wrap ; gap : 8 px } . actions { justify - content : center ; margin - top : 24 px } . actions . secondary - button , . actions . submit - button { min - width : 142 px ; min - height : 42 px }
. chart - upload - area , . comment - import { display : grid ; place - items : center ; gap : 10 px ; min - height : 190 px ; padding : 22 px ; border : 1 px dashed rgba ( 113 , 223 , 255 , .66 ) ; border - radius : 13 px ; background : linear - gradient ( 135 deg , rgba ( 5 , 47 , 69 , .66 ) , rgba ( 4 , 23 , 35 , .52 ) ) ; text - align : center } . chart - upload - area p , . comment - import > p { margin : 0 ; color : var ( -- diagnosis - muted ) ; font - size : 13 px } . upload - button { min - height : 42 px ; padding : 0 18 px } . chart - preview { display : flex ; align - items : center ; gap : 16 px ; width : 100 % ; text - align : left } . chart - preview : deep ( . el - image ) { width : 180 px ; height : 110 px ; border : 1 px solid var ( -- diagnosis - line ) ; border - radius : 8 px } . chart - preview > div { display : grid ; gap : 7 px } . chart - preview strong { color : # e8fbff } . chart - preview span { color : var ( -- diagnosis - muted ) ; font - size : 13 px } . chart - preview button { width : max - content ; padding : 0 ; border : 0 ; color : # ffadb1 ; background : transparent ; font : inherit ; cursor : pointer }
. chart - upload - area , . comment - import { display : grid ; place - items : center ; gap : 10 px ; min - height : 190 px ; padding : 22 px ; border : 1 px dashed rgba ( 113 , 223 , 255 , .66 ) ; border - radius : 13 px ; background : linear - gradient ( 135 deg , rgba ( 5 , 47 , 69 , .66 ) , rgba ( 4 , 23 , 35 , .52 ) ) ; text - align : center } . chart - upload - area p , . comment - import > p { margin : 0 ; color : var ( -- diagnosis - muted ) ; font - size : 13 px } . upload - button { min - height : 42 px ; padding : 0 18 px } . chart - preview { display : flex ; align - items : center ; gap : 16 px ; width : 100 % ; text - align : left } . chart - preview : deep ( . el - image ) { width : 180 px ; height : 110 px ; border : 1 px solid var ( -- diagnosis - line ) ; border - radius : 8 px } . chart - preview > div { display : grid ; gap : 7 px } . chart - preview strong { color : # e8fbff } . chart - preview span { color : var ( -- diagnosis - muted ) ; font - size : 13 px } . chart - preview button { width : max - content ; padding : 0 ; border : 0 ; color : # ffadb1 ; background : transparent ; font : inherit ; cursor : pointer }
. data - preview , . word - cloud - card , . top - issues , . root - cause { margin - top : 16 px ; padding : 16 px ; border : 1 px solid rgba ( 99 , 183 , 222 , .31 ) ; border - radius : 12 px ; background : rgba ( 3 , 25 , 39 , .58 ) } . data - preview > div , . word - cloud - title { display : flex ; justify - content : space - between ; gap : 12 px ; color : # e6f9ff } . data - preview span , . word - cloud - title small { color : var ( -- diagnosis - muted ) ; font - size : 12 px } . data - preview ul { max - height : 130 px ; margin : 12 px 0 0 ; padding - left : 20 px ; overflow : auto ; color : # c8e5f1 ; line - height : 1.7 ; text - align : left } . word - cloud { height : 280 px } . top - issues > p , . root - cause > p { margin : 0 0 13 px ; color : # c8e5f1 ; line - height : 1.7 } . top - issues > div { display : grid ; grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr ) ) ; gap : 12 px } . top - issues label { display : grid ; gap : 7 px ; color : # 8 bdff5 ; font - weight : 800 } . sentiment - actions { display : flex ; align - items : center ; gap : 14 px ; margin - bottom : 16 px } . sentiment - actions > span { color : var ( -- diagnosis - muted ) ; font - size : 13 px } . visual - section , . diagnosis - section { margin - top : 20 px }
. data - preview , . word - cloud - card , . top - issues , . root - cause { margin - top : 16 px ; padding : 16 px ; border : 1 px solid rgba ( 99 , 183 , 222 , .31 ) ; border - radius : 12 px ; background : rgba ( 3 , 25 , 39 , .58 ) } . data - preview > div , . word - cloud - title { display : flex ; justify - content : space - between ; gap : 12 px ; color : # e6f9ff } . data - preview span , . word - cloud - title small { color : var ( -- diagnosis - muted ) ; font - size : 12 px } . data - preview ul { max - height : 130 px ; margin : 12 px 0 0 ; padding - left : 20 px ; overflow : auto ; color : # c8e5f1 ; line - height : 1.7 ; text - align : left } . word - cloud { height : 280 px } . top - issues > p , . root - cause > p { margin : 0 0 13 px ; color : # c8e5f1 ; line - height : 1.7 } . top - issues > div { display : grid ; grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr ) ) ; gap : 12 px } . top - issues label { display : grid ; gap : 7 px ; color : # 8 bdff5 ; font - weight : 800 } . sentiment - actions { display : flex ; align - items : center ; gap : 14 px ; margin - bottom : 16 px } . sentiment - actions > span { color : var ( -- diagnosis - muted ) ; font - size : 13 px } . visual - section , . diagnosis - section { margin - top : 20 px }