@ -4,7 +4,7 @@
< section class = "task-header" > < span > 核心实训 · 产品上线与运营推广 < / span > < h1 > { { taskTitle } } < / h1 > < / section >
< section class = "task-header" > < span > 核心实训 · 产品上线与运营推广 < / span > < h1 > { { taskTitle } } < / h1 > < / section >
< TrainingTaskBrief :background ="taskBackground" :goals ="taskGoals" :requirements ="taskRequirement" :show-material ="false" :show-toolbar ="false" / >
< TrainingTaskBrief :background ="taskBackground" :goals ="taskGoals" :requirements ="taskRequirement" :show-material ="false" :show-toolbar ="false" / >
< section class = "workbench" >
< section class = "workbench" >
< div class = "workbench-top" > < div > < p > 产品问题诊断与迭代 < / p > < h2 > { { currentStep === 1 ? '全链路转化漏斗诊断' : currentStep === 2 ? '用户评论根因分析' : 'AB 测试验证方案' } } < / h2 > < / div > < TrainingTaskToolbar class = "diagnosis-toolbar" :task-key ="TASK_KEY" / > < / div >
< div class = "workbench-top" > < div > < p > 产品问题诊断与迭代 < / p > < h2 > { { currentStep === 1 ? '全链路转化漏斗诊断' : currentStep === 2 ? '用户评论根因分析' : 'AB 测试验证方案' } } < / h2 > < / div > < TrainingTaskToolbar class = "diagnosis-toolbar" :task-key ="TASK_KEY" :on-submit ="submitTask" :on-reset ="resetTraining" :on-export ="exportTrainingOutcome" / > < / div >
< nav class = "step-tabs" aria -label = " 产 品 问 题 诊 断 步 骤 " > < button type = "button" : class = "['step-tab', { active: currentStep === 1, complete: currentStep > 1 }]" : aria -current = " currentStep = = = 1 ? ' step ' : undefined " @ click = "currentStep = 1" > < span class = "step-marker" > 01 < / span > < span class = "step-copy" > < b > 全链路漏斗诊断 < / b > < small > { { currentStep === 1 ? '当前步骤 →' : '点击切换 →' } } < / small > < / span > < / button > < span class = "step-connector" aria -hidden = " true " / > < button type = "button" : class = "['step-tab', { active: currentStep === 2, complete: currentStep > 2 }]" : aria -current = " currentStep = = = 2 ? ' step ' : undefined " @ click = "currentStep = 2" > < span class = "step-marker" > 02 < / span > < span class = "step-copy" > < b > 用户评论根因分析 < / b > < small > { { currentStep === 2 ? '当前步骤 →' : '点击切换 →' } } < / small > < / span > < / button > < span class = "step-connector" aria -hidden = " true " / > < button type = "button" : class = "['step-tab', { active: currentStep === 3 }]" : aria -current = " currentStep = = = 3 ? ' step ' : undefined " @ click = "currentStep = 3" > < span class = "step-marker" > 03 < / span > < span class = "step-copy" > < b > AB测试验证 < / b > < small > { { currentStep === 3 ? '当前步骤 →' : '点击切换 →' } } < / small > < / span > < / button > < / nav >
< nav class = "step-tabs" aria -label = " 产 品 问 题 诊 断 步 骤 " > < button type = "button" : class = "['step-tab', { active: currentStep === 1, complete: currentStep > 1 }]" : aria -current = " currentStep = = = 1 ? ' step ' : undefined " @ click = "currentStep = 1" > < span class = "step-marker" > 01 < / span > < span class = "step-copy" > < b > 全链路漏斗诊断 < / b > < small > { { currentStep === 1 ? '当前步骤 →' : '点击切换 →' } } < / small > < / span > < / button > < span class = "step-connector" aria -hidden = " true " / > < button type = "button" : class = "['step-tab', { active: currentStep === 2, complete: currentStep > 2 }]" : aria -current = " currentStep = = = 2 ? ' step ' : undefined " @ click = "currentStep = 2" > < span class = "step-marker" > 02 < / span > < span class = "step-copy" > < b > 用户评论根因分析 < / b > < small > { { currentStep === 2 ? '当前步骤 →' : '点击切换 →' } } < / small > < / span > < / button > < span class = "step-connector" aria -hidden = " true " / > < button type = "button" : class = "['step-tab', { active: currentStep === 3 }]" : aria -current = " currentStep = = = 3 ? ' step ' : undefined " @ click = "currentStep = 3" > < span class = "step-marker" > 03 < / span > < span class = "step-copy" > < b > AB测试验证 < / b > < small > { { currentStep === 3 ? '当前步骤 →' : '点击切换 →' } } < / small > < / span > < / button > < / nav >
< template v-if ="currentStep === 1" >
< template v-if ="currentStep === 1" >
@ -22,7 +22,7 @@
< template v -else -if = " currentStep = = = 2 " >
< template v -else -if = " currentStep = = = 2 " >
< p class = "lead" > 漏斗只能定位 “ 哪个环节出了问题 ” , 要回答为什么出问题 , 需要分析用户最真实的反馈 。 本步骤通过词云分析和情感倾向分析 , 从用户评价中提取高频问题 , 判断整体口碑倾向 , 验证漏斗异常背后的真实根因 。 < / p >
< 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 > < 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 > < / div > < / div > < div class = "sentiment-actions" > < span > 请点击情感倾向分析按钮, 系统自动判断各情感类型评论数量 , 请计算各情感类型占比 , 正面评论占比 = 正面评论数量 / 总评论数量 , 以此类推 , 计算结果用百分数表示 , 保留两位小数。 < / span > < button type = "button" class = "upload-button" :disabled ="!analysisResult" @click ="runSentimentAnalysis" > 情 感 倾 向 分 析 < / button > < / div > < div class = "table-wrap" > < table class = "sentiment-table" > < thead > < tr > < th > 情感类型 < / th > < th > 评论数量 ( 条 ) < / th > < th > 占比 < / th > < / tr > < / thead > < tbody > < tr v-for ="row in sentiments" :key="row.type"><th>{{ row.type }}</th><td><span class="auto-count">{{ row.count ?? '—' }}</span></td><td><el-input-number v-model="row.percentage" :min="0" :max="100" :precision="2" :controls="false" :disabled="row.count === null" placeholder="请输入 " /><span class="unit"> % < / span > < / td > < / tr > < / tbody > < / table > < / div > < / section >
< section class = "section-block" > < div class = "section-heading" > < span > 二 < / span > < div > < h3 > 情感倾向分析 < / h3 > < / div > < / div > < div class = "sentiment-actions" > < span > 点击情感倾向分析后, 系统会自动计算正面 、 中性 、 负面评论数量及占比 , 并按校验所需精度 保留两位小数。 < / span > < button type = "button" class = "upload-button" :disabled ="!analysisResult" @click ="runSentimentAnalysis" > 情 感 倾 向 分 析 < / button > < / div > < div class = "table-wrap" > < table class = "sentiment-table" > < thead > < tr > < th > 情感类型 < / th > < th > 评论数量 ( 条 ) < / th > < th > 占比 < / th > < / tr > < / thead > < tbody > < tr v-for ="row in sentiments" :key="row.type"><th>{{ row.type }}</th><td><span class="auto-count">{{ row.count ?? '—' }}</span></td><td><el-input-number v-model="row.percentage" :min="0" :max="100" :precision="2" :controls="false" disabled placeholder="系统自动计算 " /><span class="unit"> % < / span > < / td > < / tr > < / tbody > < / table > < / div > < / section >
< section class = "section-block root-cause" > < p > 请结合词云TOP3与情感分析结果 , 说明本案例的核心问题根本原因 ; 说明高频问题与情感数据是如何验证步骤一漏斗异常结论的 < / p > < el -input v -model = " rootCauseAnalysis " type = "textarea" :rows ="6" maxlength = "1000" show -word -limit placeholder = "请填写根因分析与对步骤一异常结论的验证说明" / > < / section >
< 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 >
@ -48,7 +48,7 @@ import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue" ;
import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue" ;
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue" ;
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue" ;
import { getTrainingTaskByKey } from "@/api/trainingTask" ;
import { getTrainingTaskByKey } from "@/api/trainingTask" ;
import { analyzeProductDiagnosisIterationStepTwo , getStudentTrainingAnswer, saveStudentTrainingAnswer , uploadStudentTrainingFile , validateProductDiagnosisIterationStepOne , validateProductDiagnosisIterationStepTwo , validateProductDiagnosisIterationStepThree } from "@/api/studentTrainingAnswer" ;
import { analyzeProductDiagnosisIterationStepTwo , deleteStudentTrainingAnswer, getStudentTrainingAnswer, saveStudentTrainingAnswer , uploadStudentTrainingFile , validateProductDiagnosisIterationStepOne , validateProductDiagnosisIterationStepTwo , validateProductDiagnosisIterationStepThree } from "@/api/studentTrainingAnswer" ;
import { parseTrainingArray } from "@/views/training/taskKeyMap" ;
import { parseTrainingArray } from "@/views/training/taskKeyMap" ;
import { isStudentDemo } from "@/utils/studentDemo" ;
import { isStudentDemo } from "@/utils/studentDemo" ;
@ -77,7 +77,17 @@ async function handleChartUpload(event) { const file = event.target?.files?.[0];
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 = "" ; } }
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 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 ? . ( "评论数量已自动填充,请计算并填写各类情感占比" ) ; }
function runSentimentAnalysis ( ) {
if ( ! analysisResult . value ) return ;
const counts = [ analysisResult . value . positiveCount , analysisResult . value . neutralCount , analysisResult . value . negativeCount ] . map ( ( value ) => Number ( value ) || 0 ) ;
const total = counts . reduce ( ( sum , value ) => sum + value , 0 ) ;
sentiments . value = [ "正面" , "中性" , "负面" ] . map ( ( type , index ) => ( {
type ,
count : counts [ index ] ,
percentage : total > 0 ? Number ( ( counts [ index ] * 100 / total ) . toFixed ( 2 ) ) : null ,
} ) ) ;
proxy ? . $modal ? . msgSuccess ? . ( "情感评论数量与占比已自动计算" ) ;
}
async function saveStepTwoDraft ( ) { if ( saving . value ) return false ; saving . value = true ; try { await saveStudentTrainingAnswer ( TASK _KEY , { step2Answer : JSON . stringify ( buildStepTwo ( ) ) , currentStep : 2 , submitted : false , saveAction : "SAVE" } ) ; proxy ? . $modal ? . msgSuccess ? . ( "当前步骤已保存" ) ; return true ; } catch ( error ) { proxy ? . $modal ? . msgError ? . ( error ? . message || "保存失败,请重试" ) ; return false ; } finally { saving . value = false ; } } async function validateStepTwo ( ) { if ( saving . value ) return false ; saving . value = true ; try { const data = buildStepTwo ( ) ; await validateProductDiagnosisIterationStepTwo ( data ) ; await saveStudentTrainingAnswer ( TASK _KEY , { step2Answer : JSON . stringify ( data ) , currentStep : 2 , submitted : false , saveAction : "SAVE" } ) ; proxy ? . $modal ? . msgSuccess ? . ( "第 2 步校验通过,已保存" ) ; return true ; } catch ( error ) { proxy ? . $modal ? . msgError ? . ( error ? . message || "请检查词云、情感占比和根因分析" ) ; return false ; } finally { saving . value = false ; } }
async function saveStepTwoDraft ( ) { if ( saving . value ) return false ; saving . value = true ; try { await saveStudentTrainingAnswer ( TASK _KEY , { step2Answer : JSON . stringify ( buildStepTwo ( ) ) , currentStep : 2 , submitted : false , saveAction : "SAVE" } ) ; proxy ? . $modal ? . msgSuccess ? . ( "当前步骤已保存" ) ; return true ; } catch ( error ) { proxy ? . $modal ? . msgError ? . ( error ? . message || "保存失败,请重试" ) ; return false ; } finally { saving . value = false ; } } async function validateStepTwo ( ) { if ( saving . value ) return false ; saving . value = true ; try { const data = buildStepTwo ( ) ; await validateProductDiagnosisIterationStepTwo ( data ) ; await saveStudentTrainingAnswer ( TASK _KEY , { step2Answer : JSON . stringify ( data ) , currentStep : 2 , submitted : false , saveAction : "SAVE" } ) ; proxy ? . $modal ? . msgSuccess ? . ( "第 2 步校验通过,已保存" ) ; return true ; } catch ( error ) { proxy ? . $modal ? . msgError ? . ( error ? . message || "请检查词云、情感占比和根因分析" ) ; return false ; } finally { saving . value = false ; } }
function clearAbChart ( ) { if ( abChart . value . url . startsWith ( "blob:" ) ) URL . revokeObjectURL ( abChart . value . url ) ; abChart . value = { url : "" , name : "" } ; }
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 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 = "" ; } }
@ -92,6 +102,50 @@ async function nextStep() {
if ( currentStep . value === 2 && await validateStepTwo ( ) ) currentStep . value = 3 ;
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 validateStepThree ( ) { if ( saving . value ) return ; saving . value = true ; try { const data = buildStepThree ( ) ; await validateProductDiagnosisIterationStepThree ( data ) ; await saveStudentTrainingAnswer ( TASK _KEY , { step3Answer : JSON . stringify ( data ) , currentStep : 3 , submitted : false , saveAction : "SAVE" } ) ; proxy ? . $modal ? . msgSuccess ? . ( "第 3 步校验通过,已保存" ) ; } catch ( error ) { proxy ? . $modal ? . msgError ? . ( error ? . message || "请检查 AB 测试方案、指标数据和对比图" ) ; } finally { saving . value = false ; } }
async function submitTask ( ) {
if ( saving . value || uploading . value || analyzing . value ) return false ;
saving . value = true ;
try {
const stepOne = buildStepOne ( ) ;
await validateProductDiagnosisIterationStepOne ( stepOne ) ;
await saveStudentTrainingAnswer ( TASK _KEY , { step1Answer : JSON . stringify ( stepOne ) , currentStep : 1 , submitted : false , saveAction : "SAVE" } ) ;
const stepTwo = buildStepTwo ( ) ;
await validateProductDiagnosisIterationStepTwo ( stepTwo ) ;
await saveStudentTrainingAnswer ( TASK _KEY , { step2Answer : JSON . stringify ( stepTwo ) , currentStep : 2 , submitted : false , saveAction : "SAVE" } ) ;
const stepThree = buildStepThree ( ) ;
await validateProductDiagnosisIterationStepThree ( stepThree ) ;
await saveStudentTrainingAnswer ( TASK _KEY , { step3Answer : JSON . stringify ( stepThree ) , currentStep : 3 , submitted : true , saveAction : "SUBMIT" } ) ;
currentStep . value = 3 ;
proxy ? . $modal ? . msgSuccess ? . ( "实训任务已提交" ) ;
return true ;
} catch ( error ) {
proxy ? . $modal ? . msgError ? . ( error ? . message || "提交失败,请检查三个步骤的填写内容" ) ;
return false ;
} finally {
saving . value = false ;
}
}
async function resetTraining ( ) {
if ( saving . value || uploading . value || analyzing . value ) return false ;
try {
await deleteStudentTrainingAnswer ( TASK _KEY ) ;
clearChart ( ) ; clearAbChart ( ) ; wordCloudChart ? . dispose ( ) ; wordCloudChart = null ;
conversionRates . value = createConversionRates ( ) ; anomalyRows . value = [ createAnomalyRow ( ) ] ; analysisResult . value = null ;
commentPreview . value = { fileName : "" , totalComments : 0 , comments : [ ] } ; wordCloud . value = [ ] ; topIssues . value = [ "" , "" , "" ] ; sentiments . value = createSentiments ( ) ; rootCauseAnalysis . value = "" ;
abTestPlan . value = "" ; abRows . value = createAbRows ( ) ; iterationRecommendation . value = "" ; currentStep . value = 1 ;
proxy ? . $modal ? . msgSuccess ? . ( "已清空全部实训步骤,可以重新开始" ) ;
return true ;
} catch ( error ) {
proxy ? . $modal ? . msgError ? . ( error ? . message || "重新实训失败,请稍后重试" ) ;
return false ;
}
}
function exportTrainingOutcome ( ) {
const outcome = { taskName : taskTitle . value , exportedAt : new Date ( ) . toISOString ( ) , step1 : buildStepOne ( ) , step2 : buildStepTwo ( ) , step3 : buildStepThree ( ) } ;
const url = URL . createObjectURL ( new Blob ( [ JSON . stringify ( outcome , null , 2 ) ] , { type : "application/json;charset=utf-8" } ) ) ;
const link = document . createElement ( "a" ) ; link . href = url ; link . download = "产品问题诊断与迭代-实训成果.json" ; document . body . appendChild ( link ) ; link . click ( ) ; document . body . removeChild ( link ) ; URL . revokeObjectURL ( url ) ;
proxy ? . $modal ? . msgSuccess ? . ( "实训成果已导出" ) ;
}
function restore ( answer ) { try { const stepOne = JSON . parse ( answer ? . step1Answer || "{}" ) ; if ( Array . isArray ( stepOne . conversionRates ) && stepOne . conversionRates . length === nodes . length ) conversionRates . value = nodes . map ( ( node ) => ( { ... createConversionRates ( ) . find ( ( row ) => row . linkNode === node ) , ... ( stepOne . conversionRates . find ( ( row ) => row ? . linkNode === node ) || { } ) } ) ) ; if ( Array . isArray ( stepOne . anomalyRows ) && stepOne . anomalyRows . length ) anomalyRows . value = stepOne . anomalyRows . slice ( 0 , 5 ) . map ( ( row ) => ( { ... createAnomalyRow ( ) , ... row } ) ) ; if ( stepOne . funnelChartUrl ) funnelChart . value = { url : stepOne . funnelChartUrl , name : stepOne . funnelChartName || "节点转化漏斗对比图" } ; const stepTwo = JSON . parse ( answer ? . step2Answer || "{}" ) ; if ( stepTwo . sourceFileName ) { commentPreview . value = { fileName : stepTwo . sourceFileName , totalComments : 0 , comments : stepTwo . previewComments || [ ] } ; wordCloud . value = stepTwo . wordCloud || [ ] ; topIssues . value = [ 0 , 1 , 2 ] . map ( ( index ) => stepTwo . topIssues ? . [ index ] || "" ) ; sentiments . value = ( stepTwo . sentiments || createSentiments ( ) ) . map ( ( row ) => ( { ... createSentiments ( ) . find ( ( item ) => item . type === row . type ) , ... row } ) ) ; rootCauseAnalysis . value = stepTwo . rootCauseAnalysis || "" ; nextTick ( renderWordCloud ) ; } const stepThree = JSON . parse ( answer ? . step3Answer || "{}" ) ; if ( stepThree . testPlan || stepThree . comparisonChartUrl || stepThree . iterationRecommendation ) { abTestPlan . value = stepThree . testPlan || "" ; if ( Array . isArray ( stepThree . rows ) && stepThree . rows . length === createAbRows ( ) . length ) abRows . value = createAbRows ( ) . map ( ( row ) => ( { ... row , ... ( stepThree . rows . find ( ( item ) => item ? . metric === row . metric ) || { } ) } ) ) ; if ( stepThree . comparisonChartUrl ) abChart . value = { url : stepThree . comparisonChartUrl , name : stepThree . comparisonChartName || "AB 测试结果对比图" } ; iterationRecommendation . value = stepThree . iterationRecommendation || "" ; } currentStep . value = [ 1 , 2 , 3 ] . includes ( Number ( answer ? . currentStep ) ) ? Number ( answer . currentStep ) : 1 ; } catch ( error ) { } }
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 ( ) ; } ) ;
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 >