< template >
< div class = "opportunity-page student-training-shell" >
< main class = "opportunity-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 >
< div class = "opportunity-toolbar" >
< TrainingMaterialButton :material-name ="taskConfig?.materialName" :material-url ="taskConfig?.materialUrl" / >
< button class = "btn" type = "button" @click ="exportOutcome" > < el -icon > < Download / > < / e l - i c o n > 导 出 实 训 成 果 < / b u t t o n >
< / div >
< / section >
< section class = "workbench" >
< nav class = "step-indicator" aria -label = " 市 场 机 会 识 别 与 选 品 步 骤 " > < template v-for ="(step, index) in trainingSteps" :key ="step" > < button type = "button" : class = "['step-tab', { active: currentStep === index + 1, complete: currentStep > index + 1 }]" @ click = "switchStep(index + 1)" > < b > { { String ( index + 1 ) . padStart ( 2 , '0' ) } } < / b > < span > < strong > { { step } } < / strong > < small > { { currentStep === index + 1 ? '当前步骤' : '点击切换' } } → < / small > < / span > < / button > < i v -if = " index < trainingSteps.length - 1 " / > < / template > < / nav >
< section class = "step-card" >
< template v-if ="currentStep === 1" >
< div class = "step-heading" > < div > < p > Step 01 < / p > < h2 > { { trainingSteps [ 0 ] } } < / h2 > < / div > < em > 指标分类练习 < / em > < / div >
< p class = "intro" > 请对实训数据表中的各项指标 , 按照市场规模 、 市场潜力 、 竞争强度 、 运营难度 4 个类别进行分类 。 < / p >
< div class = "table-wrap" > < table > < thead > < tr > < th > 序号 < / th > < th > 指标 < / th > < th > 分类 < / th > < / tr > < / thead > < tbody > < tr v-for ="(item, index) in indicators" :key="item.id"><td>{{ index + 1 }}</td><td class="metric">{{ item.name }}</td><td><el-select v-model="answers[item.id]" :class="{ 'input-error': stepOneCheckResults[item.id] && !stepOneCheckResults[item.id].correct }" :aria-label="`${item.name} 的分类`" placeholder="请选择" style="width:100%" @change="clearStepOneCheck(item.id)"><el-option value="" label="请选择" /><el-option v-for="category in categories" :key="category" :value="category" :label="category" /></el-select><small v-if="stepOneCheckResults[item.id] && !stepOneCheckResults[item.id].correct" class="classification-error" > {{ stepOneCheckResults [ item.id ] .message }} < / small > < / td > < / tr > < / tbody > < / table > < / div >
< div class = "step-actions" > < button class = "btn primary" type = "button" :disabled ="saving" @click ="checkAndContinue" > 判 题 并 进 入 第 2 步 < / button > < button class = "btn" type = "button" :disabled ="saving" @click ="resetStepOne" > < el -icon > < RefreshLeft / > < / e l - i c o n > 清 空 填 写 < / b u t t o n > < / d i v >
< / template >
< template v -else -if = " currentStep = = = 2 " >
< div class = "step-heading" > < div > < p > Step 02 < / p > < h2 > { { trainingSteps [ 1 ] } } < / h2 > < / div > < em > 指标权重设定 < / em > < / div >
< p class = "intro" > 第 1 步分类结果已自动带入 。 请填写四类一级指标权重和各维度内二级指标权重 , 系统将自动计算对应总权重 。 < / p >
< p v-if ="!stepOnePassed" class="warning" > 请 先 完 成 并 保 存 第 1 步 的 指 标 分 类 , 再 填 写 本 步 骤 。 < / p >
< div class = "weight-summary" > < span > 一级指标权重合计 < / span > < strong : class = "{ valid: primaryWeightTotal === 100 }" > { { primaryWeightTotal } } % < / strong > < span > 对应总权重合计 < / span > < strong : class = "{ valid: correspondingWeightTotal === 100 }" > { { correspondingWeightTotal } } % < / strong > < small > 一级权重合计 、 每类维度内权重合计 、 对应总权重均须为 100 % < / small > < / div >
< div class = "table-wrap" > < table class = "weight-table" > < thead > < tr > < th > 一级指标 < / th > < th > 权重占比 ( % ) < / th > < th > 二级指标 < / th > < th > 维度内权重 ( % ) < / th > < th > 对应总权重 < / th > < / tr > < / thead > < tbody > < template v-for ="group in indicatorGroups" :key="group.name"><tr v-for="(item, index) in group.items" :key="item.id"><td v-if="index === 0" :rowspan="group.items.length" class="first-level">{{ group.name }}</td><td v-if="index === 0" :rowspan="group.items.length" class="primary-weight"><el-input-number v-model="primaryWeights[group.name]" :disabled="!stepOnePassed" :min="1" :max="100" :step="1" :aria-label="`${group.name} 的一级指标权重`" controls-position="right" style="width:100%" /><span class="percent">%</span><small>建议 {{ group.suggestion }}</small><small>维度内权重合计 <strong :class="{ valid: dimensionWeightTotal(group.name) === 100 }">{{ dimensionWeightTotal(group.name) }}%</strong></small></td><td class="metric">{{ item.name }}</td><td><el-input-number v-model="weights[item.id]" :disabled="!stepOnePassed" :min="1" :max="100" :step="1" :aria-label="`${item.name} 的维度内权重`" controls-position="right" style="width:100%" /><span class="percent">%</span><small>建议 {{ item.suggestion }}</small></td><td class="corresponding-weight" > {{ formatWeight ( correspondingWeight ( item ) ) }} % < / td > < / tr > < / template > < / tbody > < / table > < / div >
< div class = "step-actions" > < button class = "btn primary" type = "button" : disabled = "saving || !stepOnePassed" @click ="saveAndGoToStepThree" > 保 存 并 进 入 第 3 步 < / button > < button class = "btn" type = "button" : disabled = "saving || !stepOnePassed" @click ="resetStepTwo" > < el -icon > < RefreshLeft / > < / e l - i c o n > 清 空 填 写 < / b u t t o n > < / d i v >
< / template >
< template v -else -if = " currentStep = = = 3 " >
< div class = "step-heading" > < div > < p > Step 03 < / p > < h2 > { { trainingSteps [ 2 ] } } < / h2 > < / div > < em > 加权评分计算 < / em > < / div >
< p class = "intro" > 通过 Min - Max 归一法计算各二级指标得分 , 采用加权评分法计算产品品类综合得分 。 < / p >
< p v-if ="!stepTwoPassed" class="warning" > 请 先 完 成 并 保 存 第 2 步 的 指 标 权 重 设 定 , 再 填 写 本 步 骤 。 < / p >
< div class = "score-table-wrap" > < table class = "score-table" > < thead > < tr > < th > 序号 < / th > < th > 产品品类 < / th > < th > 综合得分 < / th > < / tr > < / thead > < tbody > < tr v-for ="(product, index) in products" :key="product.id"><td>{{ index + 1 }}</td><td class="metric">{{ product.name }}</td><td><el-input-number v-model="scores[product.id]" :disabled="!stepTwoPassed" :min="0" :max="100" :step="0.01" :aria-label="`${product.name} 的综合得分`" controls-position="right" style="width:100%" /><span class="percent" > 分 < / span > < / td > < / tr > < / tbody > < / table > < / div >
< div class = "calculation-guide" > < div > < h3 > 推荐工具及方法如下 , 自行选择 : < / h3 > < p > 1. Excel / WPS 表格 , 自行操作计算 , 可参考 《 实训操作说明 》 完成 。 < / p > < p > 2. 下载自动化模板 , 填写案例数据及第 2 步权重 , 完成指标计算 。 < / p > < p > 3. 导出实训数据表 , 上传到通用 AI 大模型 , 参考提示词完成计算 。 < / p > < p > 先按 Min - Max 归一法和加权评分法计算品类综合得分 ; 如案例资料提供三段式时间权重 , 则分别按第 1 – 20 天 、 第 21 – 40 天 、 第 41 – 60 天的权重计算最终得分 。 < / p > < / div > < div class = "download-stack" > < button class = "btn" type = "button" @click ="downloadInstructions" > 下 载 实 训 操 作 说 明 < / button > < button class = "btn primary" type = "button" @click ="downloadTemplate" > 下 载 自 动 化 模 板 < / button > < / div > < / div >
< div class = "step-actions" > < button class = "btn primary" type = "button" : disabled = "saving || !stepTwoPassed" @click ="saveAndGoToStepFour" > 保 存 并 进 入 第 4 步 < / button > < button class = "btn" type = "button" : disabled = "saving || !stepTwoPassed" @click ="resetStepThree" > < el -icon > < RefreshLeft / > < / e l - i c o n > 清 空 填 写 < / b u t t o n > < / d i v >
< / template >
< template v -else -if = " currentStep = = = 4 " >
< div class = "step-heading" > < div > < p > Step 04 < / p > < h2 > { { trainingSteps [ 3 ] } } < / h2 > < / div > < em > 可视化结果提交 < / em > < / div >
< p class = "intro" > 计算完各产品综合得分后 , 请按照智能健身镜 、 智能卫浴镜和智能美妆镜 , 计算各品类产品的平均得分 、 最高分 、 最低分 , 并进行可视化处理 。 < / p >
< p v-if ="!stepThreePassed" class="warning" > 请 先 完 成 并 保 存 第 3 步 的 产 品 综 合 得 分 , 再 上 传 可 视 化 结 果 图 。 < / p >
< div class = "visualization-upload" >
< input ref = "visualizationInput" class = "visually-hidden" type = "file" accept = "image/png,image/jpeg,image/webp" @change ="handleVisualizationUpload" / >
< button class = "upload-trigger" type = "button" : disabled = "saving || uploading || !stepThreePassed" @click ="openVisualizationPicker" > < el -icon > < UploadFilled / > < / e l - i c o n > { { u p l o a d i n g ? " 上 传 中 . . . " : v i s u a l i z a t i o n . u r l ? " 更 换 数 据 可 视 化 结 果 图 " : " 上 传 数 据 可 视 化 结 果 图 " } } < / b u t t o n >
< p > 支持 PNG 、 JPG 、 WEBP 格式 , 文件不超过 5 MB 。 < / p >
< div v-if ="visualization.url" class="visualization-preview"><el-image :src="visualization.url" :preview-src-list="[visualization.url]" fit="cover" preview-teleported /><div><strong>{{ visualization.name || "数据可视化结果图" }}</strong><span>点击缩略图可放大查看</span><button class="text-button" type="button" @click="clearVisualization" > 移 除 图 片 < / button > < / div > < / div >
< / div >
< div class = "visual-guide" > < div > < h3 > 推荐工具及方法如下 , 自行选择 : < / h3 > < p > 1. 使用 Excel / WPS 表格计算三类产品的平均得分 、 最高分和最低分 , 并生成图表 。 < / p > < p > 2. 下载自动化模板 , 填写关键数据 , 完成指标计算和可视化 。 < / p > < p > 3. 导出实训数据表 , 上传到通用 AI 大模型 , 参考提示词完成计算与图表生成 。 < / p > < p > 请根据数据表及各指标权重设定表 , 使用 Min - Max 归一法与加权评分法计算产品综合得分 , 并汇总平均分 、 最高分和最低分 。 < / p > < / div > < div class = "download-stack" > < button class = "btn" type = "button" @click ="downloadVisualizationInstructions" > 下 载 实 训 操 作 说 明 < / button > < button class = "btn primary" type = "button" @click ="downloadVisualizationTemplate" > 下 载 自 动 化 模 板 < / button > < / div > < / div >
< div class = "step-actions" > < button class = "btn primary" type = "button" : disabled = "saving || uploading || !stepThreePassed" @click ="saveAndGoToStepFive" > 保 存 并 进 入 第 5 步 < / button > < / div >
< / template >
< template v -else -if = " currentStep = = = 5 " >
< div class = "step-heading" > < div > < p > Step 05 < / p > < h2 > { { trainingSteps [ 4 ] } } < / h2 > < / div > < em > 评论数据洞察 < / em > < / div >
< p class = "intro" > 结合本实训提供的智能健身镜 、 智能卫浴镜和智能美妆镜三类产品评论数据 , 分析用户的痛点及未被满足的需求 。 < / p >
< p v-if ="!stepFourPassed" class="warning" > 请 先 完 成 并 保 存 第 4 步 的 数 据 可 视 化 结 果 , 再 填 写 本 步 骤 。 < / p >
< div class = "table-wrap" > < table class = "insight-table" > < thead > < tr > < th > 品类 < / th > < th > 好评率 ( % ) < / th > < th > 用户痛点 < / th > < th > 未被满足的需求 < / th > < / tr > < / thead > < tbody > < tr v-for ="product in products" :key="product.id"><td class="metric">{{ product.name }}</td><td><el-input-number v-model="reviewInsights[product.id].favorableRate" :disabled="!stepFourPassed" :min="0" :max="100" :step="0.01" :aria-label="`${product.name} 的好评率`" controls-position="right" style="width:100%" /><span class="percent" > % < / span > < / td > < td > < el -input v -model .trim = " reviewInsights [ product.id ] .painPoint " type = "textarea" : autosize = "{ minRows: 2 }" :disabled ="!stepFourPassed" : aria -label = " ` $ { product.name } 的 用 户 痛 点 ` " placeholder = "请根据评论数据提炼用户痛点" / > < / td > < td > < el -input v -model .trim = " reviewInsights [ product.id ] .unmetNeed " type = "textarea" : autosize = "{ minRows: 2 }" :disabled ="!stepFourPassed" : aria -label = " ` $ { product.name } 的 未 被 满 足 的 需 求 ` " placeholder = "请根据评论数据提炼未被满足的需求" / > < / td > < / tr > < / tbody > < / table > < / div >
< div class = "insight-guide" > < div > < h3 > 可参考以下方法 : < / h3 > < p > 1. 自行根据评价数据计算好评率 , 阅读评论后分析用户痛点及未被满足的需求 。 < / p > < p > 2. 导出评论数据表 , 上传到通用 AI 大模型 , 通过文本分析完成计算与提炼 。 < / p > < p > 提示词 : 请计算 Excel 表中产品评论数据的好评率 , 提炼用户痛点和未被满足的需求 。 < / p > < / div > < / div >
< div class = "step-actions" > < button class = "btn primary" type = "button" : disabled = "saving || !stepFourPassed" @click ="saveAndGoToStepSix" > 保 存 并 进 入 第 6 步 < / button > < button class = "btn" type = "button" : disabled = "saving || !stepFourPassed" @click ="resetStepFive" > < el -icon > < RefreshLeft / > < / e l - i c o n > 清 空 填 写 < / b u t t o n > < / d i v >
< / template >
< template v -else -if = " currentStep = = = 6 " >
< div class = "step-heading" > < div > < p > Step 06 < / p > < h2 > { { trainingSteps [ 5 ] } } < / h2 > < / div > < em > 选品结论 < / em > < / div >
< p class = "intro" > 结合前面的数据分析 , 请说明应该进行开发什么产品 , 具体原因是什么 。 < / p >
< p v-if ="!stepFivePassed" class="warning" > 请 先 完 成 并 保 存 第 5 步 的 用 户 需 求 洞 察 , 再 填 写 本 步 骤 。 < / p >
< div class = "conclusion-field" > < label for = "market-opportunity-conclusion" > 开发建议与原因 < / label > < el -input id = "market-opportunity-conclusion" v -model .trim = " developmentConclusion " type = "textarea" : autosize = "{ minRows: 4 }" :disabled ="!stepFivePassed" placeholder = "请结合综合得分、评论痛点与未被满足的需求,说明建议开发的产品及具体原因。" / > < / div >
< div class = "step-actions" > < button class = "btn primary" type = "button" : disabled = "saving || !stepFivePassed" @click ="saveAndCompleteTraining" > 保 存 并 完 成 实 训 < / button > < button class = "btn" type = "button" : disabled = "saving || !stepFivePassed" @click ="resetStepSix" > < el -icon > < RefreshLeft / > < / e l - i c o n > 清 空 填 写 < / b u t t o n > < / d i v >
< / template >
< div v -else class = "placeholder" > < p > 第 { { currentStep } } 步内容待配置 < / p > < span > 已保存的步骤可从上方步骤栏返回查看 。 < / span > < / div >
< / section >
< / section >
< / main >
< TrainingAiSidebar study -tip = " 先 辨 别 指 标 描 述 的 是 规 模 、 增 长 潜 力 、 供 给 竞 争 , 还 是 流 量 获 取 难 度 , 再 进 行 归 类 。 " reference -title = " 指 标 分 类 提 示 " reference -text = " · 市 场 规 模 : 反 映 需 求 量 与 交 易 体 量 < br / > · 市场潜力 : 反映增长速度与扩张空间 < br / > · 竞争强度 : 反映供给数量与竞争关系 < br / > · 运营难度 : 反映获取流量与转化的门槛 " :progress-items=" progressItems " / >
< / div >
< / template >
< script setup >
import * as XLSX from "xlsx" ;
import { Download , RefreshLeft , UploadFilled } from "@element-plus/icons-vue" ;
import { checkMarketOpportunitySelectionStepOne , checkMarketOpportunitySelectionStepTwo , checkMarketOpportunitySelectionStepThree , checkMarketOpportunitySelectionStepFour , checkMarketOpportunitySelectionStepFive , checkMarketOpportunitySelectionStepSix , deleteStudentTrainingAnswer , getStudentTrainingAnswer , saveStudentTrainingAnswer , uploadStudentTrainingFile } from "@/api/studentTrainingAnswer" ;
import { getTrainingTaskByKey } from "@/api/trainingTask" ;
import { parseTrainingArray } from "@/views/training/taskKeyMap" ;
import { isStudentDemo } from "@/utils/studentDemo" ;
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue" ;
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue" ;
const TASK _KEY = "market-opportunity-selection" ;
const SCORE _CALCULATION _GUIDE _URL = "/file/training-materials/market-opportunity-selection/6.综合得分计算操作说明.docx" ;
const SCORE _CALCULATION _TEMPLATE _URL = "/file/training-materials/market-opportunity-selection/6.数据表-抖音电商 - 计算模版.xlsx" ;
const DEFAULT _STEPS = [ "指标分类" , "指标权重设置" , "综合得分计算" , "数据可视化" , "用户需求洞察" , "选品结论" ] ;
const categories = [ "市场规模" , "市场潜力" , "竞争强度" , "运营难度" ] ;
const indicators = [
{ id : "search-count" , name : "搜索次数(万次)" , group : "市场规模" , suggestion : "25– 35" } , { id : "transaction-amount" , name : "成交金额(万元)" , group : "市场规模" , suggestion : "65– 75" } ,
{ id : "transaction-growth" , name : "成交金额增速(%) " , group : "市场潜力" , suggestion : "55– 65" } , { id : "category-transaction-growth" , name : "类目成交增速(%) " , group : "市场潜力" , suggestion : "35– 45" } , { id : "demand-supply-ratio" , name : "需供比" , group : "竞争强度" , suggestion : "25– 35" } , { id : "online-products" , name : "在线商品数" , group : "竞争强度" , suggestion : "35– 45" } ,
{ id : "online-merchants" , name : "在线商家数" , group : "竞争强度" , suggestion : "25– 35" } , { id : "organic-traffic-ratio" , name : "自然流占比(%) " , group : "运营难度" , suggestion : "55– 65" } , { id : "return-rate" , name : "退货率(%) " , group : "运营难度" , suggestion : "35– 45" } ,
] ;
const groupSuggestions = { "市场规模" : "25– 35" , "市场潜力" : "25– 35" , "竞争强度" : "20– 30" , "运营难度" : "10– 20" } ;
const products = [ { id : "smart-fitness-mirror" , name : "智能健身镜" } , { id : "smart-bathroom-mirror" , name : "智能卫浴镜" } , { id : "smart-beauty-mirror" , name : "智能美妆镜" } ] ;
const defaultTask = { title : "市场机会识别与选品" , background : "基于实训数据中的市场、供给与流量指标,完成市场机会识别和选品判断。" , goals : [ "理解选品数据指标的业务含义" , "掌握市场规模、潜力、竞争和运营难度的分类方法" , "为后续选品分析建立指标基础" ] , requirement : "阅读案例资料,自行完成 9 项指标的分类,并依据系统判题反馈修改。" } ;
const { proxy } = getCurrentInstance ( ) ;
const taskConfig = ref ( null ) ; const briefOpen = ref ( false ) ; const currentStep = ref ( 1 ) ; const saving = ref ( false ) ; const uploading = ref ( false ) ; const visualizationInput = ref ( null ) ; const visualization = ref ( { url : "" , name : "" } ) ; const stepOnePassed = ref ( false ) ; const stepTwoPassed = ref ( false ) ; const stepThreePassed = ref ( false ) ; const stepFourPassed = ref ( false ) ; const stepFivePassed = ref ( false ) ; const answers = ref ( createAnswers ( ) ) ; const primaryWeights = ref ( createPrimaryWeights ( ) ) ; const weights = ref ( createWeights ( ) ) ; const scores = ref ( createScores ( ) ) ; const reviewInsights = ref ( createReviewInsights ( ) ) ; const developmentConclusion = ref ( "" ) ; const dynamicStepAnswers = ref ( { } ) ; const stepOneCheckResults = ref ( { } ) ;
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 ( ( ) => { const configured = parseTrainingArray ( taskConfig . value ? . steps , DEFAULT _STEPS ) ; return DEFAULT _STEPS . map ( ( fallback , index ) => configured [ index ] || fallback ) ; } ) ;
const progressItems = computed ( ( ) => indicators . map ( ( item ) => ( { text : ` ${ answers . value [ item . id ] ? "已分类" : "待分类" } : ${ item . name } ` , status : answers . value [ item . id ] ? "done" : "" } ) ) ) ;
const indicatorGroups = computed ( ( ) => categories . map ( ( name ) => ( { name , suggestion : groupSuggestions [ name ] , items : indicators . filter ( ( item ) => item . group === name ) } ) ) ) ;
const primaryWeightTotal = computed ( ( ) => categories . reduce ( ( total , group ) => total + ( Number ( primaryWeights . value [ group ] ) || 0 ) , 0 ) ) ;
const correspondingWeightTotal = computed ( ( ) => roundWeight ( indicators . reduce ( ( total , item ) => total + correspondingWeight ( item ) , 0 ) ) ) ;
onMounted ( async ( ) => { await loadTaskConfig ( ) ; await loadSavedAnswer ( ) ; } ) ;
function createAnswers ( ) { return Object . fromEntries ( indicators . map ( ( item ) => [ item . id , "" ] ) ) ; }
function createPrimaryWeights ( ) { return Object . fromEntries ( categories . map ( ( group ) => [ group , null ] ) ) ; }
function createWeights ( ) { return Object . fromEntries ( indicators . map ( ( item ) => [ item . id , null ] ) ) ; }
function createScores ( ) { return Object . fromEntries ( products . map ( ( product ) => [ product . id , null ] ) ) ; }
function createReviewInsights ( ) { return Object . fromEntries ( products . map ( ( product ) => [ product . id , { favorableRate : null , painPoint : "" , unmetNeed : "" } ] ) ) ; }
function isValidRate ( value ) { return Number . isFinite ( Number ( value ) ) && Number ( value ) >= 0 && Number ( value ) <= 100 && Math . round ( Number ( value ) * 100 ) === Number ( value ) * 100 ; }
function hasCompleteReviewInsights ( ) { return products . every ( ( product ) => { const insight = reviewInsights . value [ product . id ] || { } ; return isValidRate ( insight . favorableRate ) && String ( insight . painPoint || "" ) . trim ( ) && String ( insight . unmetNeed || "" ) . trim ( ) ; } ) ; }
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 ) { const parsed = JSON . parse ( answer . step1Answer ) ; answers . value = { ... createAnswers ( ) , ... ( parsed ? . answers || parsed || { } ) } ; stepOnePassed . value = indicators . every ( ( item ) => answers . value [ item . id ] === item . group ) ; } if ( answer ? . step2Answer ) { const parsed = JSON . parse ( answer . step2Answer ) ; const savedPrimaryWeights = { ... createPrimaryWeights ( ) , ... ( parsed ? . primaryWeights || { } ) } ; const savedDimensionWeights = { ... createWeights ( ) , ... ( parsed ? . dimensionWeights || parsed ? . weights || { } ) } ; if ( isValidSavedStepTwoOutcome ( savedPrimaryWeights , savedDimensionWeights ) ) { primaryWeights . value = savedPrimaryWeights ; weights . value = savedDimensionWeights ; stepTwoPassed . value = stepOnePassed . value ; } else { primaryWeights . value = createPrimaryWeights ( ) ; weights . value = createWeights ( ) ; stepTwoPassed . value = false ; } } if ( answer ? . step3Answer ) { const parsed = JSON . parse ( answer . step3Answer ) ; scores . value = { ... createScores ( ) , ... ( parsed ? . scores || parsed || { } ) } ; stepThreePassed . value = stepTwoPassed . value ; } if ( answer ? . step4Answer ) { const parsed = JSON . parse ( answer . step4Answer ) ; visualization . value = { url : parsed ? . visualization ? . url || "" , name : parsed ? . visualization ? . name || "" } ; stepFourPassed . value = stepThreePassed . value && Boolean ( visualization . value . url ) ; } if ( answer ? . dynamicStepAnswers ) { const parsed = JSON . parse ( answer . dynamicStepAnswers ) ; dynamicStepAnswers . value = parsed && typeof parsed === "object" ? parsed : { } ; reviewInsights . value = { ... createReviewInsights ( ) , ... ( dynamicStepAnswers . value ? . step5 ? . reviewInsights || { } ) } ; developmentConclusion . value = String ( dynamicStepAnswers . value ? . step6 ? . developmentConclusion || "" ) ; stepFivePassed . value = stepFourPassed . value && hasCompleteReviewInsights ( ) ; } const savedStep = Number ( answer ? . currentStep ) || 1 ; currentStep . value = stepFivePassed . value ? Math . min ( savedStep , 6 ) : stepFourPassed . value ? Math . min ( savedStep , 5 ) : stepThreePassed . value ? Math . min ( savedStep , 4 ) : stepTwoPassed . value ? Math . min ( savedStep , 3 ) : stepOnePassed . value ? Math . min ( savedStep , 2 ) : 1 ; } catch ( error ) { /* Keep the page available when history cannot be restored. */ } }
function buildStepOneOutcome ( ) { return { title : taskTitle . value , answers : { ... answers . value } } ; }
function buildStepTwoOutcome ( ) { return { title : taskTitle . value , primaryWeights : { ... primaryWeights . value } , dimensionWeights : { ... weights . value } , correspondingWeights : Object . fromEntries ( indicators . map ( ( item ) => [ item . id , correspondingWeight ( item ) ] ) ) } ; }
function buildStepThreeOutcome ( ) { return { title : taskTitle . value , scores : { ... scores . value } } ; }
function buildStepFourOutcome ( ) { return { title : taskTitle . value , visualization : { ... visualization . value } } ; }
function buildStepFiveOutcome ( ) { return { title : taskTitle . value , reviewInsights : Object . fromEntries ( products . map ( ( product ) => [ product . id , { ... reviewInsights . value [ product . id ] } ] ) ) } ; }
function buildStepSixOutcome ( ) { return { title : taskTitle . value , developmentConclusion : developmentConclusion . value . trim ( ) } ; }
function buildDynamicStepAnswers ( overrides ) { return { ... dynamicStepAnswers . value , ... overrides } ; }
async function checkAndContinue ( ) { if ( saving . value ) return ; saving . value = true ; try { const result = await checkMarketOpportunitySelectionStepOne ( indicators . map ( ( item ) => ( { id : item . id , category : answers . value [ item . id ] } ) ) ) ; stepOneCheckResults . value = Object . fromEntries ( ( result ? . data ? . items || [ ] ) . map ( ( item ) => [ item . id , item ] ) ) ; if ( ! result ? . data ? . allCorrect ) throw new Error ( ` 当前答对 ${ result ? . data ? . correctCount || 0 } / ${ indicators . length } 项,请根据表内提示修改 ` ) ; await saveStudentTrainingAnswer ( TASK _KEY , { step1Answer : JSON . stringify ( buildStepOneOutcome ( ) ) , currentStep : 2 , submitted : false , saveAction : "SAVE" } ) ; stepOnePassed . value = true ; currentStep . value = 2 ; proxy ? . $modal ? . msgSuccess ( "分类正确,已进入第 2 步" ) ; } catch ( error ) { proxy ? . $modal ? . msgError ? . ( error ? . message || "请完成指标分类并根据提示修改" ) ; } finally { saving . value = false ; } }
function isIntegerWeight ( value ) { return value !== null && value !== "" && Number . isInteger ( Number ( value ) ) && Number ( value ) >= 1 && Number ( value ) <= 100 ; }
function dimensionWeightTotal ( group ) { return calculateDimensionWeightTotal ( group , weights . value ) ; }
function calculateDimensionWeightTotal ( group , dimension ) { return indicators . filter ( ( item ) => item . group === group ) . reduce ( ( total , item ) => total + ( Number ( dimension [ item . id ] ) || 0 ) , 0 ) ; }
function correspondingWeight ( item ) { return roundWeight ( ( ( Number ( primaryWeights . value [ item . group ] ) || 0 ) * ( Number ( weights . value [ item . id ] ) || 0 ) ) / 100 ) ; }
function roundWeight ( value ) { return Math . round ( ( value + Number . EPSILON ) * 100 ) / 100 ; }
function formatWeight ( value ) { return Number . isInteger ( value ) ? value : value . toFixed ( 2 ) ; }
function isValidSavedStepTwoOutcome ( primary , dimension ) { const primaryTotal = categories . reduce ( ( total , group ) => total + ( Number ( primary [ group ] ) || 0 ) , 0 ) ; const correspondingTotal = roundWeight ( indicators . reduce ( ( total , item ) => total + ( ( Number ( primary [ item . group ] ) || 0 ) * ( Number ( dimension [ item . id ] ) || 0 ) ) / 100 , 0 ) ) ; return categories . every ( ( group ) => isIntegerWeight ( primary [ group ] ) && calculateDimensionWeightTotal ( group , dimension ) === 100 ) && indicators . every ( ( item ) => isIntegerWeight ( dimension [ item . id ] ) ) && primaryTotal === 100 && correspondingTotal === 100 ; }
function isStepTwoReady ( ) { return isValidSavedStepTwoOutcome ( primaryWeights . value , weights . value ) ; }
function switchStep ( step ) { if ( step > 1 && ! stepOnePassed . value ) { proxy ? . $modal ? . msgWarning ( "请先完成第 1 步指标分类" ) ; return ; } if ( step > 2 && ! stepTwoPassed . value ) { proxy ? . $modal ? . msgWarning ( "请先完成第 2 步指标权重设定" ) ; return ; } if ( step > 3 && ! stepThreePassed . value ) { proxy ? . $modal ? . msgWarning ( "请先完成第 3 步综合得分计算" ) ; return ; } if ( step > 4 && ! stepFourPassed . value ) { proxy ? . $modal ? . msgWarning ( "请先完成第 4 步数据可视化" ) ; return ; } if ( step > 5 && ! stepFivePassed . value ) { proxy ? . $modal ? . msgWarning ( "请先完成第 5 步用户需求洞察" ) ; return ; } currentStep . value = step ; }
async function saveAndGoToStepThree ( ) { if ( saving . value || ! stepOnePassed . value ) return ; if ( ! isStepTwoReady ( ) ) { proxy ? . $modal ? . msgError ? . ( "请确认一级指标、维度内权重和对应总权重均为 100%" ) ; return ; } saving . value = true ; try { await checkMarketOpportunitySelectionStepTwo ( { primaryWeights : categories . map ( ( group ) => ( { group , weight : primaryWeights . value [ group ] } ) ) , dimensionWeights : indicators . map ( ( item ) => ( { id : item . id , weight : weights . value [ item . id ] } ) ) } ) ; await saveStudentTrainingAnswer ( TASK _KEY , { step2Answer : JSON . stringify ( buildStepTwoOutcome ( ) ) , currentStep : 3 , submitted : false , saveAction : "SAVE" } ) ; stepTwoPassed . value = true ; currentStep . value = 3 ; proxy ? . $modal ? . msgSuccess ( "权重校验通过,已进入第 3 步" ) ; } catch ( error ) { proxy ? . $modal ? . msgError ? . ( error ? . message || "请确认一级指标、维度内权重和对应总权重均满足规则" ) ; } finally { saving . value = false ; } }
async function saveAndGoToStepFour ( ) { if ( saving . value || ! stepTwoPassed . value ) return ; saving . value = true ; try { await checkMarketOpportunitySelectionStepThree ( { scores : products . map ( ( product ) => ( { id : product . id , score : scores . value [ product . id ] } ) ) } ) ; await saveStudentTrainingAnswer ( TASK _KEY , { step3Answer : JSON . stringify ( buildStepThreeOutcome ( ) ) , currentStep : 4 , submitted : false , saveAction : "SAVE" } ) ; stepThreePassed . value = true ; currentStep . value = 4 ; proxy ? . $modal ? . msgSuccess ( "综合得分校验通过,已进入第 4 步" ) ; } catch ( error ) { proxy ? . $modal ? . msgError ? . ( error ? . message || "请填写 0 至 100 分、最多保留两位小数的综合得分" ) ; } finally { saving . value = false ; } }
function clearStepOneCheck ( id ) { if ( stepOneCheckResults . value [ id ] ) { const next = { ... stepOneCheckResults . value } ; delete next [ id ] ; stepOneCheckResults . value = next ; } }
async function resetStepOne ( ) {
if ( saving . value ) return ;
saving . value = true ;
try {
await deleteStudentTrainingAnswer ( TASK _KEY ) ;
if ( visualization . value . url . startsWith ( "blob:" ) ) URL . revokeObjectURL ( visualization . value . url ) ;
answers . value = createAnswers ( ) ;
primaryWeights . value = createPrimaryWeights ( ) ;
weights . value = createWeights ( ) ;
scores . value = createScores ( ) ;
visualization . value = { url : "" , name : "" } ;
reviewInsights . value = createReviewInsights ( ) ;
developmentConclusion . value = "" ;
dynamicStepAnswers . value = { } ;
stepOneCheckResults . value = { } ;
stepOnePassed . value = false ;
stepTwoPassed . value = false ;
stepThreePassed . value = false ;
stepFourPassed . value = false ;
stepFivePassed . value = false ;
currentStep . value = 1 ;
proxy ? . $modal ? . msgSuccess ( "已清空本实训填写内容" ) ;
} catch ( error ) {
proxy ? . $modal ? . msgError ? . ( error ? . message || "清空失败,请重试" ) ;
} finally {
saving . value = false ;
}
}
function resetStepTwo ( ) { primaryWeights . value = createPrimaryWeights ( ) ; weights . value = createWeights ( ) ; proxy ? . $modal ? . msgSuccess ( "已清空第 2 步填写内容" ) ; }
function resetStepThree ( ) { scores . value = createScores ( ) ; proxy ? . $modal ? . msgSuccess ( "已清空第 3 步填写内容" ) ; }
function resetStepFive ( ) { reviewInsights . value = createReviewInsights ( ) ; stepFivePassed . value = false ; proxy ? . $modal ? . msgSuccess ( "已清空第 5 步填写内容" ) ; }
function resetStepSix ( ) { developmentConclusion . value = "" ; proxy ? . $modal ? . msgSuccess ( "已清空第 6 步填写内容" ) ; }
function openVisualizationPicker ( ) { visualizationInput . value ? . click ( ) ; }
function clearVisualization ( ) { if ( visualization . value . url . startsWith ( "blob:" ) ) URL . revokeObjectURL ( visualization . value . url ) ; visualization . value = { url : "" , name : "" } ; stepFourPassed . value = false ; stepFivePassed . value = false ; }
async function handleVisualizationUpload ( event ) { const file = event . target ? . files ? . [ 0 ] ; if ( ! file ) return ; const acceptedTypes = [ "image/jpeg" , "image/png" , "image/webp" ] ; if ( ! acceptedTypes . includes ( file . type ) ) { proxy ? . $modal ? . msgError ? . ( "请上传 PNG、JPG 或 WEBP 格式的图片" ) ; event . target . value = "" ; return ; } if ( file . size > 5 * 1024 * 1024 ) { proxy ? . $modal ? . msgError ? . ( "图片大小不能超过 5MB" ) ; event . target . value = "" ; return ; } uploading . value = true ; try { if ( isStudentDemo ( ) ) { clearVisualization ( ) ; visualization . value = { url : URL . createObjectURL ( file ) , name : file . name } ; } else { const formData = new FormData ( ) ; formData . append ( "file" , file ) ; const result = await uploadStudentTrainingFile ( formData ) ; const url = result ? . url || result ? . data ? . url ; if ( ! url ) throw new Error ( "图片上传失败,请重试" ) ; visualization . value = { url , name : file . name } ; } proxy ? . $modal ? . msgSuccess ( "可视化结果图上传成功" ) ; } catch ( error ) { proxy ? . $modal ? . msgError ? . ( error ? . message || "图片上传失败,请重试" ) ; } finally { uploading . value = false ; event . target . value = "" ; } }
async function saveAndGoToStepFive ( ) { if ( saving . value || ! stepThreePassed . value ) return ; saving . value = true ; try { await checkMarketOpportunitySelectionStepFour ( { visualizationUrl : visualization . value . url } ) ; await saveStudentTrainingAnswer ( TASK _KEY , { step4Answer : JSON . stringify ( buildStepFourOutcome ( ) ) , currentStep : 5 , submitted : false , saveAction : "SAVE" } ) ; stepFourPassed . value = true ; currentStep . value = 5 ; proxy ? . $modal ? . msgSuccess ( "可视化结果已保存,已进入第 5 步" ) ; } catch ( error ) { proxy ? . $modal ? . msgError ? . ( error ? . message || "请上传数据可视化结果图" ) ; } finally { saving . value = false ; } }
async function saveAndGoToStepSix ( ) { if ( saving . value || ! stepFourPassed . value ) return ; saving . value = true ; try { await checkMarketOpportunitySelectionStepFive ( { insights : products . map ( ( product ) => ( { productId : product . id , ... reviewInsights . value [ product . id ] } ) ) } ) ; dynamicStepAnswers . value = buildDynamicStepAnswers ( { step5 : buildStepFiveOutcome ( ) } ) ; await saveStudentTrainingAnswer ( TASK _KEY , { dynamicStepAnswers : JSON . stringify ( dynamicStepAnswers . value ) , currentStep : 6 , submitted : false , saveAction : "SAVE" } ) ; stepFivePassed . value = true ; currentStep . value = 6 ; proxy ? . $modal ? . msgSuccess ( "用户需求洞察已保存,已进入第 6 步" ) ; } catch ( error ) { proxy ? . $modal ? . msgError ? . ( error ? . message || "请完整填写三类产品的好评率、用户痛点和未被满足的需求" ) ; } finally { saving . value = false ; } }
async function saveAndCompleteTraining ( ) { if ( saving . value || ! stepFivePassed . value ) return ; saving . value = true ; try { await checkMarketOpportunitySelectionStepSix ( { developmentConclusion : developmentConclusion . value } ) ; dynamicStepAnswers . value = buildDynamicStepAnswers ( { step5 : buildStepFiveOutcome ( ) , step6 : buildStepSixOutcome ( ) } ) ; await saveStudentTrainingAnswer ( TASK _KEY , { dynamicStepAnswers : JSON . stringify ( dynamicStepAnswers . value ) , currentStep : 6 , submitted : true , saveAction : "SUBMIT" } ) ; proxy ? . $modal ? . msgSuccess ( "实训成果已保存,恭喜完成本次实训" ) ; } catch ( error ) { proxy ? . $modal ? . msgError ? . ( error ? . message || "请填写建议开发的产品及具体原因" ) ; } finally { saving . value = false ; } }
function downloadFile ( content , fileName , type ) { const blob = new Blob ( [ content ] , { type } ) ; const url = URL . createObjectURL ( blob ) ; const link = document . createElement ( "a" ) ; link . href = url ; link . download = fileName ; link . click ( ) ; URL . revokeObjectURL ( url ) ; }
function downloadInstructions ( ) { downloadBuiltInFile ( SCORE _CALCULATION _GUIDE _URL , "6.综合得分计算操作说明.docx" ) ; }
// 远端版本:下载入口已改为后端内置 Excel 模板;以下旧生成逻辑仅保留作迁移参考。
/ * 已 由 后 端 内 置 E x c e l 模 板 替 代 , 保 留 代 码 仅 供 后 续 迁 移 参 考 。
function downloadTemplate ( ) { const firstProductRow = 5 ; const lastProductRow = firstProductRow + products . length - 1 ; const rawStartColumn = 1 ; const normalizedStartColumn = rawStartColumn + indicators . length ; const totalColumn = normalizedStartColumn + indicators . length ; const negativeMetricIds = new Set ( [ "online-products" , "online-merchants" , "return-rate" ] ) ; const rows = [ [ "市场机会识别与选品 · Min-Max 加权评分自动化模板" ] , [ "填写原始指标值后,归一得分、对应总权重和综合得分会自动计算。" ] , [ ] , [ "产品品类" , ... indicators . map ( ( item ) => ` ${ item . name } 原始值 ` ) , ... indicators . map ( ( item ) => ` ${ item . name } 归一得分 ` ) , "综合得分" ] , ... products . map ( ( product ) => [ product . name , ... indicators . map ( ( ) => "" ) , ... indicators . map ( ( ) => "" ) , "" ] ) , [ ] , [ "权重设置(来自第 2 步)" ] , [ "一级指标权重(%)" , ... indicators . map ( ( item ) => primaryWeights . value [ item . group ] ? ? "" ) , ... indicators . map ( ( ) => "" ) , "" ] , [ "维度内权重(%)" , ... indicators . map ( ( item ) => weights . value [ item . id ] ? ? "" ) , ... indicators . map ( ( ) => "" ) , "" ] , [ "对应总权重(%)" , ... indicators . map ( ( ) => "" ) , ... indicators . map ( ( ) => "" ) , "" ] ] ; const worksheet = XLSX . utils . aoa _to _sheet ( rows ) ; indicators . forEach ( ( item , index ) => { const rawColumn = XLSX . utils . encode _col ( rawStartColumn + index ) ; const normalizedColumn = XLSX . utils . encode _col ( normalizedStartColumn + index ) ; const primaryWeightCell = XLSX . utils . encode _cell ( { c : rawStartColumn + index , r : 9 } ) ; const dimensionWeightCell = XLSX . utils . encode _cell ( { c : rawStartColumn + index , r : 10 } ) ; const correspondingWeightCell = XLSX . utils . encode _cell ( { c : rawStartColumn + index , r : 11 } ) ; worksheet [ correspondingWeightCell ] = { t : "n" , f : ` ${ primaryWeightCell } * ${ dimensionWeightCell } /100 ` } ; products . forEach ( ( product , productIndex ) => { const row = firstProductRow + productIndex ; const rawCell = ` ${ rawColumn } ${ row } ` ; const range = ` ${ rawColumn } $ ${ firstProductRow } : ${ rawColumn } $ ${ lastProductRow } ` ; const normalizedCell = ` ${ normalizedColumn } ${ row } ` ; worksheet [ normalizedCell ] = { t : "n" , f : negativeMetricIds . has ( item . id ) ? ` IFERROR((MAX( ${ range } )- ${ rawCell } )/(MAX( ${ range } )-MIN( ${ range } )),0) ` : ` IFERROR(( ${ rawCell } -MIN( ${ range } ))/(MAX( ${ range } )-MIN( ${ range } )),0) ` } ; } ) ; } ) ; products . forEach ( ( product , productIndex ) => { const row = firstProductRow + productIndex ; const scoreCell = ` ${ XLSX . utils . encode _col ( totalColumn ) } ${ row } ` ; const normalizedStart = XLSX . utils . encode _col ( normalizedStartColumn ) ; const normalizedEnd = XLSX . utils . encode _col ( normalizedStartColumn + indicators . length - 1 ) ; const correspondingStart = XLSX . utils . encode _col ( rawStartColumn ) ; const correspondingEnd = XLSX . utils . encode _col ( rawStartColumn + indicators . length - 1 ) ; worksheet [ scoreCell ] = { t : "n" , f : ` SUMPRODUCT( ${ normalizedStart } ${ row } : ${ normalizedEnd } ${ row } , $ ${ correspondingStart } $ 12: $ ${ correspondingEnd } $ 12)/100 ` } ; } ) ; worksheet [ "!cols" ] = [ { wch : 18 } , ... indicators . map ( ( ) => ( { wch : 18 } ) ) , ... indicators . map ( ( ) => ( { wch : 18 } ) ) , { wch : 14 } ] ; const workbook = XLSX . utils . book _new ( ) ; XLSX . utils . book _append _sheet ( workbook , worksheet , "综合得分计算" ) ; XLSX . writeFile ( workbook , "市场机会识别与选品-自动化模板.xlsx" ) ; }
* /
// 本地历史生成逻辑未绑定下载入口,不影响后端内置模板下载。
function downloadGeneratedTemplate ( ) { const firstProductRow = 5 ; const lastProductRow = firstProductRow + products . length - 1 ; const rawStartColumn = 1 ; const normalizedStartColumn = rawStartColumn + indicators . length ; const totalColumn = normalizedStartColumn + indicators . length ; const negativeMetricIds = new Set ( [ "online-products" , "online-merchants" , "return-rate" ] ) ; const rows = [ [ "市场机会识别与选品 · Min-Max 加权评分自动化模板" ] , [ "填写原始指标值后,归一得分、对应总权重和综合得分会自动计算。" ] , [ ] , [ "产品品类" , ... indicators . map ( ( item ) => ` ${ item . name } 原始值 ` ) , ... indicators . map ( ( item ) => ` ${ item . name } 归一得分 ` ) , "综合得分" ] , ... products . map ( ( product ) => [ product . name , ... indicators . map ( ( ) => "" ) , ... indicators . map ( ( ) => "" ) , "" ] ) , [ ] , [ "权重设置(来自第 2 步)" ] , [ "一级指标权重(%)" , ... indicators . map ( ( item ) => primaryWeights . value [ item . group ] ? ? "" ) , ... indicators . map ( ( ) => "" ) , "" ] , [ "维度内权重(%)" , ... indicators . map ( ( item ) => weights . value [ item . id ] ? ? "" ) , ... indicators . map ( ( ) => "" ) , "" ] , [ "对应总权重(%)" , ... indicators . map ( ( ) => "" ) , ... indicators . map ( ( ) => "" ) , "" ] ] ; const worksheet = XLSX . utils . aoa _to _sheet ( rows ) ; indicators . forEach ( ( item , index ) => { const rawColumn = XLSX . utils . encode _col ( rawStartColumn + index ) ; const normalizedColumn = XLSX . utils . encode _col ( normalizedStartColumn + index ) ; const primaryWeightCell = XLSX . utils . encode _cell ( { c : rawStartColumn + index , r : 9 } ) ; const dimensionWeightCell = XLSX . utils . encode _cell ( { c : rawStartColumn + index , r : 10 } ) ; const correspondingWeightCell = XLSX . utils . encode _cell ( { c : rawStartColumn + index , r : 11 } ) ; worksheet [ correspondingWeightCell ] = { t : "n" , f : ` ${ primaryWeightCell } * ${ dimensionWeightCell } /100 ` } ; products . forEach ( ( product , productIndex ) => { const row = firstProductRow + productIndex ; const rawCell = ` ${ rawColumn } ${ row } ` ; const range = ` ${ rawColumn } $ ${ firstProductRow } : ${ rawColumn } $ ${ lastProductRow } ` ; const normalizedCell = ` ${ normalizedColumn } ${ row } ` ; worksheet [ normalizedCell ] = { t : "n" , f : negativeMetricIds . has ( item . id ) ? ` IFERROR((MAX( ${ range } )- ${ rawCell } )/(MAX( ${ range } )-MIN( ${ range } )),0) ` : ` IFERROR(( ${ rawCell } -MIN( ${ range } ))/(MAX( ${ range } )-MIN( ${ range } )),0) ` } ; } ) ; } ) ; products . forEach ( ( product , productIndex ) => { const row = firstProductRow + productIndex ; const scoreCell = ` ${ XLSX . utils . encode _col ( totalColumn ) } ${ row } ` ; const normalizedStart = XLSX . utils . encode _col ( normalizedStartColumn ) ; const normalizedEnd = XLSX . utils . encode _col ( normalizedStartColumn + indicators . length - 1 ) ; const correspondingStart = XLSX . utils . encode _col ( rawStartColumn ) ; const correspondingEnd = XLSX . utils . encode _col ( rawStartColumn + indicators . length - 1 ) ; worksheet [ scoreCell ] = { t : "n" , f : ` SUMPRODUCT( ${ normalizedStart } ${ row } : ${ normalizedEnd } ${ row } , $ ${ correspondingStart } $ 12: $ ${ correspondingEnd } $ 12)/100 ` } ; } ) ; worksheet [ "!cols" ] = [ { wch : 18 } , ... indicators . map ( ( ) => ( { wch : 18 } ) ) , ... indicators . map ( ( ) => ( { wch : 18 } ) ) , { wch : 14 } ] ; const workbook = XLSX . utils . book _new ( ) ; XLSX . utils . book _append _sheet ( workbook , worksheet , "综合得分计算" ) ; XLSX . writeFile ( workbook , "市场机会识别与选品-自动化模板.xlsx" ) ; }
// rebase conflict resolved: retain the built-in download entry below.
function downloadTemplate ( ) { downloadBuiltInFile ( SCORE _CALCULATION _TEMPLATE _URL , "6.数据表-抖音电商 - 计算模版.xlsx" ) ; }
function downloadBuiltInFile ( url , fileName ) { const link = document . createElement ( "a" ) ; link . href = url ; link . download = fileName ; document . body . appendChild ( link ) ; link . click ( ) ; document . body . removeChild ( link ) ; }
function downloadVisualizationInstructions ( ) { downloadFile ( "市场机会识别与选品 - 数据可视化操作说明\n\n1. 按智能健身镜、智能卫浴镜和智能美妆镜分别填写每日综合得分。\n2. 使用自动化模板计算各品类的日平均得分、最高分和最低分。\n3. 使用柱状图、折线图或组合图完成可视化,并清晰标注品类和得分。\n4. 导出图表为 PNG、JPG 或 WEBP 格式后上传,并点击缩略图预览确认。" , "市场机会识别与选品-实训操作说明.txt" , "text/plain;charset=utf-8" ) ; }
function downloadVisualizationTemplate ( ) { const firstDataRow = 5 ; const dailyRows = products . flatMap ( ( product ) => Array . from ( { length : 60 } , ( _ , dayIndex ) => [ ` 第 ${ dayIndex + 1 } 天 ` , product . name , "" ] ) ) ; const lastDataRow = firstDataRow + dailyRows . length - 1 ; const dailyWorksheet = XLSX . utils . aoa _to _sheet ( [ [ "市场机会识别与选品 · 数据可视化自动化模板" ] , [ "请填入各产品每日综合得分,汇总结果会自动计算日平均得分、最高分和最低分。" ] , [ ] , [ "日期" , "产品品类" , "日综合得分" ] , ... dailyRows ] ) ; dailyWorksheet [ "!cols" ] = [ { wch : 14 } , { wch : 20 } , { wch : 16 } ] ; const summaryWorksheet = XLSX . utils . aoa _to _sheet ( [ [ "产品品类" , "日平均得分" , "最高分" , "最低分" ] , ... products . map ( ( product ) => [ product . name , "" , "" , "" ] ) ] ) ; products . forEach ( ( product , index ) => { const row = index + 2 ; const scoreRange = ` '日综合得分录入'! $ C $ ${ firstDataRow } : $ C $ ${ lastDataRow } ` ; const productRange = ` '日综合得分录入'! $ B $ ${ firstDataRow } : $ B $ ${ lastDataRow } ` ; summaryWorksheet [ ` B ${ row } ` ] = { t : "n" , f : ` AVERAGEIF( ${ productRange } ,A ${ row } , ${ scoreRange } ) ` } ; summaryWorksheet [ ` C ${ row } ` ] = { t : "n" , f : ` MAXIFS( ${ scoreRange } , ${ productRange } ,A ${ row } , ${ scoreRange } ,"<>") ` } ; summaryWorksheet [ ` D ${ row } ` ] = { t : "n" , f : ` MINIFS( ${ scoreRange } , ${ productRange } ,A ${ row } , ${ scoreRange } ,"<>") ` } ; } ) ; summaryWorksheet [ "!cols" ] = [ { wch : 20 } , { wch : 16 } , { wch : 14 } , { wch : 14 } ] ; const workbook = XLSX . utils . book _new ( ) ; XLSX . utils . book _append _sheet ( workbook , dailyWorksheet , "日综合得分录入" ) ; XLSX . utils . book _append _sheet ( workbook , summaryWorksheet , "汇总结果" ) ; XLSX . writeFile ( workbook , "市场机会识别与选品-数据可视化模板.xlsx" ) ; }
function exportOutcome ( ) { const blob = new Blob ( [ JSON . stringify ( { taskName : taskTitle . value , step1 : buildStepOneOutcome ( ) , step2 : buildStepTwoOutcome ( ) , step3 : buildStepThreeOutcome ( ) , step4 : buildStepFourOutcome ( ) , step5 : buildStepFiveOutcome ( ) , step6 : buildStepSixOutcome ( ) , currentStep : currentStep . value } , null , 2 ) ] , { type : "application/json;charset=utf-8" } ) ; const url = URL . createObjectURL ( blob ) ; const link = document . createElement ( "a" ) ; link . href = url ; link . download = ` ${ taskTitle . value } -实训结果.json ` ; link . click ( ) ; URL . revokeObjectURL ( url ) ; }
< / script >
< style lang = "scss" scoped >
/* ===== 页面级令牌(墨黑极简主题) ===== */
. opportunity - 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, 无边框/背景/阴影 */
. opportunity - 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 - header span ,
. step - heading p {
color : var ( -- student - text - secondary ) ;
font - size : 11 px ;
font - weight : 700 ;
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 - header h1 {
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 ; }
}
. opportunity - toolbar {
display : flex ;
align - items : center ;
gap : 10 px ;
flex : 0 0 auto ;
justify - content : flex - end ;
}
/* workbench 层:透明容器 */
. workbench {
margin : 0 ;
padding : 0 ;
border : 0 ;
border - radius : 0 ;
background : transparent ;
display : grid ;
gap : 10 px ;
}
/* step-card 层:透明容器 */
. step - card {
padding : 0 ;
border : 0 ;
border - radius : 0 ;
background : transparent ;
display : grid ;
gap : 10 px ;
}
. step - heading {
display : flex ;
justify - content : space - between ;
align - items : flex - start ;
gap : 12 px ;
}
. step - heading p { margin : 0 ; }
. step - heading h2 {
margin : 4 px 0 0 ;
color : var ( -- student - text ) ;
font - size : var ( -- student - fs - h1 ) ;
font - weight : 700 ;
line - height : 1.35 ;
}
. step - heading em {
border : 1 px solid var ( -- student - panel - border ) ;
border - radius : 6 px ;
padding : 3 px 10 px ;
color : var ( -- student - text - secondary ) ;
font - style : normal ;
font - size : 11 px ;
font - weight : 600 ;
white - space : nowrap ;
}
. intro {
margin : 14 px 0 ;
color : var ( -- student - text - secondary ) ;
font - size : var ( -- student - fs - body ) ;
line - height : 1.6 ;
}
. warning {
margin : 0 0 12 px ;
color : # b45309 ;
font - size : var ( -- student - fs - body - sm ) ;
}
/* ===== 权重汇总 ===== */
. weight - summary {
display : flex ;
align - items : baseline ;
gap : 8 px ;
margin : 8 px 0 16 px ;
padding : 10 px 14 px ;
border : 1 px solid var ( -- student - panel - border ) ;
border - radius : 8 px ;
color : var ( -- student - text - secondary ) ;
font - size : var ( -- student - fs - body - sm ) ;
background : var ( -- student - accent - soft ) ;
}
. weight - summary strong {
color : # b45309 ;
font - size : var ( -- student - fs - data ) ;
font - weight : 700 ;
}
. weight - summary strong . valid {
color : var ( -- student - success ) ;
}
. weight - summary small {
margin - left : auto ;
color : var ( -- student - text - secondary ) ;
font - size : var ( -- student - fs - caption ) ;
}
/* ===== 表格 ===== */
. table - wrap ,
. score - table - wrap { overflow - x : auto ; }
. table - wrap table ,
. score - table {
width : 100 % ;
border - collapse : collapse ;
}
. table - wrap table { min - width : 720 px ; }
. score - table { min - width : 520 px ; }
. table - wrap th ,
. table - wrap td ,
. score - table th ,
. score - table td {
border : 1 px solid var ( -- student - panel - border ) ;
}
. table - wrap th ,
. score - 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 ;
}
. table - wrap th : first - child ,
. table - wrap td : first - child {
text - align : center ;
font - weight : 700 ;
}
. table - wrap th : nth - child ( 2 ) ,
. metric {
text - align : center ;
font - weight : 700 ;
}
. table - wrap td { padding : 8 px 12 px ; }
. table - wrap select ,
. weight - table input ,
. score - table input {
display : block ;
width : min ( 500 px , 100 % ) ;
margin : auto ;
border : 1 px solid var ( -- student - panel - border ) ;
border - radius : 6 px ;
padding : 6 px 10 px ;
color : var ( -- student - text ) ;
background : # ffffff ;
font : inherit ;
font - size : var ( -- student - fs - body - sm ) ;
}
. table - wrap select : focus ,
. weight - table input : focus ,
. score - table input : focus {
outline : 2 px solid rgba ( 31 , 41 , 55 , 0.15 ) ;
outline - offset : - 2 px ;
}
. weight - table . first - level {
color : var ( -- student - text ) ;
font - weight : 700 ;
background : var ( -- student - accent - soft ) ;
}
. weight - table td : last - child ,
. score - table td : last - child {
text - align : center ;
}
. weight - table input ,
. score - table input {
display : inline - block ;
width : 100 px ;
margin : 0 ;
text - align : center ;
}
. weight - table . percent ,
. score - table . percent {
margin - left : 6 px ;
color : var ( -- student - text - secondary ) ;
font - size : var ( -- student - fs - body - sm ) ;
}
. score - table th : first - child ,
. score - table td : first - child {
width : 16 % ;
text - align : center ;
}
. score - table th : nth - child ( 2 ) { width : 45 % ; }
/* ===== 计算指南 ===== */
. calculation - guide ,
. visual - guide ,
. insight - guide {
display : grid ;
grid - template - columns : minmax ( 0 , 1 fr ) auto ;
gap : 16 px ;
margin - top : 18 px ;
padding : 14 px 16 px ;
border : 1 px solid var ( -- student - panel - border ) ;
border - radius : 10 px ;
color : var ( -- student - text - secondary ) ;
font - size : var ( -- student - fs - body ) ;
background : # ffffff ;
line - height : 1.55 ;
}
. calculation - guide h3 ,
. visual - guide h3 ,
. insight - guide h3 {
margin : 0 0 6 px ;
color : var ( -- student - text ) ;
font - size : var ( -- student - fs - h2 ) ;
font - weight : 700 ;
}
. calculation - guide p ,
. visual - guide p ,
. insight - guide p {
margin : 4 px 0 ;
}
. download - stack {
display : grid ;
align - content : center ;
gap : 8 px ;
}
/* ===== 上传区 ===== */
. visualization - upload {
display : grid ;
justify - items : center ;
gap : 10 px ;
min - height : 220 px ;
padding : 32 px 24 px ;
border : 1 px dashed var ( -- student - panel - border ) ;
border - radius : 10 px ;
background : # fafbfc ;
text - align : center ;
}
. upload - trigger {
display : inline - flex ;
align - items : center ;
gap : 8 px ;
border : 1 px solid var ( -- student - accent ) ;
border - radius : 8 px ;
padding : 10 px 20 px ;
color : # ffffff ;
background : var ( -- student - accent ) ;
font : inherit ;
font - size : var ( -- student - fs - button ) ;
font - weight : 600 ;
cursor : pointer ;
box - shadow : 0 2 px 8 px rgba ( 17 , 24 , 39 , 0.22 ) ;
transition : background 0.16 s ease , border - color 0.16 s ease ;
}
. upload - trigger : hover : not ( : disabled ) {
background : var ( -- student - accent - deep ) ;
border - color : var ( -- student - accent - deep ) ;
}
. upload - trigger : disabled {
cursor : not - allowed ;
opacity : 0.5 ;
}
. visualization - upload > p {
margin : 0 ;
color : var ( -- student - text - secondary ) ;
font - size : var ( -- student - fs - body - sm ) ;
}
. visually - hidden {
position : absolute ;
width : 1 px ;
height : 1 px ;
overflow : hidden ;
clip : rect ( 0 , 0 , 0 , 0 ) ;
white - space : nowrap ;
}
. visualization - preview {
display : flex ;
align - items : center ;
width : min ( 580 px , 100 % ) ;
gap : 14 px ;
padding : 10 px ;
border : 1 px solid var ( -- student - panel - border ) ;
border - radius : 8 px ;
text - align : left ;
background : # ffffff ;
}
. visualization - preview . el - image {
flex : 0 0 140 px ;
height : 90 px ;
border - radius : 6 px ;
cursor : zoom - in ;
overflow : hidden ;
}
. visualization - preview div {
display : grid ;
gap : 6 px ;
}
. visualization - preview strong {
color : var ( -- student - text ) ;
font - size : var ( -- student - fs - body - sm ) ;
}
. visualization - preview span {
color : var ( -- student - text - secondary ) ;
font - size : var ( -- student - fs - caption ) ;
}
. text - button {
justify - self : start ;
border : 0 ;
padding : 0 ;
color : var ( -- student - danger ) ;
background : transparent ;
cursor : pointer ;
font : inherit ;
font - size : var ( -- student - fs - body - sm ) ;
}
. text - button : hover { text - decoration : underline ; }
/* ===== 结论 ===== */
. conclusion - field {
display : grid ;
gap : 6 px ;
}
. conclusion - field label {
color : var ( -- student - text ) ;
font - weight : 600 ;
font - size : var ( -- student - fs - body - sm ) ;
}
. conclusion - field textarea {
min - height : 140 px ;
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 ;
}
. conclusion - field textarea : focus {
outline : 2 px solid rgba ( 31 , 41 , 55 , 0.15 ) ;
outline - offset : - 2 px ;
}
/* ===== 底部按钮 ===== */
. step - actions {
display : flex ;
justify - content : center ;
gap : 10 px ;
margin - top : 18 px ;
}
. btn {
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 : 700 ;
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 ;
}
. btn : 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 ) ;
}
. 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 ) ;
}
. btn . primary : hover {
background : var ( -- student - accent - deep ) ;
border - color : var ( -- student - accent - deep ) ;
transform : translateY ( - 1 px ) ;
box - shadow : 0 5 px 14 px rgba ( 17 , 24 , 39 , 0.32 ) ;
}
. btn : disabled {
cursor : not - allowed ;
opacity : 0.5 ;
transform : none ;
box - shadow : none ;
}
. placeholder {
padding : 60 px 20 px ;
text - align : center ;
color : var ( -- student - text - secondary ) ;
}
. placeholder p {
color : var ( -- student - text ) ;
font - size : var ( -- student - fs - h2 ) ;
font - weight : 600 ;
}
/* ===== 响应式 ===== */
@ media ( max - width : 720 px ) {
. task - header { flex - direction : column ; align - items : flex - start ; }
. workbench - actions { justify - content : flex - start ; flex - wrap : wrap ; }
. step - heading em { display : none ; }
. weight - summary { flex - wrap : wrap ; }
. weight - summary small { width : 100 % ; margin - left : 0 ; }
. calculation - guide ,
. visual - guide ,
. insight - guide { grid - template - columns : 1 fr ; }
}
< / style >
< style lang = "scss" >
/* ===== 画布 + 行高 ===== */
# app . studentLayout . app - main : has ( . opportunity - page ) {
background : # ffffff ! important ;
}
# app . studentLayout . app - main : has ( . opportunity - page ) . opportunity - page {
line - height : 1.55 ! important ;
}
/* ===== 步骤导航:按钮切换风 ===== */
# app . studentLayout section . app - main : has ( . opportunity - 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 ;
> i { display : none ! important ; }
. step - tab {
flex : 1 1 0 ! important ;
max - width : 220 px ! important ;
display : flex ! important ;
flex - direction : row ! important ;
align - items : center ! important ;
justify - content : center ! important ;
gap : 8 px ! important ;
min - width : 0 ! important ;
height : auto ! important ;
min - height : 42 px ! important ;
margin : 0 ! important ;
padding : 8 px 12 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 : normal ! 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 ;
/* 编号小徽章(原 <b> 是圆形大圈,改为方形小徽章) */
> b {
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 ;
}
> span {
display : flex ! important ;
flex - direction : column ! important ;
gap : 0 ! important ;
min - width : 0 ! important ;
}
> span > strong {
overflow : hidden ! important ;
text - overflow : ellipsis ! important ;
white - space : normal ! important ;
line - height : 1.3 ! important ;
color : inherit ! important ;
font - weight : 600 ! important ;
font - size : 13 px ! important ;
}
> span > small { display : none ! important ; }
/* 未到达项 hover */
& : hover : not ( . active ) : not ( . complete ) {
border - color : # 1 f2937 ! important ;
background : # f9fafb ! important ;
color : # 1 f2937 ! important ;
> b { background : # 1 f2937 ! important ; color : # ffffff ! important ; }
}
/* 已完成 */
& . complete : not ( . active ) {
border - color : # dce9e2 ! important ;
color : # 374151 ! important ;
> b { background : rgba ( 74 , 157 , 110 , 0.12 ) ! important ; color : # 3 f8a5f ! important ; }
& : hover {
border - color : # 4 a9d6e ! important ;
background : # f9fafb ! important ;
> b { background : # 4 a9d6e ! important ; color : # ffffff ! important ; }
}
}
/* 当前步骤 */
& . active {
border - color : # 1 f2937 ! important ;
background : # 1 f2937 ! important ;
color : # ffffff ! important ;
font - weight : 700 ! important ;
> b { 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 ( . opportunity - page ) . step - indicator {
justify - content : flex - start ! important ;
gap : 8 px ! important ;
overflow - x : auto ! important ;
}
# app . studentLayout section . app - main : has ( . opportunity - page ) . step - indicator . step - tab {
flex : 0 0 148 px ! important ;
max - width : none ! important ;
}
}
/* ===== 顶部任务工具栏换肤 ===== */
. opportunity - page . training - task - toolbar { gap : 8 px ; }
. opportunity - page . task - toolbar - button . el - button ,
. opportunity - page . material - 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 ;
}
. opportunity - page . task - toolbar - button . el - button . el - icon ,
. opportunity - page . material - button . el - button . el - icon {
font - size : 14 px ;
color : inherit ;
}
. opportunity - page . task - toolbar - button . el - button : hover ,
. opportunity - page . task - toolbar - button . el - button : focus ,
. opportunity - page . material - button . el - button : hover ,
. opportunity - page . material - 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 ) ;
}
. opportunity - 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 ) ;
}
. opportunity - page . task - toolbar - button -- submit . el - button : hover ,
. opportunity - 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 ) ;
}
< / style >