You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
dianshang-qianduan/src/views/product/market-opportunity-selectio...

1505 lines
78 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<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>
</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>
</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>
</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>
</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>
</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>
</template>
<div v-else class="placeholder">
<p>第 {{ currentStep }} 步内容待配置</p><span>已保存的步骤可从上方步骤栏返回查看。</span>
</div>
</section>
<div class="step-navigation">
<div class="step-nav-left">
<button v-if="currentStep > 1" type="button" class="btn-nav btn-ghost" :disabled="saving"
@click="prevStep">
<el-icon>
<ArrowLeft />
</el-icon>
上一步
</button>
<button type="button" class="btn-nav btn-save" :disabled="saving"
@click="saveCurrentProgress">
<el-icon>
<CircleCheck />
</el-icon>
保存当前进度
</button>
</div>
<div class="step-nav-right">
<span class="step-progress">Step {{ String(currentStep).padStart(2, "0") }} / {{
String(trainingSteps.length).padStart(2, "0") }}</span>
<button v-if="currentStep < trainingSteps.length" type="button" class="btn-nav btn-primary"
:disabled="saving" @click="nextStep">
下一步
<el-icon>
<ArrowRight />
</el-icon>
</button>
</div>
</div>
</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 { ArrowLeft, ArrowRight, CircleCheck, 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 } }
/* ===== 底部步骤导航 ===== */
function prevStep() { if (currentStep.value > 1) switchStep(currentStep.value - 1) }
async function saveCurrentProgress() {
if (saving.value) return
saving.value = true
try {
const step = currentStep.value
const payload = { currentStep: step, submitted: false, saveAction: "SAVE" }
if (step === 1) payload.step1Answer = JSON.stringify(buildStepOneOutcome())
if (step === 2) payload.step2Answer = JSON.stringify(buildStepTwoOutcome())
if (step === 3) payload.step3Answer = JSON.stringify(buildStepThreeOutcome())
if (step === 4) payload.step4Answer = JSON.stringify(buildStepFourOutcome())
if (step === 5) { dynamicStepAnswers.value = buildDynamicStepAnswers({ step5: buildStepFiveOutcome() }); payload.dynamicStepAnswers = JSON.stringify(dynamicStepAnswers.value) }
if (step === 6) { dynamicStepAnswers.value = buildDynamicStepAnswers({ step5: buildStepFiveOutcome(), step6: buildStepSixOutcome() }); payload.dynamicStepAnswers = JSON.stringify(dynamicStepAnswers.value) }
await saveStudentTrainingAnswer(TASK_KEY, payload)
proxy?.$modal?.msgSuccess("进度已保存")
} catch (error) {
proxy?.$modal?.msgError?.(error?.message || "保存失败,请重试")
} finally {
saving.value = false
}
}
function nextStep() {
const step = currentStep.value
if (step === 1) checkAndContinue()
else if (step === 2) saveAndGoToStepThree()
else if (step === 3) saveAndGoToStepFour()
else if (step === 4) saveAndGoToStepFive()
else if (step === 5) saveAndGoToStepSix()
else if (step === 6) saveAndCompleteTraining()
}
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-training-shell,页面不再重复声明 */
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: var(--student-fs-caption);
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: var(--student-fs-body-sm);
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: var(--student-fs-body-sm);
}
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: var(--student-fs-caption);
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;
}
.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;
}
/* ============================================================
* 底部导航:工具栏式
* ============================================================ */
.step-navigation {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 16px;
padding: 12px 16px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: #ffffff;
}
.step-nav-left,
.step-nav-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
}
.step-nav-right {
margin-left: auto;
}
.btn-nav {
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-family: inherit;
font-size: var(--student-fs-button);
font-weight: 600;
cursor: pointer;
transition: all 0.16s ease;
white-space: nowrap;
.el-icon {
font-size: 18px;
}
&:hover:not(:disabled) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12);
}
}
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid var(--student-accent);
border-radius: 8px;
color: #ffffff;
background: var(--student-accent);
font-family: inherit;
font-size: var(--student-fs-button);
font-weight: 600;
box-shadow: 0 1px 2px rgba(37, 99, 235, 0.2), 0 2px 8px rgba(37, 99, 235, 0.15);
cursor: pointer;
transition: all 0.16s ease;
white-space: nowrap;
.el-icon {
font-size: 18px;
}
&:hover:not(:disabled) {
border-color: var(--student-accent-deep);
background: var(--student-accent-deep);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(29, 78, 216, 0.3), 0 4px 12px rgba(29, 78, 216, 0.22);
}
&:active:not(:disabled) {
transform: translateY(0);
}
}
.btn-ghost {
border-color: rgba(37, 99, 235, 0.35);
color: var(--student-accent);
background: #ffffff;
&:hover:not(:disabled) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
}
}
.btn-save {
border-color: #e5e7eb;
color: #4b5563;
background: #f9fafb;
&:hover:not(:disabled) {
border-color: #d1d5db;
background: #f3f4f6;
transform: none;
box-shadow: none;
}
}
.btn-primary:disabled,
.btn-ghost:disabled,
.btn-save:disabled,
.btn-nav:disabled {
cursor: not-allowed;
opacity: 0.5;
transform: none;
box-shadow: none;
}
.step-progress {
color: var(--student-text-secondary);
font-family: var(--student-font-data);
font-size: var(--student-fs-caption);
font-weight: 700;
letter-spacing: 0.05em;
white-space: nowrap;
}
@media (max-width: 1100px) {
.step-nav-right {
margin-left: 0;
}
}
@media (max-width: 900px) {
.step-navigation {
flex-direction: column;
align-items: stretch;
}
.step-nav-left,
.step-nav-right {
justify-content: center;
}
.btn-nav,
.btn-primary {
width: 100%;
}
}
.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 {
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: var(--student-fs-body) !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: var(--student-fs-body-sm) !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: var(--student-fs-body) !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: var(--student-fs-body) !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: var(--student-fs-body) !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>