|
|
<template>
|
|
|
<div class="opportunity-page student-training-shell">
|
|
|
<main class="opportunity-core">
|
|
|
<section class="task-header">
|
|
|
<div class="task-head-main">
|
|
|
<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>
|
|
|
</section>
|
|
|
<section class="task-action-strip">
|
|
|
<TrainingTaskToolbar :task-key="TASK_KEY" :on-submit="saveAndCompleteTraining" />
|
|
|
</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 /></el-icon> 清空填写</button></div>
|
|
|
</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 /></el-icon> 清空填写</button></div>
|
|
|
</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 /></el-icon> 清空填写</button></div>
|
|
|
</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 /></el-icon>{{ uploading ? "上传中..." : visualization.url ? "更换数据可视化结果图" : "上传数据可视化结果图" }}</button>
|
|
|
<p>支持 PNG、JPG、WEBP 格式,文件不超过 5MB。</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 /></el-icon> 清空填写</button></div>
|
|
|
</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" type="button" :disabled="saving || !stepFivePassed" @click="resetStepSix"><el-icon><RefreshLeft /></el-icon> 清空填写</button></div>
|
|
|
</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 TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.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 模板;以下旧生成逻辑仅保留作迁移参考。
|
|
|
/* 已由后端内置 Excel 模板替代,保留代码仅供后续迁移参考。
|
|
|
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: #2563eb;
|
|
|
--student-accent-deep: #1d4ed8;
|
|
|
--student-accent-light: #3b82f6;
|
|
|
--student-accent-soft: #eff6ff;
|
|
|
--student-danger: #ef4444;
|
|
|
--student-success: #2ba471;
|
|
|
--student-layer-1: #1d4ed8;
|
|
|
--student-layer-2: #3b82f6;
|
|
|
--student-layer-3: #93c5fd;
|
|
|
--student-panel-border: #e5e7eb;
|
|
|
--student-card-bg: #ffffff;
|
|
|
--student-panel-bg: #fafbfc;
|
|
|
--student-text: #1f2937;
|
|
|
--student-text-secondary: #6b7280;
|
|
|
--el-color-primary: #2563eb;
|
|
|
--el-color-primary-light-3: #60a5fa;
|
|
|
--el-color-primary-light-5: #93c5fd;
|
|
|
--el-color-primary-light-7: #bfdbfe;
|
|
|
--el-color-primary-light-8: #dbeafe;
|
|
|
--el-color-primary-light-9: #eff6ff;
|
|
|
--el-color-primary-dark-2: #1d4ed8;
|
|
|
--el-input-hover-border-color: #3b82f6;
|
|
|
--el-input-focus-border-color: #2563eb;
|
|
|
--el-input-focus-ring-color: rgba(59, 130, 246, 0.15);
|
|
|
--el-textarea-hover-border-color: #3b82f6;
|
|
|
--el-textarea-focus-border-color: #2563eb;
|
|
|
--student-fs-display: 17px;
|
|
|
--student-fs-h1: 15px;
|
|
|
--student-fs-h2: 14px;
|
|
|
--student-fs-h3: 13px;
|
|
|
--student-fs-body: 13px;
|
|
|
--student-fs-body-sm: 12px;
|
|
|
--student-fs-caption: 11px;
|
|
|
--student-fs-button: 13px;
|
|
|
--student-fs-button-lg: 13px;
|
|
|
--student-fs-data: 14px;
|
|
|
|
|
|
position: relative;
|
|
|
z-index: 2;
|
|
|
display: grid;
|
|
|
grid-template-columns: minmax(720px, 1fr) 340px;
|
|
|
gap: 24px;
|
|
|
width: 100%;
|
|
|
max-width: 100%;
|
|
|
min-height: calc(100vh - 106px);
|
|
|
margin: 0;
|
|
|
padding: 24px 24px 32px;
|
|
|
color: var(--student-text);
|
|
|
background: #ffffff;
|
|
|
}
|
|
|
|
|
|
/* 对齐 foundation 的 .training-core:只设 gap,无边框/背景/阴影 */
|
|
|
.opportunity-core {
|
|
|
display: grid;
|
|
|
gap: 10px;
|
|
|
align-content: start;
|
|
|
min-width: 0;
|
|
|
border: 0;
|
|
|
background: transparent;
|
|
|
box-shadow: none;
|
|
|
}
|
|
|
|
|
|
.task-action-strip {
|
|
|
margin: 0;
|
|
|
padding: 10px 16px;
|
|
|
}
|
|
|
.task-action-strip :deep(.training-task-toolbar) {
|
|
|
display: flex;
|
|
|
flex-wrap: wrap;
|
|
|
align-items: center;
|
|
|
justify-content: flex-end;
|
|
|
gap: 8px;
|
|
|
}
|
|
|
|
|
|
/* 任务头:独立卡片 + 左侧教育蓝竖条 */
|
|
|
.task-header {
|
|
|
position: relative;
|
|
|
display: flex;
|
|
|
align-items: flex-start;
|
|
|
justify-content: space-between;
|
|
|
gap: 16px;
|
|
|
margin: 0;
|
|
|
padding: 18px 20px 18px 24px;
|
|
|
border: 1px solid var(--student-panel-border);
|
|
|
border-radius: 12px;
|
|
|
background: var(--student-card-bg);
|
|
|
overflow: hidden;
|
|
|
|
|
|
&::before {
|
|
|
content: "";
|
|
|
position: absolute;
|
|
|
top: 0;
|
|
|
bottom: 0;
|
|
|
left: 0;
|
|
|
width: 4px;
|
|
|
background: var(--student-accent);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.task-header span,
|
|
|
.step-heading p {
|
|
|
color: var(--student-text-secondary);
|
|
|
font-size: 11px;
|
|
|
font-weight: 700;
|
|
|
letter-spacing: 0.12em;
|
|
|
text-transform: uppercase;
|
|
|
}
|
|
|
|
|
|
|
|
|
.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: 8px;
|
|
|
min-width: 0;
|
|
|
flex: 1 1 auto;
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.task-brief {
|
|
|
margin: 0;
|
|
|
color: var(--student-text-secondary);
|
|
|
font-size: 12px;
|
|
|
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: 6px;
|
|
|
margin-top: 8px;
|
|
|
padding-top: 8px;
|
|
|
border-top: 1px solid var(--student-panel-border);
|
|
|
|
|
|
b { color: var(--student-text); font-size: 12px; }
|
|
|
ul { display: grid; gap: 4px; margin: 0; padding-left: 18px; }
|
|
|
p { margin: 0; }
|
|
|
}
|
|
|
|
|
|
.opportunity-toolbar {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
gap: 10px;
|
|
|
flex: 0 0 auto;
|
|
|
justify-content: flex-end;
|
|
|
}
|
|
|
|
|
|
/* workbench 层:透明容器 */
|
|
|
.workbench {
|
|
|
margin: 0;
|
|
|
padding: 0;
|
|
|
border: 0;
|
|
|
border-radius: 0;
|
|
|
background: transparent;
|
|
|
display: grid;
|
|
|
gap: 10px;
|
|
|
}
|
|
|
|
|
|
/* step-card 层:透明容器 */
|
|
|
.step-card {
|
|
|
padding: 0;
|
|
|
border: 0;
|
|
|
border-radius: 0;
|
|
|
background: transparent;
|
|
|
display: grid;
|
|
|
gap: 10px;
|
|
|
}
|
|
|
|
|
|
.step-heading {
|
|
|
display: flex;
|
|
|
justify-content: space-between;
|
|
|
align-items: flex-start;
|
|
|
gap: 12px;
|
|
|
}
|
|
|
|
|
|
.step-heading p { margin: 0; }
|
|
|
|
|
|
.step-heading h2 {
|
|
|
margin: 4px 0 0;
|
|
|
color: var(--student-text);
|
|
|
font-size: var(--student-fs-h1);
|
|
|
font-weight: 700;
|
|
|
line-height: 1.35;
|
|
|
}
|
|
|
|
|
|
.step-heading em {
|
|
|
border: 1px solid var(--student-panel-border);
|
|
|
border-radius: 6px;
|
|
|
padding: 3px 10px;
|
|
|
color: var(--student-text-secondary);
|
|
|
font-style: normal;
|
|
|
font-size: 11px;
|
|
|
font-weight: 600;
|
|
|
white-space: nowrap;
|
|
|
}
|
|
|
|
|
|
.intro {
|
|
|
margin: 14px 0;
|
|
|
color: var(--student-text-secondary);
|
|
|
font-size: var(--student-fs-body);
|
|
|
line-height: 1.6;
|
|
|
}
|
|
|
|
|
|
.warning {
|
|
|
margin: 0 0 12px;
|
|
|
color: #b45309;
|
|
|
font-size: var(--student-fs-body-sm);
|
|
|
}
|
|
|
|
|
|
/* ===== 权重汇总 ===== */
|
|
|
.weight-summary {
|
|
|
display: flex;
|
|
|
align-items: baseline;
|
|
|
gap: 8px;
|
|
|
margin: 8px 0 16px;
|
|
|
padding: 10px 14px;
|
|
|
border: 1px solid var(--student-panel-border);
|
|
|
border-radius: 8px;
|
|
|
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: 720px; }
|
|
|
.score-table { min-width: 520px; }
|
|
|
|
|
|
.table-wrap th,
|
|
|
.table-wrap td,
|
|
|
.score-table th,
|
|
|
.score-table td {
|
|
|
border: 1px solid var(--student-panel-border);
|
|
|
}
|
|
|
|
|
|
.table-wrap th,
|
|
|
.score-table th {
|
|
|
padding: 10px 12px;
|
|
|
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: 8px 12px; }
|
|
|
|
|
|
.table-wrap select,
|
|
|
.weight-table input,
|
|
|
.score-table input {
|
|
|
display: block;
|
|
|
width: min(500px, 100%);
|
|
|
margin: auto;
|
|
|
border: 1px solid var(--student-panel-border);
|
|
|
border-radius: 6px;
|
|
|
padding: 6px 10px;
|
|
|
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: 2px solid rgba(59, 130, 246, 0.15);
|
|
|
outline-offset: -2px;
|
|
|
}
|
|
|
|
|
|
.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: 100px;
|
|
|
margin: 0;
|
|
|
text-align: center;
|
|
|
}
|
|
|
|
|
|
.weight-table .percent,
|
|
|
.score-table .percent {
|
|
|
margin-left: 6px;
|
|
|
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, 1fr) auto;
|
|
|
gap: 16px;
|
|
|
margin-top: 18px;
|
|
|
padding: 14px 16px;
|
|
|
border: 1px solid var(--student-panel-border);
|
|
|
border-radius: 10px;
|
|
|
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 6px;
|
|
|
color: var(--student-text);
|
|
|
font-size: var(--student-fs-h2);
|
|
|
font-weight: 700;
|
|
|
}
|
|
|
|
|
|
.calculation-guide p,
|
|
|
.visual-guide p,
|
|
|
.insight-guide p {
|
|
|
margin: 4px 0;
|
|
|
}
|
|
|
|
|
|
.download-stack {
|
|
|
display: grid;
|
|
|
align-content: center;
|
|
|
gap: 8px;
|
|
|
}
|
|
|
|
|
|
/* ===== 上传区 ===== */
|
|
|
.visualization-upload {
|
|
|
display: grid;
|
|
|
justify-items: center;
|
|
|
gap: 10px;
|
|
|
min-height: 220px;
|
|
|
padding: 32px 24px;
|
|
|
border: 1px dashed var(--student-panel-border);
|
|
|
border-radius: 10px;
|
|
|
background: #fafbfc;
|
|
|
text-align: center;
|
|
|
}
|
|
|
|
|
|
.upload-trigger {
|
|
|
display: inline-flex;
|
|
|
align-items: center;
|
|
|
gap: 8px;
|
|
|
border: 1px solid var(--student-accent);
|
|
|
border-radius: 8px;
|
|
|
padding: 10px 20px;
|
|
|
color: #ffffff;
|
|
|
background: var(--student-accent);
|
|
|
font: inherit;
|
|
|
font-size: var(--student-fs-button);
|
|
|
font-weight: 600;
|
|
|
cursor: pointer;
|
|
|
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.22);
|
|
|
transition: background 0.16s ease, border-color 0.16s 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: 1px;
|
|
|
height: 1px;
|
|
|
overflow: hidden;
|
|
|
clip: rect(0, 0, 0, 0);
|
|
|
white-space: nowrap;
|
|
|
}
|
|
|
|
|
|
.visualization-preview {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
width: min(580px, 100%);
|
|
|
gap: 14px;
|
|
|
padding: 10px;
|
|
|
border: 1px solid var(--student-panel-border);
|
|
|
border-radius: 8px;
|
|
|
text-align: left;
|
|
|
background: #ffffff;
|
|
|
}
|
|
|
|
|
|
.visualization-preview .el-image {
|
|
|
flex: 0 0 140px;
|
|
|
height: 90px;
|
|
|
border-radius: 6px;
|
|
|
cursor: zoom-in;
|
|
|
overflow: hidden;
|
|
|
}
|
|
|
|
|
|
.visualization-preview div {
|
|
|
display: grid;
|
|
|
gap: 6px;
|
|
|
}
|
|
|
|
|
|
.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: 6px;
|
|
|
}
|
|
|
|
|
|
.conclusion-field label {
|
|
|
color: var(--student-text);
|
|
|
font-weight: 600;
|
|
|
font-size: var(--student-fs-body-sm);
|
|
|
}
|
|
|
|
|
|
.conclusion-field textarea {
|
|
|
min-height: 140px;
|
|
|
border: 1px solid var(--student-panel-border);
|
|
|
border-radius: 6px;
|
|
|
padding: 10px 12px;
|
|
|
color: var(--student-text);
|
|
|
background: #ffffff;
|
|
|
font: inherit;
|
|
|
font-size: var(--student-fs-body);
|
|
|
line-height: 1.55;
|
|
|
}
|
|
|
|
|
|
.conclusion-field textarea:focus {
|
|
|
outline: 2px solid rgba(59, 130, 246, 0.15);
|
|
|
outline-offset: -2px;
|
|
|
}
|
|
|
|
|
|
/* ===== 底部按钮 ===== */
|
|
|
.step-actions {
|
|
|
display: flex;
|
|
|
justify-content: center;
|
|
|
gap: 10px;
|
|
|
margin-top: 18px;
|
|
|
}
|
|
|
|
|
|
.btn {
|
|
|
display: inline-flex;
|
|
|
align-items: center;
|
|
|
justify-content: center;
|
|
|
gap: 6px;
|
|
|
min-height: 36px;
|
|
|
height: 36px;
|
|
|
padding: 0 14px;
|
|
|
border: 1px solid rgba(37, 99, 235, 0.35);
|
|
|
border-radius: 8px;
|
|
|
color: var(--student-accent);
|
|
|
background: #ffffff;
|
|
|
font-size: var(--student-fs-button);
|
|
|
font-weight: 700;
|
|
|
cursor: pointer;
|
|
|
text-shadow: none;
|
|
|
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, transform 0.16s ease, box-shadow 0.16s ease;
|
|
|
}
|
|
|
|
|
|
.btn:hover {
|
|
|
border-color: var(--student-accent);
|
|
|
background: var(--student-accent-soft);
|
|
|
color: var(--student-accent);
|
|
|
transform: translateY(-1px);
|
|
|
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.12);
|
|
|
}
|
|
|
|
|
|
.btn.primary {
|
|
|
border-color: var(--student-accent);
|
|
|
color: #ffffff;
|
|
|
background: var(--student-accent);
|
|
|
font-weight: 700;
|
|
|
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.28);
|
|
|
}
|
|
|
|
|
|
.btn.primary:hover {
|
|
|
background: var(--student-accent-deep);
|
|
|
border-color: var(--student-accent-deep);
|
|
|
transform: translateY(-1px);
|
|
|
box-shadow: 0 5px 14px rgba(29, 78, 216, 0.32);
|
|
|
}
|
|
|
|
|
|
.btn:disabled {
|
|
|
cursor: not-allowed;
|
|
|
opacity: 0.5;
|
|
|
transform: none;
|
|
|
box-shadow: none;
|
|
|
}
|
|
|
|
|
|
.placeholder {
|
|
|
padding: 60px 20px;
|
|
|
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: 720px) {
|
|
|
.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: 1fr; }
|
|
|
}
|
|
|
</style>
|
|
|
|
|
|
<style lang="scss">
|
|
|
/* ============================================================
|
|
|
* 步骤导航:分段连接胶囊(教育蓝主题)
|
|
|
* ============================================================ */
|
|
|
#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: 0 !important;
|
|
|
width: 100% !important;
|
|
|
margin: 0 0 20px !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: 240px !important;
|
|
|
min-width: 0 !important;
|
|
|
display: flex !important;
|
|
|
flex-direction: row !important;
|
|
|
align-items: center !important;
|
|
|
justify-content: center !important;
|
|
|
gap: 8px !important;
|
|
|
height: auto !important;
|
|
|
min-height: 40px !important;
|
|
|
margin: 0 !important;
|
|
|
padding: 8px 14px !important;
|
|
|
border: 1px solid #e5e7eb !important;
|
|
|
border-right: none !important;
|
|
|
border-radius: 0 !important;
|
|
|
background: #ffffff !important;
|
|
|
box-shadow: none !important;
|
|
|
color: #6b7280 !important;
|
|
|
font-size: 13px !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: all 0.16s ease !important;
|
|
|
|
|
|
&:first-child {
|
|
|
border-radius: 8px 0 0 8px !important;
|
|
|
}
|
|
|
&:last-child {
|
|
|
border-radius: 0 8px 8px 0 !important;
|
|
|
border-right: 1px solid #e5e7eb !important;
|
|
|
}
|
|
|
|
|
|
> b {
|
|
|
display: inline-flex !important;
|
|
|
align-items: center !important;
|
|
|
justify-content: center !important;
|
|
|
flex: 0 0 auto !important;
|
|
|
min-width: 22px !important;
|
|
|
height: 22px !important;
|
|
|
padding: 0 5px !important;
|
|
|
border-radius: 6px !important;
|
|
|
background: #f3f4f6 !important;
|
|
|
color: #6b7280 !important;
|
|
|
font-family: var(--student-font-data), ui-monospace, "SF Mono", Consolas, monospace !important;
|
|
|
font-size: 12px !important;
|
|
|
font-weight: 700 !important;
|
|
|
line-height: 1 !important;
|
|
|
transition: all 0.16s 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: 13px !important;
|
|
|
}
|
|
|
|
|
|
> span > small { display: none !important; }
|
|
|
|
|
|
&:hover:not(.active):not(.complete) {
|
|
|
border-color: #2563eb !important;
|
|
|
background: #eff6ff !important;
|
|
|
color: #2563eb !important;
|
|
|
z-index: 1 !important;
|
|
|
> b { background: #2563eb !important; color: #ffffff !important; }
|
|
|
}
|
|
|
|
|
|
&.complete:not(.active) {
|
|
|
border-color: #e5e7eb !important;
|
|
|
color: #374151 !important;
|
|
|
> b { background: rgba(43, 164, 113, 0.12) !important; color: #16915f !important; }
|
|
|
&:hover {
|
|
|
border-color: #2ba471 !important;
|
|
|
background: #f0fdf6 !important;
|
|
|
z-index: 1 !important;
|
|
|
> b { background: #2ba471 !important; color: #ffffff !important; }
|
|
|
}
|
|
|
}
|
|
|
|
|
|
&.active {
|
|
|
border-color: #2563eb !important;
|
|
|
background: #2563eb !important;
|
|
|
color: #ffffff !important;
|
|
|
font-weight: 700 !important;
|
|
|
z-index: 2 !important;
|
|
|
> b { background: #ffffff !important; color: #2563eb !important; }
|
|
|
&:hover {
|
|
|
background: #1d4ed8 !important;
|
|
|
border-color: #1d4ed8 !important;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/* ===== 窄屏:横向滑动 ===== */
|
|
|
@media (max-width: 900px) {
|
|
|
#app .studentLayout section.app-main:has(.opportunity-page) .step-indicator {
|
|
|
justify-content: flex-start !important;
|
|
|
overflow-x: auto !important;
|
|
|
}
|
|
|
#app .studentLayout section.app-main:has(.opportunity-page) .step-indicator .step-tab {
|
|
|
flex: 0 0 140px !important;
|
|
|
max-width: none !important;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/* ============================================================
|
|
|
* 顶部工具栏:TrainingMaterialButton 资料按钮 → 教育蓝
|
|
|
* ============================================================ */
|
|
|
.opportunity-page .opportunity-toolbar .material-button.el-button {
|
|
|
height: 34px !important;
|
|
|
min-height: 34px !important;
|
|
|
border: 1px solid rgba(37, 99, 235, 0.35) !important;
|
|
|
border-radius: 8px !important;
|
|
|
color: #2563eb !important;
|
|
|
background: #ffffff !important;
|
|
|
font-size: 13px !important;
|
|
|
font-weight: 600 !important;
|
|
|
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease !important;
|
|
|
|
|
|
&:hover {
|
|
|
border-color: #2563eb !important;
|
|
|
color: #2563eb !important;
|
|
|
background: #eff6ff !important;
|
|
|
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12) !important;
|
|
|
}
|
|
|
|
|
|
&.is-plain {
|
|
|
border-color: #e5e7eb !important;
|
|
|
color: #9ca3af !important;
|
|
|
&:hover {
|
|
|
border-color: #e5e7eb !important;
|
|
|
color: #9ca3af !important;
|
|
|
background: #ffffff !important;
|
|
|
box-shadow: none !important;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/* ============================================================
|
|
|
* 顶部导出按钮(原生 <button.btn>)
|
|
|
* ============================================================ */
|
|
|
#app .studentLayout section.app-main:has(.opportunity-page) .opportunity-toolbar > button.btn {
|
|
|
display: inline-flex !important;
|
|
|
align-items: center !important;
|
|
|
justify-content: center !important;
|
|
|
gap: 6px !important;
|
|
|
height: 34px !important;
|
|
|
min-height: 34px !important;
|
|
|
padding: 0 14px !important;
|
|
|
border: 1px solid rgba(37, 99, 235, 0.35) !important;
|
|
|
border-radius: 8px !important;
|
|
|
color: #2563eb !important;
|
|
|
background: #ffffff !important;
|
|
|
font-size: 13px !important;
|
|
|
font-weight: 600 !important;
|
|
|
line-height: 1 !important;
|
|
|
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease !important;
|
|
|
}
|
|
|
|
|
|
#app .studentLayout section.app-main:has(.opportunity-page) .opportunity-toolbar > button.btn:hover {
|
|
|
border-color: #2563eb !important;
|
|
|
color: #2563eb !important;
|
|
|
background: #eff6ff !important;
|
|
|
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12) !important;
|
|
|
transform: none !important;
|
|
|
}
|
|
|
</style>
|