< template >
< div class = "industry-pest-page student-training-shell" >
< main class = "industry-core" >
< section class = "task-header" >
< div class = "task-head-main" >
< span class = "task-badge" > < el -icon > < DataAnalysis / > < / e l - i c o n > 核 心 实 训 · 市 场 洞 察 与 需 求 分 析 < / s p a n >
< h1 class = "task-title" > { { taskTitle } } < / h1 >
< p class = "task-brief" >
< span class = "task-brief-text" > { { taskBackground } } < / span >
< button type = "button" class = "task-brief-toggle" @ click = "briefOpen = !briefOpen" >
{ { briefOpen ? "收起" : "展开目标与要求" } }
< / button >
< span v-if ="briefOpen" class="task-brief-more" >
< b > 实训目标 < / b >
< ul > < li v-for ="goal in taskGoals" :key ="goal" > {{ goal }} < / li > < / ul >
< b > 实训要求 < / b >
< p > { { taskRequirement } } < / p >
< / span >
< / p >
< / div >
< TrainingTaskToolbar class = "industry-demand-toolbar" :task-key ="TASK_KEY" :on-submit ="submitTraining" :on-reset ="resetTraining" :on-export ="exportOutcome" / >
< / section >
< section class = "industry-workbench" >
< nav class = "step-indicator" aria -label = " 行 业 需 求 分 析 步 骤 " >
< template v-for ="(step, index) in trainingSteps" :key="`${step}-${index}`" >
< button type = "button" : class = "['step-tab', { active: currentStep === index + 1, complete: currentStep > index + 1 }]" @ click = "switchStep(index + 1)" >
< span class = "step-marker" > { { String ( index + 1 ) . padStart ( 2 , '0' ) } } < / span >
< span class = "step-copy" > < strong class = "step-name" :title ="step" > { { step } } < / strong > < span class = "step-action" > { { currentStep === index + 1 ? '当前步骤' : '点击切换' } } → < / span > < / span >
< / button >
< span v -if = " index < trainingSteps.length - 1 " class = "step-connector" / >
< / template >
< / nav >
< section class = "industry-step-card" >
< template v-if ="currentStep === 1" >
< div class = "step-title-row" > < div > < p class = "section-eyebrow" > Step 01 < / p > < h3 > { { trainingSteps [ 0 ] } } < / h3 > < / div > < span class = "step-chip" > PEST 分类练习 < / span > < / div >
< p class = "step-desc" > 请根据案例文档中的案例信息进行 PEST 分析 , 进行上述各项内容归类 , 把文档中相应内容填写到以下四类中 。 < / p >
< TrainingStepOneChoiceQuestions :questions ="taskConfig?.stepOneQuestions" v -model = " stepOneChoiceAnswers " / >
< div class = "pest-table-wrap" >
< table class = "pest-table" > < thead > < tr > < th > 序号 < / th > < th > PEST类型 < / th > < th > 信息资讯 < / th > < / tr > < / thead >
< tbody > < tr v-for ="(row, index) in pestRows" :key="row.key"><td>{{ index + 1 }}</td><td class="type-cell" > {{ row.label }} < / td > < td > < el -input v -model .trim = " form [ row.key ] " type = "textarea" : autosize = "{ minRows: 2 }" :placeholder ="row.placeholder" / > < / td > < / tr > < / tbody > < / table > < / div >
< / template >
< template v -else -if = " currentStep = = = 2 " >
< div class = "step-title-row" > < div > < p class = "section-eyebrow" > Step 02 < / p > < h3 > { { trainingSteps [ 1 ] } } < / h3 > < / div > < span class = "step-chip" > 资料补充 < / span > < / div >
< div class = "research-guide" >
< p > 请自行收集资料 , 补充 PEST 完善 , 每个维度至少补充 1 条 。 信息收集可参考以下方法 : < / p >
< ol >
< li > 自行通过互联网平台搜索 、 电商平台 ( 京东 / 天猫搜索 “ 智能健身镜 ” ) 、 行业报告网站 ( 艾瑞咨询 、 前瞻产业研究院等 ) 、 新闻资讯 ( 百度 / 头条搜索健身行业关键词 ) 。 < / li >
< li > 使用 AI 大模型智能搜索功能进行信息收集 , 参考提示词见实训任务案例文档 。 < / li >
< / ol >
< p > 请把补充的 PEST 信息资讯填入下表 : < / p >
< / div >
< p v-if ="!stepOnePassed" class="step-warning" > 请 先 完 成 并 保 存 第 1 步 的 PEST 信 息 归 类 , 再 填 写 本 步 骤 。 < / p >
< div class = "pest-table-wrap" >
< table class = "pest-table" > < thead > < tr > < th > 序号 < / th > < th > PEST类型 < / th > < th > 补充信息资讯 < / th > < / tr > < / thead >
< tbody > < tr v-for ="(row, index) in pestRows" :key="row.key"><td>{{ index + 1 }}</td><td class="type-cell" > {{ row.label }} < / td > < td > < el -input v -model .trim = " supplementForm [ row.key ] " type = "textarea" : autosize = "{ minRows: 2 }" :disabled ="!stepOnePassed" :placeholder ="row.placeholder" / > < / td > < / tr > < / tbody > < / table > < / div >
< / template >
< template v -else -if = " currentStep = = = 3 " >
< div class = "step-title-row" > < div > < p class = "section-eyebrow" > Step 03 < / p > < h3 > { { trainingSteps [ 2 ] } } < / h3 > < / div > < span class = "step-chip" > 机会与挑战 < / span > < / div >
< p class = "step-desc" > 基于上述 PEST 信息归类与补充的信息 , 从每个 PEST 维度分别提炼该产品面临的核心机遇与潜在挑战 , 并给出应对思路 。 每个单元格至少填写 1 条 , 可按换行分条 。 < / p >
< p v-if ="!stepTwoPassed" class="step-warning" > 请 先 完 成 并 保 存 第 2 步 的 PEST 补 充 资 讯 , 再 填 写 本 步 骤 。 < / p >
< div class = "analysis-table-wrap" >
< table class = "analysis-table" > < thead > < tr > < th > PEST维度 < / th > < th > 核心机遇 ( 至少1条 ) < / th > < th > 潜在挑战 ( 至少1条 ) < / th > < th > 应对思路建议 < / th > < / tr > < / thead >
< tbody > < tr v-for ="row in pestAnalysisRows" :key="row.key"><td class="type-cell" > {{ row.label }} < / td > < td > < el -input v -model .trim = " analysisForm [ row.key ] .opportunities " type = "textarea" : autosize = "{ minRows: 2 }" :disabled ="!stepTwoPassed" placeholder = "例如:政策、需求或技术带来的增长机会" / > < / td > < td > < el -input v -model .trim = " analysisForm [ row.key ] .challenges " type = "textarea" : autosize = "{ minRows: 2 }" :disabled ="!stepTwoPassed" placeholder = "例如:合规、成本、竞争或使用门槛" / > < / td > < td > < el -input v -model .trim = " analysisForm [ row.key ] .strategies " type = "textarea" : autosize = "{ minRows: 2 }" :disabled ="!stepTwoPassed" placeholder = "例如:针对挑战给出的产品、运营或合规方案" / > < / td > < / tr > < / tbody > < / table > < / div >
< / template >
< template v -else -if = " currentStep = = = 4 " >
< div class = "step-title-row" > < div > < p class = "section-eyebrow" > Step 04 < / p > < h3 > { { trainingSteps [ 3 ] } } < / h3 > < / div > < span class = "step-chip" > 综合结论 < / span > < / div >
< p class = "step-desc" > 基于前述 PEST 分析 , 完成行业环境评估 、 市场进入判断与风险总结 。 < / p >
< p v-if ="!stepThreePassed" class="step-warning" > 请 先 完 成 并 保 存 第 3 步 的 机 会 与 挑 战 分 析 , 再 填 写 本 步 骤 。 < / p >
< div class = "conclusion-form" : class = "{ 'is-disabled': !stepThreePassed }" >
< section class = "conclusion-section" > < h4 > ( 1 ) 总体 PEST 环境评估 < / h4 > < p > 概括当前宏观环境对该产品的总体友好程度 , 从以下五个等级中选择一个 : < / p > < div class = "option-row" > < el -radio -group v-model ="conclusionForm.overallAssessment" :disabled="!stepThreePassed" class="option-row-group"><el-radio v-for="item in assessmentOptions" :key="item" :label="item" class="option-card" > {{ item }} < / el -radio > < / e l - r a d i o - g r o u p > < / d i v > < / s e c t i o n >
< section class = "conclusion-section" > < h4 > ( 2 ) 市场进入结论 < / h4 > < p > 从以下三个结论中选择一个 , 并说明理由 : < / p > < div class = "option-row decision-options" > < el -radio -group v-model ="conclusionForm.marketDecision" :disabled="!stepThreePassed" class="option-row-group"><el-radio v-for="item in decisionOptions" :key="item" :label="item" class="option-card" > {{ item }} < / el -radio > < / e l - r a d i o - g r o u p > < / d i v > < e l - i n p u t v - m o d e l . t r i m = " c o n c l u s i o n F o r m . d e c i s i o n R e a s o n " t y p e = " t e x t a r e a " : a u t o s i z e = " { m i n R o w s : 2 } " : d i s a b l e d = " ! s t e p T h r e e P a s s e d " p l a c e h o l d e r = " 请 说 明 市 场 进 入 结 论 的 理 由 " / > < / s e c t i o n >
< section class = "conclusion-section" > < h4 > ( 3 ) 市场进入需要关注的风险点 < / h4 > < p > 请指出 2 个以上的风险点 。 < / p > < div class = "risk-grid" > < label > 核心风险点 1 : < el -input v -model .trim = " conclusionForm.riskOne " type = "textarea" : autosize = "{ minRows: 2 }" :disabled ="!stepThreePassed" placeholder = "填写核心风险点" / > < / label > < label > 核心风险点 2 : < el -input v -model .trim = " conclusionForm.riskTwo " type = "textarea" : autosize = "{ minRows: 2 }" :disabled ="!stepThreePassed" placeholder = "填写核心风险点" / > < / label > < label > 其他风险点 : < el -input v -model .trim = " conclusionForm.otherRisks " type = "textarea" : autosize = "{ minRows: 2 }" :disabled ="!stepThreePassed" placeholder = "可补充其他风险点" / > < / label > < / div > < / section >
< section class = "conclusion-section" > < h4 > ( 4 ) 总结 PEST 环境评估结论 < / h4 > < el -input v -model .trim = " conclusionForm.summary " type = "textarea" : autosize = "{ minRows: 3 }" :disabled ="!stepThreePassed" placeholder = "总结该产品的 PEST 环境评估结论" / > < / section >
< / div >
< / template >
< div v -else class = "step-placeholder" > < p > 第 { { currentStep } } 步内容待配置 < / p > < span > 前序步骤已保存 , 可从上方步骤栏返回查看 。 < / span > < / div >
< div class = "step-navigation" >
< button v-if ="currentStep > 1" type="button" class="btn-nav" :disabled="saving" @click="previousStep" > 上 一 步 < / button >
< button type = "button" class = "btn-nav btn-primary" :disabled ="saving" @click ="saveCurrentProgress" > 保 存 当 前 进 度 < / button >
< button v-if ="currentStep < trainingSteps.length" type="button" class="btn-nav btn-primary" :disabled="saving" @click="nextStep" > 下 一 步 < / button >
< / div >
< / section >
< / section >
< / main >
< TrainingAiSidebar study -tip = " 先 从 案 例 中 提 取 宏 观 环 境 信 息 , 再 按 政 治 、 经 济 、 社 会 、 技 术 四 个 维 度 归 类 和 补 充 。 " reference -title = " PEST 分 类 提 示 " reference -text = " · 政 治 : 政 策 、 法 规 、 认 证 、 税 收 、 数 据 合 规 < br / > · 经济 : 市场规模 、 成本 、 商业模式 、 供应链 < br / > · 社会 : 人群需求 、 消费趋势 、 生活方式与场景 < br / > · 技术 : 产品技术 、 平台能力 、 设备互联与创新 " :progress-items=" progressItems " / >
< / div >
< / template >
< script setup >
import { DataAnalysis } from "@element-plus/icons-vue" ;
import { checkIndustryDemandAnalysisStepOne , checkIndustryDemandAnalysisStepTwo , checkIndustryDemandAnalysisStepThree , checkIndustryDemandAnalysisStepFour , deleteStudentTrainingAnswer , getStudentTrainingAnswer , saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer" ;
import { getTrainingTaskByKey } from "@/api/trainingTask" ;
import { parseTrainingArray } from "@/views/training/taskKeyMap" ;
import useUserStore from "@/store/modules/user" ;
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue" ;
import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue" ;
import TrainingStepOneChoiceQuestions from "@/views/components/TrainingStepOneChoiceQuestions.vue" ;
const TASK _KEY = "industry-demand-analysis" ;
const { proxy } = getCurrentInstance ( ) ;
const userStore = useUserStore ( ) ;
const DEFAULT _STEPS = [ "PEST宏观环境分析" , "行业机会识别" , "需求判断" , "分析结论" ] ;
const pestRows = [
{ key : "political" , label : "政治环境" , placeholder : "填写政策、法规、认证、税收或数据合规等信息" } ,
{ key : "economic" , label : "经济环境" , placeholder : "填写市场规模、成本、商业模式或供应链等信息" } ,
{ key : "social" , label : "社会环境" , placeholder : "填写用户需求、消费趋势、生活方式或场景变化等信息" } ,
{ key : "technology" , label : "技术环境" , placeholder : "填写产品技术、平台能力、设备互联或创新等信息" } ,
] ;
const pestAnalysisRows = [
{ key : "political" , label : "政治环境( P) " } ,
{ key : "economic" , label : "经济环境( E) " } ,
{ key : "social" , label : "社会环境( S) " } ,
{ key : "technology" , label : "技术环境( T) " } ,
] ;
const assessmentOptions = [ "高度友好" , "较为友好" , "中性" , "存在风险" , "风险较高" ] ;
const decisionOptions = [ "建议进入" , "谨慎进入" , "建议暂缓" ] ;
const defaultTask = {
title : "行业需求分析" ,
background : "通过 PEST 模型识别影响行业需求的宏观环境因素,为后续产品机会判断提供依据。" ,
goals : [ "掌握 PEST 四维度的分类方法" , "提炼案例中的宏观环境信息" , "形成行业需求分析的基础材料" ] ,
requirement : "请阅读案例资料,将相关信息归类到政治、经济、社会和技术环境中,并补充自行收集的行业资讯。" ,
} ;
const taskConfig = ref ( null ) ;
const briefOpen = ref ( false ) ;
const currentStep = ref ( 1 ) ;
const saving = ref ( false ) ;
const draftReady = ref ( false ) ;
const stepOnePassed = ref ( false ) ;
const stepTwoPassed = ref ( false ) ;
const stepThreePassed = ref ( false ) ;
const form = ref ( createForm ( ) ) ;
const stepOneChoiceAnswers = ref ( { } ) ;
const supplementForm = ref ( createForm ( ) ) ;
const analysisForm = ref ( createAnalysisForm ( ) ) ;
const conclusionForm = ref ( createConclusionForm ( ) ) ;
const taskTitle = computed ( ( ) => taskConfig . value ? . taskName || defaultTask . title ) ;
const taskBackground = computed ( ( ) => taskConfig . value ? . background || defaultTask . background ) ;
const taskGoals = computed ( ( ) => parseArray ( taskConfig . value ? . objectives , defaultTask . goals ) ) ;
const taskRequirement = computed ( ( ) => taskConfig . value ? . requirements || defaultTask . requirement ) ;
const trainingSteps = computed ( ( ) => parseTrainingArray ( taskConfig . value ? . steps , DEFAULT _STEPS ) ) ;
const progressItems = computed ( ( ) => pestRows . map ( ( row ) => ( { text : ` ${ conclusionForm . value . summary ? "已总结" : analysisForm . value [ row . key ] . opportunities ? "已分析" : supplementForm . value [ row . key ] ? "已补充" : form . value [ row . key ] ? "已归类" : "待填写" } : ${ row . label } ` , status : conclusionForm . value . summary || analysisForm . value [ row . key ] . opportunities || supplementForm . value [ row . key ] || form . value [ row . key ] ? "done" : "" } ) ) ) ;
onMounted ( async ( ) => { await loadTaskConfig ( ) ; loadDraft ( ) ; await loadSavedAnswer ( ) ; draftReady . value = true ; } ) ;
watch ( ( ) => JSON . stringify ( buildDraft ( ) ) , persistDraft ) ;
function createForm ( ) { return { political : "" , economic : "" , social : "" , technology : "" } ; }
function createAnalysisForm ( ) { return { political : createAnalysisRow ( ) , economic : createAnalysisRow ( ) , social : createAnalysisRow ( ) , technology : createAnalysisRow ( ) } ; }
function createAnalysisRow ( ) { return { opportunities : "" , challenges : "" , strategies : "" } ; }
function createConclusionForm ( ) { return { overallAssessment : "" , marketDecision : "" , decisionReason : "" , riskOne : "" , riskTwo : "" , otherRisks : "" , summary : "" } ; }
function parseArray ( value , fallback ) { if ( Array . isArray ( value ) ) return value . length ? value : fallback ; if ( ! value ) return fallback ; try { const parsed = JSON . parse ( value ) ; return Array . isArray ( parsed ) && parsed . length ? parsed . map ( ( item ) => String ( item ? . name ? ? item ) . trim ( ) ) . filter ( Boolean ) : fallback ; } catch ( error ) { const list = String ( value ) . split ( /[, ,|]/ ) . map ( ( item ) => item . trim ( ) ) . filter ( Boolean ) ; return list . length ? list : fallback ; } }
async function loadTaskConfig ( ) { try { taskConfig . value = ( await getTrainingTaskByKey ( TASK _KEY ) ) ? . data || null ; } catch ( error ) { taskConfig . value = null ; } }
async function loadSavedAnswer ( ) {
try {
const answer = ( await getStudentTrainingAnswer ( TASK _KEY ) ) ? . data ;
if ( answer ? . step1Answer ) { restoreOutcome ( answer . step1Answer , form ) ; stepOnePassed . value = true ; clearDraft ( ) ; }
if ( answer ? . step2Answer ) { restoreOutcome ( answer . step2Answer , supplementForm , "supplements" ) ; stepTwoPassed . value = true ; }
if ( answer ? . step3Answer ) { restoreAnalysisOutcome ( answer . step3Answer ) ; stepThreePassed . value = true ; }
if ( answer ? . step4Answer ) restoreConclusionOutcome ( answer . step4Answer ) ;
if ( Number ( answer ? . currentStep ) > 0 ) currentStep . value = Number ( answer . currentStep ) ;
} catch ( error ) { /* Keep the local draft when the answer endpoint is unavailable. */ }
}
function restoreOutcome ( value , target , field = "pest" ) { try { const parsed = typeof value === "string" ? JSON . parse ( value ) : value ; target . value = { ... createForm ( ) , ... ( parsed ? . [ field ] || parsed || { } ) } ; if ( field === "pest" && parsed ? . choiceAnswers && typeof parsed . choiceAnswers === "object" ) stepOneChoiceAnswers . value = parsed . choiceAnswers ; } catch ( error ) { target . value = createForm ( ) ; } }
function restoreAnalysisOutcome ( value ) { try { const parsed = typeof value === "string" ? JSON . parse ( value ) : value ; const source = parsed ? . analysis || parsed || { } ; analysisForm . value = Object . fromEntries ( pestAnalysisRows . map ( ( row ) => [ row . key , { ... createAnalysisRow ( ) , ... ( source [ row . key ] || { } ) } ] ) ) ; } catch ( error ) { analysisForm . value = createAnalysisForm ( ) ; } }
function restoreConclusionOutcome ( value ) { try { const parsed = typeof value === "string" ? JSON . parse ( value ) : value ; conclusionForm . value = { ... createConclusionForm ( ) , ... ( parsed ? . conclusion || parsed || { } ) } ; } catch ( error ) { conclusionForm . value = createConclusionForm ( ) ; } }
function buildStepOneOutcome ( ) { return { title : taskTitle . value , pest : { ... form . value } , choiceAnswers : { ... stepOneChoiceAnswers . value } } ; }
function buildStepTwoOutcome ( ) { return { title : taskTitle . value , supplements : { ... supplementForm . value } } ; }
function buildStepThreeOutcome ( ) { return { title : taskTitle . value , analysis : JSON . parse ( JSON . stringify ( analysisForm . value ) ) } ; }
function buildStepFourOutcome ( ) { return { title : taskTitle . value , conclusion : { ... conclusionForm . value } } ; }
function buildDraft ( ) { return { step1 : buildStepOneOutcome ( ) , step2 : buildStepTwoOutcome ( ) , step3 : buildStepThreeOutcome ( ) , step4 : buildStepFourOutcome ( ) , currentStep : currentStep . value } ; }
function getLocalDraftKey ( ) { const info = userStore . userInfo || { } ; return ` ${ TASK _KEY } :draft: ${ info . userId || info . user _id || info . username || info . userName || "anonymous" } ` ; }
function persistDraft ( ) { if ( ! draftReady . value ) return ; try { localStorage . setItem ( getLocalDraftKey ( ) , JSON . stringify ( buildDraft ( ) ) ) ; } catch ( error ) { /* Browser draft is optional. */ } }
function loadDraft ( ) { try { const raw = localStorage . getItem ( getLocalDraftKey ( ) ) ; if ( raw ) { const draft = JSON . parse ( raw ) ; restoreOutcome ( draft ? . step1 || draft , form ) ; restoreOutcome ( draft ? . step2 , supplementForm , "supplements" ) ; restoreAnalysisOutcome ( draft ? . step3 ) ; restoreConclusionOutcome ( draft ? . step4 ) ; if ( Number ( draft ? . currentStep ) > 0 ) currentStep . value = Number ( draft . currentStep ) ; } } catch ( error ) { clearDraft ( ) ; } }
function clearDraft ( ) { try { localStorage . removeItem ( getLocalDraftKey ( ) ) ; } catch ( error ) { /* Ignore storage failures. */ } }
function switchStep ( step ) { if ( step === 2 && ! stepOnePassed . value ) { proxy ? . $modal ? . msgWarning ( "请先完成并保存第 1 步" ) ; return ; } if ( step === 3 && ! stepTwoPassed . value ) { proxy ? . $modal ? . msgWarning ( "请先完成并保存第 2 步" ) ; return ; } if ( step === 4 && ! stepThreePassed . value ) { proxy ? . $modal ? . msgWarning ( "请先完成并保存第 3 步" ) ; return ; } currentStep . value = step ; }
async function saveAndGoToStepTwo ( ) { if ( saving . value ) return ; saving . value = true ; try { await checkIndustryDemandAnalysisStepOne ( form . value ) ; await saveStudentTrainingAnswer ( TASK _KEY , { step1Answer : JSON . stringify ( buildStepOneOutcome ( ) ) , currentStep : 2 , submitted : false , saveAction : "SAVE" } ) ; stepOnePassed . value = true ; clearDraft ( ) ; currentStep . value = 2 ; proxy ? . $modal ? . msgSuccess ( "已保存,已进入第 2 步" ) ; } catch ( error ) { proxy ? . $modal ? . msgError ? . ( error ? . message || "请完整填写 PEST 四类信息" ) ; } finally { saving . value = false ; } }
async function saveAndGoToStepThree ( ) { if ( saving . value || ! stepOnePassed . value ) return ; saving . value = true ; try { await checkIndustryDemandAnalysisStepTwo ( supplementForm . value ) ; await saveStudentTrainingAnswer ( TASK _KEY , { step2Answer : JSON . stringify ( buildStepTwoOutcome ( ) ) , currentStep : 3 , submitted : false , saveAction : "SAVE" } ) ; stepTwoPassed . value = true ; clearDraft ( ) ; currentStep . value = 3 ; proxy ? . $modal ? . msgSuccess ( "已保存,已进入第 3 步" ) ; } catch ( error ) { proxy ? . $modal ? . msgError ? . ( error ? . message || "请为每个 PEST 维度至少补充 1 条信息" ) ; } finally { saving . value = false ; } }
async function saveAndGoToStepFour ( ) { if ( saving . value || ! stepTwoPassed . value ) return ; saving . value = true ; try { await checkIndustryDemandAnalysisStepThree ( { rows : pestAnalysisRows . map ( ( row ) => ( { dimension : row . key , ... analysisForm . value [ row . key ] } ) ) } ) ; await saveStudentTrainingAnswer ( TASK _KEY , { step3Answer : JSON . stringify ( buildStepThreeOutcome ( ) ) , currentStep : 4 , submitted : false , saveAction : "SAVE" } ) ; stepThreePassed . value = true ; clearDraft ( ) ; currentStep . value = 4 ; proxy ? . $modal ? . msgSuccess ( "已保存,已进入第 4 步" ) ; } catch ( error ) { proxy ? . $modal ? . msgError ? . ( error ? . message || "请完成每个 PEST 维度的机遇、挑战和应对思路" ) ; } finally { saving . value = false ; } }
async function submitTraining ( ) { if ( saving . value || ! stepThreePassed . value ) return ; saving . value = true ; try { await checkIndustryDemandAnalysisStepFour ( conclusionForm . value ) ; await saveStudentTrainingAnswer ( TASK _KEY , { step4Answer : JSON . stringify ( buildStepFourOutcome ( ) ) , currentStep : 4 , submitted : true , saveAction : "SUBMIT" } ) ; clearDraft ( ) ; proxy ? . $modal ? . msgSuccess ( "任务提交成功" ) ; } catch ( error ) { proxy ? . $modal ? . msgError ? . ( error ? . message || "请完成 PEST 环境评估结论" ) ; } finally { saving . value = false ; } }
async function saveCurrentStepFour ( ) { if ( saving . value || ! stepThreePassed . value ) return ; saving . value = true ; try { await checkIndustryDemandAnalysisStepFour ( conclusionForm . value ) ; await saveStudentTrainingAnswer ( TASK _KEY , { step4Answer : JSON . stringify ( buildStepFourOutcome ( ) ) , currentStep : 4 , submitted : false , saveAction : "SAVE" } ) ; clearDraft ( ) ; proxy ? . $modal ? . msgSuccess ( "第 4 步已保存" ) ; } catch ( error ) { proxy ? . $modal ? . msgError ? . ( error ? . message || "请完成 PEST 环境评估结论" ) ; } finally { saving . value = false ; } }
async function saveCurrentProgress ( showMessage = true ) {
if ( saving . value ) return false ;
saving . value = true ;
try {
const payloads = {
1 : async ( ) => { await checkIndustryDemandAnalysisStepOne ( form . value ) ; return { step1Answer : JSON . stringify ( buildStepOneOutcome ( ) ) , currentStep : 1 } ; } ,
2 : async ( ) => { if ( ! stepOnePassed . value ) throw new Error ( "请先完成第 1 步" ) ; await checkIndustryDemandAnalysisStepTwo ( supplementForm . value ) ; return { step2Answer : JSON . stringify ( buildStepTwoOutcome ( ) ) , currentStep : 2 } ; } ,
3 : async ( ) => { if ( ! stepTwoPassed . value ) throw new Error ( "请先完成第 2 步" ) ; await checkIndustryDemandAnalysisStepThree ( { rows : pestAnalysisRows . map ( ( row ) => ( { dimension : row . key , ... analysisForm . value [ row . key ] } ) ) } ) ; return { step3Answer : JSON . stringify ( buildStepThreeOutcome ( ) ) , currentStep : 3 } ; } ,
4 : async ( ) => { if ( ! stepThreePassed . value ) throw new Error ( "请先完成第 3 步" ) ; await checkIndustryDemandAnalysisStepFour ( conclusionForm . value ) ; return { step4Answer : JSON . stringify ( buildStepFourOutcome ( ) ) , currentStep : 4 } ; } ,
} ;
const answer = await payloads [ currentStep . value ] ? . ( ) ;
if ( ! answer ) return false ;
await saveStudentTrainingAnswer ( TASK _KEY , { ... answer , submitted : false , saveAction : "SAVE" } ) ;
if ( currentStep . value === 1 ) stepOnePassed . value = true ;
if ( currentStep . value === 2 ) stepTwoPassed . value = true ;
if ( currentStep . value === 3 ) stepThreePassed . value = true ;
clearDraft ( ) ;
if ( showMessage ) proxy ? . $modal ? . msgSuccess ( "当前步骤已保存" ) ;
return true ;
} catch ( error ) { proxy ? . $modal ? . msgError ? . ( error ? . message || "请完整填写当前步骤内容" ) ; return false ; } finally { saving . value = false ; }
}
function previousStep ( ) { if ( currentStep . value > 1 ) currentStep . value -= 1 ; }
async function nextStep ( ) { if ( currentStep . value < trainingSteps . value . length && await saveCurrentProgress ( false ) ) currentStep . value += 1 ; }
async function resetTraining ( ) { form . value = createForm ( ) ; stepOneChoiceAnswers . value = { } ; supplementForm . value = createForm ( ) ; analysisForm . value = createAnalysisForm ( ) ; conclusionForm . value = createConclusionForm ( ) ; stepOnePassed . value = false ; stepTwoPassed . value = false ; stepThreePassed . value = false ; currentStep . value = 1 ; clearDraft ( ) ; try { saving . value = true ; await deleteStudentTrainingAnswer ( TASK _KEY ) ; } catch ( error ) { /* Local clearing still succeeds. */ } finally { saving . value = false ; } proxy ? . $modal ? . msgSuccess ( "已重新开始本次实训" ) ; }
function resetStepOne ( ) { form . value = createForm ( ) ; stepOneChoiceAnswers . value = { } ; stepOnePassed . value = false ; proxy ? . $modal ? . msgSuccess ( "已清空第 1 步填写内容" ) ; }
function resetStepTwo ( ) { supplementForm . value = createForm ( ) ; proxy ? . $modal ? . msgSuccess ( "已清空第 2 步填写内容" ) ; }
function resetStepThree ( ) { analysisForm . value = createAnalysisForm ( ) ; proxy ? . $modal ? . msgSuccess ( "已清空第 3 步填写内容" ) ; }
function resetStepFour ( ) { conclusionForm . value = createConclusionForm ( ) ; proxy ? . $modal ? . msgSuccess ( "已清空第 4 步填写内容" ) ; }
function exportOutcome ( ) { const blob = new Blob ( [ JSON . stringify ( buildDraft ( ) , null , 2 ) ] , { type : "application/json;charset=utf-8" } ) ; const url = URL . createObjectURL ( blob ) ; 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 ( "导出成功" ) ; }
< / script >
< style lang = "scss" scoped >
/* ===== 页面级令牌(墨黑极简主题) ===== */
. industry - pest - page {
-- student - accent : # 1 f2937 ;
-- student - accent - deep : # 111827 ;
-- student - accent - light : # 4 b5563 ;
-- student - accent - soft : # f3f4f6 ;
-- student - danger : # d9554c ;
-- student - success : # 4 a9d6e ;
-- student - layer - 1 : # 374151 ;
-- student - layer - 2 : # 43 a294 ;
-- student - layer - 3 : # 8 b6fc0 ;
-- student - panel - border : # e5e7eb ;
-- student - card - bg : # ffffff ;
-- student - panel - bg : # fafbfc ;
-- student - text : # 1 f2937 ;
-- student - text - secondary : # 6 b7280 ;
-- student - fs - display : 17 px ;
-- student - fs - h1 : 15 px ;
-- student - fs - h2 : 14 px ;
-- student - fs - h3 : 13 px ;
-- student - fs - body : 13 px ;
-- student - fs - body - sm : 12 px ;
-- student - fs - caption : 11 px ;
-- student - fs - button : 13 px ;
-- student - fs - button - lg : 13 px ;
-- student - fs - data : 14 px ;
/* 不设 grid-template-columns, 由全局 .student-training-shell 单列布局接管 */
min - width : 0 ;
color : var ( -- student - text ) ;
background : # ffffff ;
}
/* 对齐 foundation 的 .training-core: 只设 gap, 无边框/背景/阴影 */
. industry - core {
display : grid ;
gap : 10 px ;
align - content : start ;
min - width : 0 ;
border : 0 ;
background : transparent ;
box - shadow : none ;
}
/* 任务头:独立卡片(对齐 foundation 的 .task-header) */
. task - header {
display : flex ;
align - items : flex - start ;
justify - content : space - between ;
gap : 12 px ;
margin : 0 ;
padding : 16 px ;
border : 1 px solid # e7e9ed ;
border - radius : 16 px ;
background : # ffffff ;
}
. task - badge ,
. section - eyebrow {
color : var ( -- student - text - secondary ) ;
font - weight : 700 ;
font - size : 11 px ;
letter - spacing : 0.12 em ;
text - transform : uppercase ;
}
. task - badge {
display : inline - flex ;
align - items : center ;
gap : 7 px ;
padding : 4 px 12 px ;
border : 1 px solid var ( -- student - panel - border ) ;
border - radius : 999 px ;
color : var ( -- student - accent - deep ) ;
background : var ( -- student - accent - soft ) ;
font - size : 12 px ;
font - weight : 700 ;
text - transform : none ;
letter - spacing : 0 ;
}
. task - title {
margin : 0 ;
color : var ( -- student - text ) ;
font - size : var ( -- student - fs - display ) ;
font - weight : 700 ;
line - height : 1.35 ;
}
/* task-header 内部主列(对齐 foundation 的 .task-head-main) */
. task - head - main {
display : grid ;
justify - items : start ;
gap : 8 px ;
min - width : 0 ;
flex : 1 1 auto ;
}
. task - brief {
margin : 0 ;
color : var ( -- student - text - secondary ) ;
font - size : 12 px ;
line - height : 1.6 ;
}
. task - brief - text {
display : - webkit - box ;
overflow : hidden ;
- webkit - box - orient : vertical ;
- webkit - line - clamp : 1 ;
}
. task - brief - toggle {
padding : 0 ;
border : 0 ;
color : var ( -- student - accent - deep ) ;
background : transparent ;
font : inherit ;
font - weight : 700 ;
cursor : pointer ;
& : hover { text - decoration : underline ; }
}
. task - brief - more {
display : grid ;
gap : 6 px ;
margin - top : 8 px ;
padding - top : 8 px ;
border - top : 1 px solid var ( -- student - panel - border ) ;
b { color : var ( -- student - text ) ; font - size : 12 px ; }
ul { display : grid ; gap : 4 px ; margin : 0 ; padding - left : 18 px ; }
p { margin : 0 ; }
}
. industry - demand - toolbar {
flex : 0 0 auto ;
justify - content : flex - end ;
max - width : 100 % ;
}
/* step-card 层:透明容器,去掉旧的 border/bg( 对齐 foundation 的 .task-card) */
. industry - step - card {
padding : 0 ;
border : 0 ;
border - radius : 0 ;
background : transparent ;
display : grid ;
gap : 10 px ;
}
. step - title - row {
display : flex ;
justify - content : space - between ;
align - items : flex - start ;
gap : 12 px ;
}
. step - title - row h3 {
margin : 4 px 0 0 ;
color : var ( -- student - text ) ;
font - size : var ( -- student - fs - h1 ) ;
font - weight : 700 ;
line - height : 1.35 ;
}
. step - chip {
border : 1 px solid var ( -- student - panel - border ) ;
border - radius : 6 px ;
padding : 3 px 10 px ;
color : var ( -- student - text - secondary ) ;
font - size : 11 px ;
font - weight : 600 ;
white - space : nowrap ;
}
. step - desc ,
. research - guide {
color : var ( -- student - text - secondary ) ;
font - size : var ( -- student - fs - body ) ;
line - height : 1.6 ;
}
. step - desc { margin : 14 px 0 ; }
. research - guide {
margin : 14 px 0 12 px ;
padding : 12 px 14 px ;
border - left : 3 px solid var ( -- student - accent ) ;
background : var ( -- student - accent - soft ) ;
border - radius : 0 8 px 8 px 0 ;
}
. research - guide p { margin : 0 0 6 px ; }
. research - guide ol { margin : 0 0 6 px ; padding - left : 20 px ; }
. research - guide li + li { margin - top : 4 px ; }
. step - warning {
margin : 10 px 0 ;
color : # b45309 ;
font - size : var ( -- student - fs - body - sm ) ;
}
/* ===== 表格 ===== */
. pest - table - wrap ,
. analysis - table - wrap { overflow - x : auto ; }
. pest - table ,
. analysis - table {
width : 100 % ;
min - width : 720 px ;
border - collapse : collapse ;
}
. pest - table th ,
. pest - table td ,
. analysis - table th ,
. analysis - table td {
border : 1 px solid var ( -- student - panel - border ) ;
}
. pest - table th ,
. analysis - table th {
padding : 10 px 12 px ;
color : var ( -- student - text ) ;
background : # fafbfc ;
font - size : var ( -- student - fs - body - sm ) ;
font - weight : 800 ;
text - align : left ;
}
. pest - table th : first - child ,
. pest - table td : first - child ,
. analysis - table th : first - child ,
. analysis - table td : first - child {
width : 8 % ;
text - align : center ;
}
. pest - table th : nth - child ( 2 ) ,
. pest - table . type - cell {
width : 18 % ;
text - align : center ;
font - weight : 700 ;
}
. analysis - table th : nth - child ( 2 ) ,
. analysis - table . type - cell {
width : 14 % ;
text - align : center ;
font - weight : 700 ;
}
. pest - table textarea ,
. analysis - table textarea ,
. conclusion - section textarea {
box - sizing : border - box ;
display : block ;
width : 100 % ;
min - height : 120 px ;
resize : vertical ;
border : 1 px solid var ( -- student - panel - border ) ;
border - radius : 6 px ;
padding : 10 px 12 px ;
color : var ( -- student - text ) ;
background : # ffffff ;
font : inherit ;
font - size : var ( -- student - fs - body ) ;
line - height : 1.55 ;
}
. pest - table textarea ,
. analysis - table textarea {
border : 0 ;
border - radius : 0 ;
background : transparent ;
padding : 8 px 10 px ;
}
. analysis - table textarea { min - height : 140 px ; }
. pest - table textarea : focus ,
. analysis - table textarea : focus ,
. conclusion - section textarea : focus {
outline : 2 px solid rgba ( 31 , 41 , 55 , 0.2 ) ;
outline - offset : - 2 px ;
}
. pest - table textarea : disabled ,
. analysis - table textarea : disabled ,
. conclusion - section textarea : disabled {
cursor : not - allowed ;
color : var ( -- student - text - secondary ) ;
background : var ( -- student - accent - soft ) ;
}
/* ===== 结论表单 ===== */
. conclusion - form { display : grid ; gap : 14 px ; }
. conclusion - section {
padding : 14 px ;
border : 1 px solid var ( -- student - panel - border ) ;
border - radius : 10 px ;
background : # ffffff ;
}
. conclusion - section h4 {
margin : 0 ;
color : var ( -- student - text ) ;
font - size : var ( -- student - fs - h2 ) ;
font - weight : 700 ;
}
. conclusion - section p {
margin : 6 px 0 10 px ;
color : var ( -- student - text - secondary ) ;
font - size : var ( -- student - fs - body ) ;
line - height : 1.55 ;
}
. option - row {
display : flex ;
flex - wrap : wrap ;
gap : 8 px 14 px ;
margin - bottom : 12 px ;
}
. option - card {
display : inline - flex ;
align - items : center ;
gap : 6 px ;
min - width : 100 px ;
padding : 8 px 12 px ;
border : 1 px solid var ( -- student - panel - border ) ;
border - radius : 8 px ;
color : var ( -- student - text ) ;
cursor : pointer ;
transition : border - color 0.16 s ease , background 0.16 s ease , color 0.16 s ease ;
font - size : var ( -- student - fs - body ) ;
}
. option - card : hover {
border - color : var ( -- student - accent ) ;
background : var ( -- student - accent - soft ) ;
}
. option - card input { accent - color : var ( -- student - accent ) ; }
. risk - grid {
display : grid ;
grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr ) ) ;
gap : 12 px ;
}
. risk - grid label {
display : grid ;
gap : 6 px ;
color : var ( -- student - text ) ;
font - weight : 600 ;
font - size : var ( -- student - fs - body - sm ) ;
}
. risk - grid textarea {
min - height : 120 px ;
font - weight : 400 ;
font - size : var ( -- student - fs - body ) ;
}
/* ===== 底部步骤导航按钮( scoped 基础) ===== */
. step - navigation {
display : flex ;
justify - content : center ;
gap : 14 px ;
margin - top : 20 px ;
}
. step - navigation . btn - nav {
display : inline - flex ;
align - items : center ;
justify - content : center ;
gap : 6 px ;
min - height : 40 px ;
padding : 0 18 px ;
border : 1 px solid rgba ( 31 , 41 , 55 , 0.4 ) ;
border - radius : 10 px ;
color : var ( -- student - text ) ;
background : # ffffff ;
font - size : var ( -- student - fs - button ) ;
font - weight : 600 ;
cursor : pointer ;
text - shadow : none ;
transition : border - color 0.16 s ease , background 0.16 s ease , color 0.16 s ease , transform 0.16 s ease , box - shadow 0.16 s ease ;
}
. step - navigation . btn - nav : hover {
border - color : var ( -- student - accent ) ;
background : var ( -- student - accent - soft ) ;
color : var ( -- student - accent ) ;
transform : translateY ( - 1 px ) ;
box - shadow : 0 3 px 10 px rgba ( 31 , 41 , 55 , 0.12 ) ;
}
. step - navigation . btn - nav . btn - primary {
border - color : var ( -- student - accent ) ;
color : # ffffff ;
background : var ( -- student - accent ) ;
font - weight : 700 ;
box - shadow : 0 3 px 10 px rgba ( 31 , 41 , 55 , 0.28 ) ;
}
. step - navigation . btn - nav . btn - primary : hover {
background : var ( -- student - accent - deep ) ;
border - color : var ( -- student - accent - deep ) ;
box - shadow : 0 5 px 14 px rgba ( 31 , 41 , 55 , 0.32 ) ;
}
. step - navigation . btn - nav : disabled {
cursor : not - allowed ;
opacity : 0.5 ;
transform : none ;
box - shadow : none ;
}
. step - placeholder {
padding : 60 px 20 px ;
text - align : center ;
color : var ( -- student - text - secondary ) ;
}
. step - placeholder p {
color : var ( -- student - text ) ;
font - size : var ( -- student - fs - h2 ) ;
font - weight : 600 ;
}
/* ===== 响应式 ===== */
@ media ( max - width : 840 px ) {
. risk - grid { grid - template - columns : 1 fr ; }
}
@ media ( max - width : 720 px ) {
. task - header { flex - direction : column ; align - items : flex - start ; }
. industry - demand - toolbar { width : 100 % ; justify - content : flex - start ; }
. step - chip { display : none ; }
. step - navigation { flex - direction : column ; }
. step - navigation . btn - nav { width : 100 % ; }
}
< / style >
< style lang = "scss" >
/* ===== 步骤导航:按钮切换风 ===== */
# app . studentLayout section . app - main : has ( . industry - pest - page ) . step - indicator {
display : flex ! important ;
flex - wrap : nowrap ! important ;
align - items : stretch ! important ;
justify - content : center ! important ;
gap : 10 px ! important ;
width : 100 % ! important ;
margin : 4 px 0 16 px ! important ;
padding : 0 ! important ;
border : none ! important ;
border - radius : 0 ! important ;
background : transparent ! important ;
box - shadow : none ! important ;
. step - connector { display : none ! important ; }
. step - tab {
flex : 0 0 auto ! important ;
min - width : auto ! important ;
max - width : none ! important ;
display : flex ! important ;
flex - direction : row ! important ;
align - items : center ! important ;
justify - content : center ! important ;
gap : 8 px ! important ;
height : auto ! important ;
min - height : 42 px ! important ;
margin : 0 ! important ;
padding : 8 px 14 px ! important ;
border : 1 px solid # e5e7eb ! important ;
border - radius : 8 px ! important ;
background : # ffffff ! important ;
box - shadow : none ! important ;
color : # 6 b7280 ! important ;
font - size : 13 px ! important ;
font - weight : 600 ! important ;
line - height : 1.3 ! important ;
letter - spacing : 0 ! important ;
white - space : nowrap ! important ;
text - align : center ! important ;
cursor : pointer ;
transition : background - color 0.16 s ease , color 0.16 s ease , border - color 0.16 s ease ! important ;
/* 编号小徽章(原 .step-marker 是圆形大圈,改为方形小徽章) */
. step - marker {
display : inline - flex ! important ;
align - items : center ! important ;
justify - content : center ! important ;
flex : 0 0 auto ! important ;
min - width : 22 px ! important ;
width : auto ! important ;
height : 22 px ! important ;
padding : 0 5 px ! important ;
border : none ! important ;
border - radius : 6 px ! important ;
background : # f3f4f6 ! important ;
color : # 6 b7280 ! important ;
font - family : ui - monospace , "SF Mono" , Consolas , monospace ! important ;
font - size : 12 px ! important ;
font - weight : 700 ! important ;
line - height : 1 ! important ;
transition : background - color 0.16 s ease , color 0.16 s ease ! important ;
}
. step - copy {
display : flex ! important ;
flex - direction : column ! important ;
gap : 0 ! important ;
min - width : 0 ! important ;
}
. step - name {
overflow : hidden ! important ;
text - overflow : ellipsis ! important ;
white - space : normal ! important ;
line - height : 1.3 ! important ;
color : inherit ! important ;
}
. step - action { display : none ! important ; }
/* 未到达项 hover */
& : hover : not ( . active ) : not ( . complete ) {
border - color : # 1 f2937 ! important ;
background : # f9fafb ! important ;
color : # 1 f2937 ! important ;
. step - marker { background : # 1 f2937 ! important ; color : # ffffff ! important ; }
}
/* 已完成 */
& . complete : not ( . active ) {
border - color : # dce9e2 ! important ;
color : # 374151 ! important ;
. step - marker { background : rgba ( 74 , 157 , 110 , 0.12 ) ! important ; color : # 3 f8a5f ! important ; }
& : hover {
border - color : # 4 a9d6e ! important ;
background : # f9fafb ! important ;
. step - marker { background : # 4 a9d6e ! important ; color : # ffffff ! important ; }
}
}
/* 当前步骤 */
& . active {
border - color : # 1 f2937 ! important ;
background : # 1 f2937 ! important ;
color : # ffffff ! important ;
font - weight : 700 ! important ;
. step - marker { background : # ffffff ! important ; color : # 1 f2937 ! important ; }
& : hover {
background : # 111827 ! important ;
border - color : # 111827 ! important ;
}
}
}
}
/* ===== 窄屏 ===== */
@ media ( max - width : 900 px ) {
# app . studentLayout section . app - main : has ( . industry - pest - page ) . step - indicator {
justify - content : flex - start ! important ;
gap : 8 px ! important ;
overflow - x : auto ! important ;
}
# app . studentLayout section . app - main : has ( . industry - pest - page ) . step - indicator . step - tab {
flex : 0 0 auto ! important ;
max - width : none ! important ;
}
}
/* ===== 顶部任务工具栏换肤 ===== */
. industry - pest - page . training - task - toolbar { gap : 8 px ; }
. industry - pest - page . task - toolbar - button . el - button {
display : inline - flex ;
align - items : center ;
justify - content : center ;
gap : 6 px ;
min - height : 36 px ;
height : 36 px ;
padding : 0 14 px ;
border : 1 px solid # d1d5db ;
border - radius : 8 px ;
color : # 374151 ;
background : # ffffff ;
box - shadow : none ;
text - shadow : none ;
font - size : 13 px ;
font - weight : 600 ;
transition : border - color 0.16 s ease , background 0.16 s ease , color 0.16 s ease , box - shadow 0.16 s ease ;
}
. industry - pest - page . task - toolbar - button . el - button . el - icon {
font - size : 14 px ;
color : inherit ;
}
. industry - pest - page . task - toolbar - button . el - button : hover ,
. industry - pest - page . task - toolbar - button . el - button : focus {
border - color : # 1 f2937 ;
color : # 1 f2937 ;
background : # f9fafb ;
box - shadow : 0 2 px 6 px rgba ( 17 , 24 , 39 , 0.1 ) ;
}
. industry - pest - page . task - toolbar - button -- submit . el - button {
border - color : # 1 f2937 ;
color : # ffffff ;
background : # 1 f2937 ;
box - shadow : 0 2 px 8 px rgba ( 17 , 24 , 39 , 0.22 ) ;
}
. industry - pest - page . task - toolbar - button -- submit . el - button : hover ,
. industry - pest - page . task - toolbar - button -- submit . el - button : focus {
border - color : # 111827 ;
color : # ffffff ;
background : # 111827 ;
box - shadow : 0 4 px 12 px rgba ( 17 , 24 , 39 , 0.28 ) ;
}
. industry - pest - page . task - toolbar - button . el - button . is - loading {
border - color : # d1d5db ;
color : # 374151 ;
background : # ffffff ;
}
< / style >