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...

162 lines
48 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">
<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><small>维度内权重合计 <strong :class="{ valid: dimensionWeightTotal(group.name) === 100 }">{{ dimensionWeightTotal(group.name) }}%</strong></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 归一法和加权评分法计算品类综合得分;如案例资料提供三段式时间权重,则分别按第 120 天、第 2140 天、第 4160 天的权重计算最终得分。</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 * as XLSX from "xlsx";
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 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: "2535" }, { id: "transaction-amount", name: "成交金额万元", group: "市场规模", suggestion: "6575" },
{ id: "transaction-growth", name: "成交金额增速%", group: "市场潜力", suggestion: "5565" }, { id: "category-transaction-growth", name: "类目成交增速%", group: "市场潜力", suggestion: "3545" }, { id: "demand-supply-ratio", name: "需供比", group: "竞争强度", suggestion: "2535" }, { id: "online-products", name: "在线商品数", group: "竞争强度", suggestion: "3545" },
{ id: "online-merchants", name: "在线商家数", group: "竞争强度", suggestion: "2535" }, { id: "organic-traffic-ratio", name: "自然流占比%", group: "运营难度", suggestion: "5565" }, { id: "return-rate", name: "退货率%", group: "运营难度", suggestion: "3545" },
];
const groupSuggestions = { "市场规模": "2535", "市场潜力": "2535", "竞争强度": "2030", "运营难度": "1020" };
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) => 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); 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) => item.group === group).reduce((total, item) => total + (Number(weights.value[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 isStepTwoReady() { return categories.every((group) => isIntegerWeight(primaryWeights.value[group]) && dimensionWeightTotal(group) === 100) && indicators.every((item) => isIntegerWeight(weights.value[item.id])) && primaryWeightTotal.value === 100 && correspondingWeightTotal.value === 100; }
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; } }
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?.("请上传 PNGJPG 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 模板替代,保留代码仅供后续迁移参考。
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 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. 导出图表为 PNGJPG 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 { 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>