<template>
  <div class="opportunity-page student-training-shell">
    <main class="opportunity-core">
      <header class="task-header"><span>产品规划与设计 · MARKET SIGNALS</span><h1>{{ taskTitle }}</h1></header>
      <TrainingTaskBrief :background="taskBackground" :goals="taskGoals" :requirements="taskRequirement" :show-material="false" />
      <section class="workbench">
        <div class="workbench-actions"><TrainingMaterialButton :material-name="taskConfig?.materialName" :material-url="taskConfig?.materialUrl" /><button class="btn" type="button" @click="exportOutcome"><el-icon><Download /></el-icon> 导出实训成果</button></div>
        <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><select v-model="answers[item.id]" :class="{ 'input-error': stepOneCheckResults[item.id] && !stepOneCheckResults[item.id].correct }" :aria-label="`${item.name} 的分类`" @change="clearStepOneCheck(item.id)"><option value="">请选择</option><option v-for="category in categories" :key="category" :value="category">{{ category }}</option></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"><input v-model.number="primaryWeights[group.name]" :disabled="!stepOnePassed" type="number" min="1" max="100" step="1" :aria-label="`${group.name} 的一级指标权重`" /><span class="percent">%</span><small>建议 {{ group.suggestion }}</small></td><td class="metric">{{ item.name }}</td><td><input v-model.number="weights[item.id]" :disabled="!stepOnePassed" type="number" min="1" max="100" step="1" :aria-label="`${item.name} 的维度内权重`" /><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><input v-model.number="scores[product.id]" :disabled="!stepTwoPassed" type="number" min="0" max="100" step="0.01" :aria-label="`${product.name} 的综合得分`" /><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><input v-model.number="reviewInsights[product.id].favorableRate" :disabled="!stepFourPassed" type="number" min="0" max="100" step="0.01" :aria-label="`${product.name} 的好评率`" /><span class="percent">%</span></td><td><textarea v-model.trim="reviewInsights[product.id].painPoint" :disabled="!stepFourPassed" :aria-label="`${product.name} 的用户痛点`" placeholder="请根据评论数据提炼用户痛点" /></td><td><textarea v-model.trim="reviewInsights[product.id].unmetNeed" :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><textarea id="market-opportunity-conclusion" v-model.trim="developmentConclusion" :disabled="!stepFivePassed" placeholder="请结合综合得分、评论痛点与未被满足的需求，说明建议开发的产品及具体原因。" /></div>
            <div class="step-actions"><button class="btn primary" type="button" :disabled="saving || !stepFivePassed" @click="saveAndCompleteTraining">保存并完成实训</button><button class="btn" type="button" :disabled="saving || !stepFivePassed" @click="resetStepSix"><el-icon><RefreshLeft /></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 { Download, RefreshLeft, UploadFilled } from "@element-plus/icons-vue";
import { checkMarketOpportunitySelectionStepOne, checkMarketOpportunitySelectionStepTwo, checkMarketOpportunitySelectionStepThree, checkMarketOpportunitySelectionStepFour, checkMarketOpportunitySelectionStepFive, checkMarketOpportunitySelectionStepSix, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
import { getTrainingTaskByKey } from "@/api/trainingTask";
import { parseTrainingArray } from "@/views/training/taskKeyMap";
import { isStudentDemo } from "@/utils/studentDemo";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";

const TASK_KEY = "market-opportunity-selection";
const DEFAULT_STEPS = ["指标分类", "指标权重设置", "综合得分计算", "数据可视化", "用户需求洞察", "选品结论"];
const categories = ["市场规模", "市场潜力", "竞争强度", "运营难度"];
const indicators = [
  { id: "search-count", name: "搜索次数（万次）", group: "市场规模", suggestion: "25–35" }, { id: "demand-supply-ratio", 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: "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 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) => answers.value[item.id] === 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) => Boolean(answers.value[item.id])); } if (answer?.step2Answer) { const parsed = JSON.parse(answer.step2Answer); primaryWeights.value = { ...createPrimaryWeights(), ...(parsed?.primaryWeights || {}) }; weights.value = { ...createWeights(), ...(parsed?.dimensionWeights || parsed?.weights || {}) }; stepTwoPassed.value = stepOnePassed.value && categories.every((group) => isIntegerWeight(primaryWeights.value[group])) && indicators.every((item) => isIntegerWeight(weights.value[item.id])) && primaryWeightTotal.value === 100 && categories.every((group) => dimensionWeightTotal(group) === 100); } 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 indicators.filter((item) => answers.value[item.id] === group).reduce((total, item) => total + (Number(weights.value[item.id]) || 0), 0); }
function correspondingWeight(item) { return roundWeight(((Number(primaryWeights.value[answers.value[item.id]]) || 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 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; 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; } }
function resetStepOne() { answers.value = createAnswers(); stepOneCheckResults.value = {}; proxy?.$modal?.msgSuccess("已清空第 1 步填写内容"); }
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() { downloadFile("市场机会识别与选品 - 实训操作说明\n\n1. 汇总三类产品的原始数据。\n2. 对正向指标计算 (x-min)/(max-min)，对逆向指标计算 (max-x)/(max-min)。\n3. 将归一化得分乘以第 2 步的对应总权重后求和。\n4. 如案例资料提供三段式时间权重，分别计算第 1–20 天、第 21–40 天和第 41–60 天的加权得分。\n5. 将产品品类综合得分填写回第 3 步表格，保留两位小数。", "市场机会识别与选品-实训操作说明.txt", "text/plain;charset=utf-8"); }
function downloadTemplate() { const header = ["产品品类", ...indicators.map((item) => item.name), "综合得分"]; const rows = products.map((product) => [product.name, ...indicators.map(() => ""), ""]); const primaryWeightRow = ["一级指标权重(%)", ...indicators.map((item) => primaryWeights.value[item.group] ?? ""), ""]; const dimensionWeightRow = ["维度内权重(%)", ...indicators.map((item) => weights.value[item.id] ?? ""), ""]; const correspondingWeightRow = ["对应总权重(%)", ...indicators.map((item) => correspondingWeight(item)), ""]; downloadFile([header, primaryWeightRow, dimensionWeightRow, correspondingWeightRow, ...rows].map((row) => row.join(",")).join("\n"), "市场机会识别与选品-自动化模板.csv", "text/csv;charset=utf-8"); }
function downloadVisualizationInstructions() { downloadFile("市场机会识别与选品 - 数据可视化操作说明\n\n1. 按智能健身镜、智能卫浴镜和智能美妆镜汇总产品综合得分。\n2. 分别计算各品类的平均分、最高分和最低分。\n3. 使用柱状图、折线图或组合图完成可视化，并清晰标注品类和得分。\n4. 导出图表为 PNG、JPG 或 WEBP 格式后上传。", "市场机会识别与选品-数据可视化操作说明.txt", "text/plain;charset=utf-8"); }
function downloadVisualizationTemplate() { const header = ["产品品类", "平均得分", "最高分", "最低分"]; const rows = products.map((product) => [product.name, "", "", ""]); downloadFile([header, ...rows].map((row) => row.join(",")).join("\n"), "市场机会识别与选品-数据可视化模板.csv", "text/csv;charset=utf-8"); }
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 { display:grid; grid-template-columns:minmax(720px,1fr) 340px; gap:24px; min-height:calc(100vh - 106px); padding:24px; color:#e9f8ff; background:#06111d; }.opportunity-core { min-width:0; border:1px solid rgba(0,180,255,.25); background:rgba(8,18,28,.64); box-shadow:0 22px 48px rgba(0,0,0,.22); }.task-header { padding:24px 28px 4px; }.task-header span,.step-heading p { color:#66dcff; font-size:13px; font-weight:800; letter-spacing:.08em; }.task-header h1 { margin:9px 0 0; color:#fff; font-size:29px; }.workbench { margin:24px; padding:22px; border:1px solid rgba(33,194,255,.35); border-radius:20px; background:rgba(1,24,39,.72); }.workbench-actions { display:flex; justify-content:flex-end; gap:8px; }.btn { display:inline-flex; align-items:center; justify-content:center; gap:6px; border:1px solid #2cc9ff; border-radius:999px; padding:10px 18px; color:#dff8ff; background:rgba(8,87,121,.55); cursor:pointer; }.primary { border-color:#36d9ff; background:linear-gradient(110deg,#0eaae4,#0572ac); font-weight:700; }.btn:disabled { cursor:not-allowed; opacity:.6; }.step-indicator { display:flex; align-items:center; gap:6px; margin:18px 0; padding:8px; overflow-x:auto; overscroll-behavior-x:contain; scroll-snap-type:x proximity; scrollbar-width:thin; border:1px solid rgba(30,190,255,.28); border-radius:18px; }.step-tab { display:flex; align-items:center; flex:0 0 172px; gap:8px; min-width:0; padding:8px 10px; scroll-snap-align:start; border:0; border-radius:14px; color:#96c6d8; background:transparent; text-align:left; cursor:pointer; }.step-tab.active { color:#fff; background:linear-gradient(110deg,#148fc6,#0879aa); }.step-tab b { display:grid; place-items:center; flex:0 0 34px; width:34px; height:34px; border:1px solid #4ed7ff; border-radius:50%; font-size:13px; }.step-tab span { display:grid; gap:2px; min-width:0; }.step-tab strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:14px; }.step-tab small { color:#5ec9ed; font-size:12px; }.step-indicator i { flex:0 0 14px; height:2px; background:rgba(83,204,255,.4); }.step-card { padding:24px; border:1px solid rgba(31,185,249,.32); border-radius:18px; background:rgba(1,21,34,.65); }.step-heading { display:flex; justify-content:space-between; align-items:start; }.step-heading p { margin:0; }.step-heading h2 { margin:5px 0 0; color:#fff; font-size:24px; }.step-heading em { border:1px solid #38caff; border-radius:999px; padding:7px 12px; color:#9aeaff; font-style:normal; }.intro { margin:24px 0; color:#c5e4ef; font-size:16px; line-height:1.8; }.warning { margin:0 0 16px; color:#ffd58e; }.weight-summary { display:flex; align-items:baseline; gap:10px; margin:10px 0 18px; padding:13px 16px; border:1px solid rgba(57,207,255,.28); border-radius:11px; color:#c5e4ef; background:rgba(11,81,113,.18); }.weight-summary strong { color:#ffcf77; font-size:23px; }.weight-summary strong.valid { color:#67e8a9; }.weight-summary small { margin-left:auto; color:#8fc9dc; }.table-wrap,.score-table-wrap { overflow-x:auto; }.table-wrap table,.score-table { width:100%; min-width:720px; border-collapse:collapse; }.table-wrap th,.table-wrap td,.score-table th,.score-table td { border:1px solid rgba(139,213,241,.45); }.table-wrap th,.score-table th { padding:14px; color:#fff; background:#079ed9; font-size:16px; }.table-wrap th:first-child,.table-wrap td:first-child { width:22%; text-align:center; }.table-wrap th:nth-child(2),.metric { width:34%; text-align:center; font-weight:700; }.table-wrap td { padding:10px 18px; }.table-wrap select,.weight-table input,.score-table input { display:block; width:min(600px,100%); margin:auto; border:1px solid rgba(115,220,255,.58); border-radius:6px; padding:9px 12px; color:#e9faff; background:#09293c; font:inherit; }.weight-table .first-level { color:#67dcff; font-weight:800; }.weight-table td:last-child,.score-table td:last-child { position:relative; text-align:center; }.weight-table input,.score-table input { display:inline-block; width:150px; margin:0; text-align:center; }.weight-table .percent,.score-table .percent { margin-left:8px; color:#78d9f7; }.score-table th:first-child,.score-table td:first-child { width:16%; text-align:center; }.score-table th:nth-child(2) { width:45%; }.score-table td { padding:12px 18px; }.calculation-guide { display:grid; grid-template-columns:minmax(0,1fr) 210px; gap:22px; margin-top:26px; padding:19px 21px; border:1px solid rgba(64,206,255,.27); border-radius:14px; color:#c9e9f4; background:rgba(9,67,94,.18); line-height:1.75; }.calculation-guide h3 { margin:0 0 8px; color:#fff; font-size:16px; }.calculation-guide p { margin:6px 0; }.download-stack { display:grid; align-content:center; gap:12px; }.download-stack .btn { border-radius:8px; }.step-actions { display:flex; justify-content:center; gap:12px; margin-top:26px; }.placeholder { padding:94px 20px; color:#a8cfdd; text-align:center; }.placeholder p { color:#fff; font-size:20px; } @media (max-width:1280px) { .opportunity-page { grid-template-columns:1fr; } } @media (max-width:720px) { .opportunity-page { padding:12px; }.workbench { margin:12px; padding:14px; }.task-header { padding:18px; }.workbench-actions { justify-content:flex-start; flex-wrap:wrap; }.step-indicator { margin:14px 0; }.step-heading em { display:none; }.weight-summary { flex-wrap:wrap; }.weight-summary small { width:100%; margin-left:0; }.calculation-guide { grid-template-columns:1fr; } }
.visualization-upload { display:grid; justify-items:center; gap:12px; min-height:280px; padding:48px 24px; border:1px dashed rgba(77,214,255,.68); border-radius:16px; background:radial-gradient(circle at 50% 25%,rgba(16,143,193,.23),rgba(5,31,48,.3) 58%,rgba(1,21,34,.5)); text-align:center; }.upload-trigger { display:inline-flex; align-items:center; gap:8px; border:1px solid #41d8ff; border-radius:8px; padding:15px 23px; color:#fff; background:linear-gradient(110deg,#0faee6,#0874af); font:inherit; font-weight:800; cursor:pointer; }.upload-trigger:disabled { cursor:not-allowed; opacity:.6; }.visualization-upload > p { margin:0; color:#8dc9dd; }.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(620px,100%); gap:16px; padding:12px; border:1px solid rgba(91,218,255,.4); border-radius:12px; text-align:left; background:rgba(2,17,29,.55); }.visualization-preview .el-image { flex:0 0 164px; height:104px; border-radius:8px; cursor:zoom-in; overflow:hidden; }.visualization-preview div { display:grid; gap:8px; }.visualization-preview strong { color:#fff; }.visualization-preview span { color:#9bcbdc; font-size:13px; }.text-button { justify-self:start; border:0; padding:0; color:#ffb4a5; background:transparent; cursor:pointer; font:inherit; }.visual-guide { margin-top:24px; padding:18px 22px; border-left:3px solid #31d1ff; color:#c9e9f4; background:rgba(8,78,108,.18); line-height:1.75; }.visual-guide h3 { margin:0 0 7px; color:#fff; font-size:16px; }.visual-guide p { margin:5px 0; }
.table-wrap select.input-error { border-color:#ff8d87; }
.classification-error { display:block; margin-top:6px; color:#ffaaa4; font-size:12px; text-align:center; }
.weight-table th:first-child { width:15%; }.weight-table th:nth-child(2) { width:18%; }.weight-table .metric { width:25%; }.weight-table th:nth-child(4) { width:25%; }.weight-table td { vertical-align:middle; text-align:center; }.weight-table .primary-weight { min-width:154px; }.weight-table small { display:block; margin-top:7px; color:#8fc9dc; font-size:12px; }.weight-table .corresponding-weight { color:#67dcff; font-weight:800; }
.visual-guide { display:grid; grid-template-columns:minmax(0,1fr) 210px; gap:22px; }.visual-guide .download-stack { align-content:center; }.visual-guide .download-stack .btn { border-radius:8px; }
.insight-table th:first-child { width:18%; }.insight-table th:nth-child(2) { width:16%; }.insight-table th:nth-child(3),.insight-table th:nth-child(4) { width:33%; }.insight-table td { padding:12px; vertical-align:top; }.insight-table td:first-child { vertical-align:middle; }.insight-table input { display:inline-block; width:105px; margin:0; text-align:center; }.insight-table .percent { margin-left:7px; color:#78d9f7; }.insight-table textarea { display:block; box-sizing:border-box; width:100%; min-height:106px; resize:vertical; border:1px solid rgba(115,220,255,.58); border-radius:7px; padding:10px 12px; color:#e9faff; background:#09293c; font:inherit; line-height:1.65; }.insight-table textarea::placeholder { color:#77a8bc; }.insight-guide { margin-top:24px; padding:18px 22px; border-left:3px solid #31d1ff; color:#c9e9f4; background:rgba(8,78,108,.18); line-height:1.75; }.insight-guide h3 { margin:0 0 7px; color:#fff; font-size:16px; }.insight-guide p { margin:5px 0; }
.conclusion-field { display:grid; gap:10px; }.conclusion-field label { color:#d9f5ff; font-weight:800; }.conclusion-field textarea { box-sizing:border-box; width:100%; min-height:280px; resize:vertical; border:1px solid rgba(115,220,255,.58); border-radius:12px; padding:16px 18px; color:#e9faff; background:linear-gradient(135deg,rgba(9,41,60,.96),rgba(2,27,43,.84)); font:inherit; font-size:16px; line-height:1.8; }.conclusion-field textarea::placeholder { color:#77a8bc; }
@media (max-width:720px) { .visual-guide { grid-template-columns:1fr; } }
</style>
