feat: 完善实训运营模块交互

fix/non-blocking-route-navigation
chenyuan 2 months ago
parent 195413f0ce
commit f23f064754

File diff suppressed because it is too large Load Diff

@ -1,73 +1,76 @@
<template>
<div class="diagnosis student-training-shell">
<main class="training-core">
<section class="task-header"><div class="task-badge"><el-icon><FirstAidKit /></el-icon> 核心实训 · 产品上线与运营推广</div><h1 class="task-title">{{ taskTitle }}</h1></section>
<div class="diagnosis-page student-training-shell">
<main class="diagnosis-core">
<section class="task-header"><span>核心实训 · 产品上线与运营推广</span><h1>{{ taskTitle }}</h1></section>
<TrainingTaskBrief :background="taskBackground" :goals="taskGoals" :requirements="taskRequirement" :show-material="false" />
<section class="task-card">
<div class="workbench-head workbench-head--actions-only"><div class="workbench-actions"><TrainingMaterialButton :material-name="taskConfig?.materialName" :material-url="taskConfig?.materialUrl" /><button type="button" class="outline-action" @click="exportOutcome"><el-icon><Download /></el-icon>导出实训成果</button></div></div>
<nav v-if="hasConfiguredSteps" class="step-indicator" aria-label="产品问题诊断步骤"><template v-for="(step,index) in configuredSteps" :key="`${step}-${index}`"><button type="button" :class="['step-tab',{active:activeStep===index+1,complete:activeStep>index+1}]" :aria-current="activeStep===index+1?'step':undefined" :aria-label="`第${index+1}步:${step}`" @click="setCurrentStep(index+1)"><span class="step-marker">{{ String(index+1).padStart(2,'0') }}</span><span class="step-copy"><strong :title="step">{{ step }}</strong><span class="step-action">{{ activeStep===index+1?'当前步骤':'点击切换 →' }}</span></span></button><span v-if="index<configuredSteps.length-1" class="step-connector" aria-hidden="true"></span></template></nav>
<section class="step-card">
<div class="step-title-row"><div><p v-if="hasConfiguredSteps" class="section-eyebrow">Step {{ String(activeStep).padStart(2, '0') }}</p><h3>{{ activeStepName }}</h3></div></div>
<nav class="analysis-tabs" role="tablist" aria-label="产品问题诊断工具"><button v-for="(item,index) in modules" :key="item.name" type="button" class="analysis-tab" :class="{ active: activeModule === index }" :aria-selected="activeModule === index" :disabled="item.disabled" role="tab" @click="setActiveModule(index)">{{ item.name }}</button></nav>
<div class="business-workspace"><component :is="activeComponent" :key="workspaceKey" ref="businessComponent" /></div>
</section>
<div v-if="hasConfiguredSteps" class="step-navigation"><button type="button" class="btn-nav" :disabled="activeStep === 1" @click="goPreviousStep"><el-icon><ArrowLeft /></el-icon>上一步</button><button type="button" class="btn-nav btn-save" :disabled="saving" @click="saveCurrentProgress"><el-icon><CircleCheck /></el-icon>保存当前进度</button><button type="button" class="btn-nav btn-primary" :disabled="activeStep === configuredSteps.length" @click="goNextStep">下一步<el-icon><ArrowRight /></el-icon></button></div>
<div class="training-actions"><el-button class="training-action" :loading="saving" @click="submitTask">提交任务</el-button><el-button class="training-action" :loading="saving" @click="resetTask">清空填写</el-button></div>
<section class="workbench">
<div class="workbench-top"><div><p>产品问题诊断与迭代</p><h2>{{ currentStep === 1 ? '全链路转化漏斗诊断' : currentStep === 2 ? '用户评论根因分析' : 'AB 测试验证方案' }}</h2></div><TrainingMaterialButton :material-name="taskConfig?.materialName" :material-url="taskConfig?.materialUrl" /></div>
<nav class="step-tabs" aria-label="产品问题诊断步骤"><button type="button" :class="{ active: currentStep === 1 }" @click="currentStep = 1"><b>01</b><span>全链路漏斗诊断<small>转化率与异常节点</small></span></button><i></i><button type="button" :class="{ active: currentStep === 2 }" @click="currentStep = 2"><b>02</b><span>用户评论根因分析<small>词云与情感倾向</small></span></button><i></i><button type="button" :class="{ active: currentStep === 3 }" @click="currentStep = 3"><b>03</b><span>AB 测试验证<small>改进方案有效性</small></span></button></nav>
<template v-if="currentStep === 1">
<p class="lead">从曝光到复购逐层计算渠道转化率,并与行业均值比较,定位产品运营环节中的异常流失。</p>
<section class="section-block"><div class="section-heading"><span>一</span><div><h3>计算各节点转化率</h3><p>根据案例数据填写各渠道链路节点转化率,数值以百分比(%)填写。</p></div></div><div class="table-wrap"><table class="funnel-table"><thead><tr><th>链路节点</th><th>天猫</th><th>京东</th><th>抖音</th><th>拼多多</th><th>行业均值</th></tr></thead><tbody><tr v-for="row in conversionRates" :key="row.linkNode"><th>{{ row.linkNode }}</th><td v-for="field in rateFields" :key="field"><el-input-number v-model="row[field]" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit">%</span></td></tr></tbody></table></div></section>
<section class="section-block visual-section"><div class="section-heading"><span>二</span><div><h3>全链路转化漏斗可视化</h3><p>根据上述转化率绘制各渠道与行业均值的节点转化漏斗对比图。</p></div></div><input ref="chartInput" class="visually-hidden" type="file" accept="image/png,image/jpeg,image/webp" @change="handleChartUpload" /><div class="chart-upload-area"><template v-if="funnelChart.url"><div class="chart-preview"><el-image :src="funnelChart.url" :preview-src-list="[funnelChart.url]" fit="cover" preview-teleported /><div><strong>{{ funnelChart.name || '节点转化漏斗对比图' }}</strong><span>点击缩略图可放大预览</span><button type="button" @click="clearChart">移除图片</button></div></div><button type="button" class="secondary-button" :disabled="uploading" @click="chartInput?.click()">{{ uploading ? '上传中…' : '更换对比图' }}</button></template><template v-else><button type="button" class="upload-button" :disabled="uploading" @click="chartInput?.click()">{{ uploading ? '上传中…' : '上传节点转化漏斗对比图' }}</button><p>支持 PNG、JPG、WEBP 格式,图片不超过 5MB;上传后可点击预览。</p></template></div></section>
<section class="section-block diagnosis-section"><div class="section-heading"><span>三</span><div><h3>定位异常节点</h3><p>对比行业均值数据,定位异常节点并填写分析与判定。</p></div></div><div class="table-toolbar"><span>默认 1 行,最多 5 行;渠道必须来源于上表。</span><div><button type="button" class="table-button" :disabled="anomalyRows.length >= 5" @click="addAnomalyRow">增加一行</button><button type="button" class="table-button ghost" :disabled="anomalyRows.length <= 1" @click="removeAnomalyRow">删除一行</button></div></div><div class="table-wrap"><table class="diagnosis-table"><thead><tr><th>渠道</th><th>链路节点</th><th>店铺转化率</th><th>行业均值转化率</th><th>异常分析与判定</th></tr></thead><tbody><tr v-for="(row, index) in anomalyRows" :key="index"><td><el-select v-model="row.channel" placeholder="请选择"><el-option v-for="channel in channels" :key="channel.key" :label="channel.label" :value="channel.label" /></el-select></td><td><el-select v-model="row.linkNode" placeholder="请选择"><el-option v-for="node in nodes" :key="node" :label="node" :value="node" /></el-select></td><td><el-input-number v-model="row.storeConversionRate" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit">%</span></td><td><el-input-number v-model="row.industryAverageConversionRate" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit">%</span></td><td><el-input v-model="row.anomalyAnalysis" type="textarea" :rows="2" maxlength="300" show-word-limit placeholder="请填写异常原因或正常判定" /></td></tr></tbody></table></div></section>
<div class="actions"><button type="button" class="secondary-button" :disabled="saving" @click="saveStepOneDraft">保存草稿</button><button type="button" class="submit-button" :disabled="saving || uploading" @click="validateStepOne">{{ saving ? '校验中…' : '校验并进入第 2 步' }}</button></div>
</template>
<template v-else-if="currentStep === 2">
<p class="lead">漏斗只能定位“哪个环节出了问题”,本步骤通过词云和情感倾向分析,从真实用户评价中提取高频问题,验证异常背后的根本原因。</p>
<section class="section-block"><div class="section-heading"><span>一</span><div><h3>词云分析</h3><p>导入评论数据,系统自动生成高频问题词云。</p></div></div><input ref="commentInput" class="visually-hidden" type="file" accept=".csv,.txt,.xls,.xlsx,text/csv,text/plain,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" @change="handleCommentImport" /><div class="comment-import"><button type="button" class="upload-button" :disabled="analyzing" @click="commentInput?.click()">{{ analyzing ? '正在生成词云…' : '导入评论数据生成词云' }}</button><p>支持 CSV、TXT、XLS、XLSX,文件不超过 5MB。</p><div v-if="commentPreview.fileName" class="data-preview"><div><strong>{{ commentPreview.fileName }}</strong><span>已解析 {{ commentPreview.totalComments }} 条评论 · 数据预览</span></div><ul><li v-for="(comment, index) in commentPreview.comments" :key="index">{{ comment }}</li></ul></div></div><div v-if="wordCloud.length" class="word-cloud-card"><div class="word-cloud-title"><span>高频问题词云</span><small>基于导入评论自动生成</small></div><div ref="wordCloudRef" class="word-cloud" aria-label="评论高频问题词云"></div></div><div class="top-issues"><p>请根据词云图填写词云中出现频次最高的 TOP3 问题。</p><div><label v-for="(_, index) in topIssues" :key="index">TOP{{ index + 1 }}<el-input v-model="topIssues[index]" :placeholder="`填写第 ${index + 1} 个高频问题`" /></label></div></div></section>
<section class="section-block"><div class="section-heading"><span>二</span><div><h3>情感倾向分析</h3><p>点击分析后,系统自动填充评论数量;请自行计算占比,保留两位小数。</p></div></div><div class="sentiment-actions"><button type="button" class="upload-button" :disabled="!analysisResult" @click="runSentimentAnalysis">情感倾向分析</button><span>评论数量由系统填充;系统仅校验学生填写的占比。</span></div><div class="table-wrap"><table class="sentiment-table"><thead><tr><th>情感类型</th><th>评论数量(条)</th><th>占比</th></tr></thead><tbody><tr v-for="row in sentiments" :key="row.type"><th>{{ row.type }}</th><td><span class="auto-count">{{ row.count ?? '—' }}</span></td><td><el-input-number v-model="row.percentage" :min="0" :max="100" :precision="2" :controls="false" :disabled="row.count === null" placeholder="请输入" /><span class="unit">%</span></td></tr></tbody></table></div></section>
<section class="section-block root-cause"><p>请结合词云 TOP3 与情感分析结果,说明本案例的核心问题根本原因;说明高频问题与情感数据是如何验证步骤一漏斗异常结论的。</p><el-input v-model="rootCauseAnalysis" type="textarea" :rows="6" maxlength="1000" show-word-limit placeholder="请填写根因分析与对步骤一异常结论的验证说明" /></section>
<div class="actions"><button type="button" class="secondary-button" :disabled="saving" @click="saveStepTwoDraft">保存草稿</button><button type="button" class="submit-button" :disabled="saving || analyzing" @click="validateStepTwo">{{ saving ? '校验中…' : '校验并保存第 2 步' }}</button></div>
</template>
<template v-else>
<p class="lead">找到根因后,还需要通过 AB 测试验证改进方案是否真正有效:将用户随机分为维持原方案的对照组和应用改进方案的实验组,用关键指标差异判断方案是否值得落地。</p>
<section class="section-block"><div class="section-heading"><span>一</span><div><h3>确定测试方案</h3><p>请基于前两步的漏斗异常与评论根因,设计 AB 测试方案。</p></div></div><el-input v-model="abTestPlan" type="textarea" :rows="6" maxlength="1000" show-word-limit placeholder="说明对照组与实验组设置、改进内容、随机分流方式、观察周期及成功判定标准" /></section>
<section class="section-block"><div class="section-heading"><span>二</span><div><h3>AB 测试数据填报</h3><p>填写案例资料中的对照组与实验组数据,并逐项给出判定与分析。</p></div></div><div class="table-wrap"><table class="ab-table"><thead><tr><th>测试指标</th><th>对照组数据</th><th>实验组数据</th><th>判定与分析</th></tr></thead><tbody><tr v-for="row in abRows" :key="row.metric"><th>{{ row.metric }}</th><td><el-input-number v-model="row.controlValue" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit">%</span></td><td><el-input-number v-model="row.experimentValue" :min="0" :max="100" :precision="2" :controls="false" placeholder="请输入" /><span class="unit">%</span></td><td><el-input v-model="row.analysis" type="textarea" :rows="2" maxlength="300" show-word-limit placeholder="如:提升 8pp,显著提升,建议采用" /></td></tr></tbody></table></div></section>
<section class="section-block"><div class="section-heading"><span>三</span><div><h3>可视化呈现</h3><p>绘制 AB 测试结果对比图并上传,作为改进方案的可视化证据。</p></div></div><input ref="abChartInput" class="visually-hidden" type="file" accept="image/png,image/jpeg,image/webp" @change="handleAbChartUpload" /><div class="chart-upload-area"><template v-if="abChart.url"><div class="chart-preview"><el-image :src="abChart.url" :preview-src-list="[abChart.url]" fit="cover" preview-teleported /><div><strong>{{ abChart.name || 'AB 测试结果对比图' }}</strong><span>点击缩略图可放大预览</span><button type="button" @click="clearAbChart">移除图片</button></div></div><button type="button" class="secondary-button" :disabled="uploading" @click="abChartInput?.click()">{{ uploading ? '上传中…' : '更换对比图' }}</button></template><template v-else><button type="button" class="upload-button" :disabled="uploading" @click="abChartInput?.click()">{{ uploading ? '上传中…' : '上传 AB 测试结果对比图' }}</button><p>支持 PNG、JPG、WEBP 格式,图片不超过 5MB;上传后可点击预览。</p></template></div></section>
<section class="section-block"><div class="section-heading"><span>四</span><div><h3>诊断结论与迭代优化建议</h3><p>结合全链路漏斗、评论根因与 AB 测试对照结果,明确产品问题及下一轮优化动作。</p></div></div><el-input v-model="iterationRecommendation" type="textarea" :rows="6" maxlength="1200" show-word-limit placeholder="说明验证结论、需要保留或放大的改进方案、待优化问题及后续迭代建议" /></section>
<div class="actions"><button type="button" class="secondary-button" :disabled="saving" @click="saveStepThreeDraft">保存草稿</button><button type="button" class="submit-button" :disabled="saving || uploading" @click="validateStepThree">{{ saving ? '校验中…' : '校验并保存第 3 步' }}</button></div>
</template>
</section>
</main>
<TrainingAiSidebar study-tip="当前任务建议:先选择合适的诊断工具,定位产品表现中的核心问题,再根据计算或分析结果形成迭代建议。" :progress-items="progressItems" />
<TrainingAiSidebar :study-tip="currentStep === 1 ? '先从案例资料提取各渠道漏斗数据,计算转化率并与行业均值逐层对比。' : currentStep === 2 ? '先导入案例评论数据生成词云,再点击情感倾向分析,并用高频问题与情感占比解释漏斗异常。' : '基于前两步结论设计随机分流的 AB 测试,用五项指标的组间差异验证改进方案。'" :progress-items="progressItems" />
</div>
</template>
<script setup>
import { computed, defineAsyncComponent, onMounted, ref } from "vue";
import { ArrowLeft, ArrowRight, CircleCheck, Download, FirstAidKit } from "@element-plus/icons-vue";
import { computed, getCurrentInstance, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
import * as echarts from "echarts";
import "echarts-wordcloud";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
import { getTrainingTaskByKey } from "@/api/trainingTask";
import { getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
import { analyzeProductDiagnosisIterationStepTwo, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile, validateProductDiagnosisIterationStepOne, validateProductDiagnosisIterationStepTwo, validateProductDiagnosisIterationStepThree } from "@/api/studentTrainingAnswer";
import { parseTrainingArray } from "@/views/training/taskKeyMap";
import { isModuleUnique } from "@/utils/index.js";
import { isStudentDemo } from "@/utils/studentDemo";
const TASK_KEY = "product-diagnosis-iteration";
const taskConfig = ref(null); const activeStep = ref(1); const activeModule = ref(0); const saving = ref(false); const workspaceKey = ref(0); const businessComponent = ref(null);
const modules = ref([
{ name: "五维四率法", component: defineAsyncComponent(() => import("./five.vue")), disabled: false },
{ name: "KANO 模型", component: defineAsyncComponent(() => import("./kano.vue")), disabled: false },
{ name: "市场机会矩阵", component: defineAsyncComponent(() => import("./market.vue")), disabled: false },
{ name: "流量数据分析法", component: defineAsyncComponent(() => import("./traffic.vue")), disabled: false },
{ name: "用户评论分析", component: defineAsyncComponent(() => import("../digitalMarketingAlgorithms/components/emotion-analysis.vue")), disabled: false },
]);
const taskTitle = computed(() => taskConfig.value?.taskName || "产品问题诊断与迭代");
const taskBackground = computed(() => taskConfig.value?.background || "通过多种分析方法定位产品、流量、需求和用户反馈中的关键问题,形成可落地的优化迭代方案。");
const taskRequirement = computed(() => taskConfig.value?.requirements || "选择适用的诊断工具完成数据分析、问题定位和改进建议输出。");
const taskGoals = computed(() => parseTrainingArray(taskConfig.value?.objectives, ["掌握产品问题诊断的核心方法", "能够基于数据定位影响表现的关键因素", "形成可执行的产品迭代建议"]));
const configuredSteps = computed(() => parseTrainingArray(taskConfig.value?.steps)); const hasConfiguredSteps = computed(() => configuredSteps.value.length > 0); const activeStepName = computed(() => configuredSteps.value[activeStep.value - 1] || "产品问题诊断"); const activeComponent = computed(() => modules.value[activeModule.value]?.component); const progressItems = computed(() => configuredSteps.value.map((text,index) => ({ text, status:index + 1 < activeStep.value ? "done" : index + 1 === activeStep.value ? "doing" : "" })));
function normalizeActiveStep(value) { return configuredSteps.value.length ? Math.min(Math.max(Number(value) || 1, 1), configuredSteps.value.length) : 1; }
function buildProgress() { return { activeStep: activeStep.value, activeModule: activeModule.value, updatedAt: new Date().toISOString() }; }
function applyProgress(value) { if (!value || typeof value !== "object") return; activeStep.value = normalizeActiveStep(value.activeStep); activeModule.value = Math.min(Math.max(Number(value.activeModule) || 0, 0), modules.value.length - 1); }
async function persistProgress(saveAction = "SAVE") { await saveStudentTrainingAnswer(TASK_KEY, { saveAction, currentStep: activeStep.value, step1Answer: JSON.stringify(buildProgress()) }); }
function setCurrentStep(step) { activeStep.value = normalizeActiveStep(step); activeModule.value = Math.min(activeStep.value - 1, modules.value.length - 1); persistProgress("SAVE").catch(() => {}); }
function setActiveModule(index) { if (modules.value[index]?.disabled) return; activeModule.value = index; persistProgress("SAVE").catch(() => {}); }
function goPreviousStep() { setCurrentStep(activeStep.value - 1); } function goNextStep() { setCurrentStep(activeStep.value + 1); }
async function saveCurrentProgress() { if (saving.value) return; saving.value = true; try { await persistProgress(); } finally { saving.value = false; } }
async function submitTask() { if (saving.value) return; saving.value = true; try { businessComponent.value?.submitTask?.(); await persistProgress("SAVE"); } finally { saving.value = false; } }
async function resetTask() { if (saving.value) return; saving.value = true; try { businessComponent.value?.resetTask?.(); activeStep.value = 1; activeModule.value = 0; workspaceKey.value += 1; await persistProgress("RESET"); } finally { saving.value = false; } }
function exportOutcome() { const blob = new Blob([JSON.stringify(buildProgress(), 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); }
onMounted(async () => { try { modules.value.forEach((item) => { try { item.disabled = !isModuleUnique([item.name]); } catch (error) { item.disabled = false; } }); const res = await getTrainingTaskByKey(TASK_KEY); taskConfig.value = res?.data || null; activeStep.value = normalizeActiveStep(activeStep.value); } catch (error) { taskConfig.value = null; } try { const res = await getStudentTrainingAnswer(TASK_KEY); if (res?.data?.step1Answer) applyProgress(JSON.parse(res.data.step1Answer)); } catch (error) {} });
const TASK_KEY = "product-diagnosis-iteration"; const nodes = ["曝光-点击", "点击-加购", "加购-下单", "下单-好评", "好评-复购"]; const channels = [{ key: "tmall", label: "天猫" }, { key: "jd", label: "京东" }, { key: "douyin", label: "抖音" }, { key: "pinduoduo", label: "拼多多" }]; const rateFields = [...channels.map((item) => item.key), "industryAverage"];
const { proxy } = getCurrentInstance(); const taskConfig = ref(null); const currentStep = ref(1); const saving = ref(false); const uploading = ref(false); const analyzing = ref(false); const chartInput = ref(null); const commentInput = ref(null); const abChartInput = ref(null); const wordCloudRef = ref(null); let wordCloudChart = null;
const conversionRates = ref(createConversionRates()); const anomalyRows = ref([createAnomalyRow()]); const funnelChart = ref({ url: "", name: "" }); const analysisResult = ref(null); const commentPreview = ref({ fileName: "", totalComments: 0, comments: [] }); const wordCloud = ref([]); const topIssues = ref(["", "", ""]); const sentiments = ref(createSentiments()); const rootCauseAnalysis = ref(""); const abTestPlan = ref(""); const abRows = ref(createAbRows()); const abChart = ref({ url: "", name: "" }); const iterationRecommendation = ref("");
const taskTitle = computed(() => taskConfig.value?.taskName || "产品问题诊断与迭代"); const taskBackground = computed(() => taskConfig.value?.background || "从曝光到复购的完整数据漏斗与用户评价出发,定位异常流失并形成迭代方向。"); const taskRequirement = computed(() => taskConfig.value?.requirements || "完成转化漏斗诊断、用户评论词云和情感根因分析。"); const taskGoals = computed(() => parseTrainingArray(taskConfig.value?.objectives, ["掌握全链路转化率计算方法", "能够将店铺数据与行业均值进行对比", "从用户评价中定位问题根因"]));
const progressItems = computed(() => currentStep.value === 1 ? [{ text: "填写五个节点的渠道转化率", status: conversionRates.value.every((row) => rateFields.every((field) => row[field] !== null && row[field] !== "" && Number.isFinite(Number(row[field])))) ? "done" : "doing" }, { text: "上传节点转化漏斗对比图", status: funnelChart.value.url ? "done" : "" }, { text: "完成异常节点分析", status: anomalyRows.value.every((row) => row.anomalyAnalysis?.trim()) ? "done" : "" }] : currentStep.value === 2 ? [{ text: "导入评论数据并生成词云", status: analysisResult.value ? "done" : "doing" }, { text: "完成情感倾向分析", status: sentiments.value.every((row) => row.count !== null) ? "done" : "" }, { text: "填写根因分析", status: rootCauseAnalysis.value.trim() ? "done" : "" }] : [{ text: "设计 AB 测试方案", status: abTestPlan.value.trim() ? "done" : "doing" }, { text: "填写五项对照数据", status: abRows.value.every((row) => row.controlValue !== null && row.experimentValue !== null && row.analysis?.trim()) ? "done" : "" }, { text: "上传 AB 对比图", status: abChart.value.url ? "done" : "" }, { text: "提交迭代优化建议", status: iterationRecommendation.value.trim() ? "done" : "" }]);
function createConversionRates() { return nodes.map((linkNode) => ({ linkNode, tmall: null, jd: null, douyin: null, pinduoduo: null, industryAverage: null })); } function createAnomalyRow() { return { channel: "", linkNode: "", storeConversionRate: null, industryAverageConversionRate: null, anomalyAnalysis: "" }; } function createSentiments() { return ["正面", "中性", "负面"].map((type) => ({ type, count: null, percentage: null })); } function createAbRows() { return ["点击-加购转化率", "加购-下单转化率", "下单-好评转化率", "详情页跳出率", "退货率"].map((metric) => ({ metric, controlValue: null, experimentValue: null, analysis: "" })); }
function buildStepOne() { return { conversionRates: conversionRates.value.map((row) => ({ ...row })), funnelChartUrl: funnelChart.value.url, funnelChartName: funnelChart.value.name, anomalyRows: anomalyRows.value.map((row) => ({ ...row })) }; } function buildStepTwo() { return { sourceFileName: commentPreview.value.fileName, topIssues: [...topIssues.value], sentiments: sentiments.value.map((row) => ({ ...row })), rootCauseAnalysis: rootCauseAnalysis.value, wordCloud: wordCloud.value, previewComments: commentPreview.value.comments }; } function buildStepThree() { return { testPlan: abTestPlan.value, rows: abRows.value.map((row) => ({ ...row })), comparisonChartUrl: abChart.value.url, comparisonChartName: abChart.value.name, iterationRecommendation: iterationRecommendation.value }; }
function addAnomalyRow() { if (anomalyRows.value.length < 5) anomalyRows.value.push(createAnomalyRow()); } function removeAnomalyRow() { if (anomalyRows.value.length > 1) anomalyRows.value.pop(); } function clearChart() { if (funnelChart.value.url.startsWith("blob:")) URL.revokeObjectURL(funnelChart.value.url); funnelChart.value = { url: "", name: "" }; }
async function handleChartUpload(event) { const file = event.target?.files?.[0]; if (!file) return; const accepted = ["image/jpeg", "image/png", "image/webp"]; if (!accepted.includes(file.type) || file.size > 5 * 1024 * 1024) { proxy?.$modal?.msgError?.("请上传不超过 5MB 的 PNG、JPG 或 WEBP 图片"); event.target.value = ""; return; } uploading.value = true; try { let url = ""; if (isStudentDemo()) url = URL.createObjectURL(file); else { const data = new FormData(); data.append("file", file); const response = await uploadStudentTrainingFile(data); url = response?.url || response?.data?.url || ""; } if (!url) throw new Error("上传成功但未返回图片地址"); clearChart(); funnelChart.value = { url, name: file.name }; proxy?.$modal?.msgSuccess?.("漏斗对比图上传成功,可点击图片预览"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "图片上传失败,请重试"); } finally { uploading.value = false; event.target.value = ""; } }
async function saveStepOneDraft() { await persistStepOne(false); } async function validateStepOne() { if (saving.value) return; saving.value = true; try { const data = buildStepOne(); await validateProductDiagnosisIterationStepOne(data); await saveStudentTrainingAnswer(TASK_KEY, { step1Answer: JSON.stringify(data), currentStep: 2, submitted: false, saveAction: "SAVE" }); currentStep.value = 2; proxy?.$modal?.msgSuccess?.("第 1 步校验通过,已进入第 2 步"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请检查第 1 步填写内容"); } finally { saving.value = false; } } async function persistStepOne(showMessage) { if (saving.value) return; saving.value = true; try { await saveStudentTrainingAnswer(TASK_KEY, { step1Answer: JSON.stringify(buildStepOne()), currentStep: 1, submitted: false, saveAction: "SAVE" }); if (showMessage !== false) proxy?.$modal?.msgSuccess?.("草稿已保存"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "保存失败,请重试"); } finally { saving.value = false; } }
async function handleCommentImport(event) { const file = event.target?.files?.[0]; if (!file) return; if (!/\.(csv|txt|xls|xlsx)$/i.test(file.name) || file.size > 5 * 1024 * 1024) { proxy?.$modal?.msgError?.("请上传不超过 5MB 的 CSV、TXT、XLS 或 XLSX 评论文件"); event.target.value = ""; return; } analyzing.value = true; try { const result = (await analyzeProductDiagnosisIterationStepTwo(file))?.data; if (!result) throw new Error("未获得评论分析结果"); analysisResult.value = result; commentPreview.value = { fileName: result.sourceFileName || file.name, totalComments: result.totalComments || 0, comments: result.previewComments || [] }; wordCloud.value = result.wordCloud || []; topIssues.value = [0, 1, 2].map((index) => result.topIssues?.[index] || ""); sentiments.value = createSentiments(); await nextTick(); renderWordCloud(); proxy?.$modal?.msgSuccess?.("评论数据已导入,词云已生成"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "评论数据分析失败,请检查文件格式"); } finally { analyzing.value = false; event.target.value = ""; } }
function renderWordCloud() { if (!wordCloudRef.value || !wordCloud.value.length) return; wordCloudChart?.dispose(); wordCloudChart = echarts.init(wordCloudRef.value); wordCloudChart.setOption({ backgroundColor: "transparent", tooltip: { show: true }, series: [{ type: "wordCloud", shape: "circle", gridSize: 5, sizeRange: [16, 58], rotationRange: [-35, 35], textStyle: { color: () => ["#53e2ff", "#6ae1bb", "#ffd06a", "#91b8ff", "#ff9e88"][Math.floor(Math.random() * 5)] }, data: wordCloud.value }] }); }
function runSentimentAnalysis() { if (!analysisResult.value) return; sentiments.value = [{ type: "正面", count: analysisResult.value.positiveCount, percentage: null }, { type: "中性", count: analysisResult.value.neutralCount, percentage: null }, { type: "负面", count: analysisResult.value.negativeCount, percentage: null }]; proxy?.$modal?.msgSuccess?.("评论数量已自动填充,请计算并填写各类情感占比"); }
async function saveStepTwoDraft() { if (saving.value) return; saving.value = true; try { await saveStudentTrainingAnswer(TASK_KEY, { step2Answer: JSON.stringify(buildStepTwo()), currentStep: 2, submitted: false, saveAction: "SAVE" }); proxy?.$modal?.msgSuccess?.("草稿已保存"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "保存失败,请重试"); } finally { saving.value = false; } } async function validateStepTwo() { if (saving.value) return; saving.value = true; try { const data = buildStepTwo(); await validateProductDiagnosisIterationStepTwo(data); await saveStudentTrainingAnswer(TASK_KEY, { step2Answer: JSON.stringify(data), currentStep: 2, submitted: false, saveAction: "SAVE" }); proxy?.$modal?.msgSuccess?.("第 2 步校验通过,已保存"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请检查词云、情感占比和根因分析"); } finally { saving.value = false; } }
function clearAbChart() { if (abChart.value.url.startsWith("blob:")) URL.revokeObjectURL(abChart.value.url); abChart.value = { url: "", name: "" }; }
async function handleAbChartUpload(event) { const file = event.target?.files?.[0]; if (!file) return; const accepted = ["image/jpeg", "image/png", "image/webp"]; if (!accepted.includes(file.type) || file.size > 5 * 1024 * 1024) { proxy?.$modal?.msgError?.("请上传不超过 5MB 的 PNG、JPG 或 WEBP 图片"); event.target.value = ""; return; } uploading.value = true; try { let url = ""; if (isStudentDemo()) url = URL.createObjectURL(file); else { const data = new FormData(); data.append("file", file); const response = await uploadStudentTrainingFile(data); url = response?.url || response?.data?.url || ""; } if (!url) throw new Error("上传成功但未返回图片地址"); clearAbChart(); abChart.value = { url, name: file.name }; proxy?.$modal?.msgSuccess?.("AB 测试结果对比图上传成功,可点击图片预览"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "图片上传失败,请重试"); } finally { uploading.value = false; event.target.value = ""; } }
async function saveStepThreeDraft() { if (saving.value) return; saving.value = true; try { await saveStudentTrainingAnswer(TASK_KEY, { step3Answer: JSON.stringify(buildStepThree()), currentStep: 3, submitted: false, saveAction: "SAVE" }); proxy?.$modal?.msgSuccess?.("草稿已保存"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "保存失败,请重试"); } finally { saving.value = false; } }
async function validateStepThree() { if (saving.value) return; saving.value = true; try { const data = buildStepThree(); await validateProductDiagnosisIterationStepThree(data); await saveStudentTrainingAnswer(TASK_KEY, { step3Answer: JSON.stringify(data), currentStep: 3, submitted: false, saveAction: "SAVE" }); proxy?.$modal?.msgSuccess?.("第 3 步校验通过,已保存"); } catch (error) { proxy?.$modal?.msgError?.(error?.message || "请检查 AB 测试方案、指标数据和对比图"); } finally { saving.value = false; } }
function restore(answer) { try { const stepOne = JSON.parse(answer?.step1Answer || "{}"); if (Array.isArray(stepOne.conversionRates) && stepOne.conversionRates.length === nodes.length) conversionRates.value = nodes.map((node) => ({ ...createConversionRates().find((row) => row.linkNode === node), ...(stepOne.conversionRates.find((row) => row?.linkNode === node) || {}) })); if (Array.isArray(stepOne.anomalyRows) && stepOne.anomalyRows.length) anomalyRows.value = stepOne.anomalyRows.slice(0, 5).map((row) => ({ ...createAnomalyRow(), ...row })); if (stepOne.funnelChartUrl) funnelChart.value = { url: stepOne.funnelChartUrl, name: stepOne.funnelChartName || "节点转化漏斗对比图" }; const stepTwo = JSON.parse(answer?.step2Answer || "{}"); if (stepTwo.sourceFileName) { commentPreview.value = { fileName: stepTwo.sourceFileName, totalComments: 0, comments: stepTwo.previewComments || [] }; wordCloud.value = stepTwo.wordCloud || []; topIssues.value = [0, 1, 2].map((index) => stepTwo.topIssues?.[index] || ""); sentiments.value = (stepTwo.sentiments || createSentiments()).map((row) => ({ ...createSentiments().find((item) => item.type === row.type), ...row })); rootCauseAnalysis.value = stepTwo.rootCauseAnalysis || ""; nextTick(renderWordCloud); } const stepThree = JSON.parse(answer?.step3Answer || "{}"); if (stepThree.testPlan || stepThree.comparisonChartUrl || stepThree.iterationRecommendation) { abTestPlan.value = stepThree.testPlan || ""; if (Array.isArray(stepThree.rows) && stepThree.rows.length === createAbRows().length) abRows.value = createAbRows().map((row) => ({ ...row, ...(stepThree.rows.find((item) => item?.metric === row.metric) || {}) })); if (stepThree.comparisonChartUrl) abChart.value = { url: stepThree.comparisonChartUrl, name: stepThree.comparisonChartName || "AB 测试结果对比图" }; iterationRecommendation.value = stepThree.iterationRecommendation || ""; } currentStep.value = [1, 2, 3].includes(Number(answer?.currentStep)) ? Number(answer.currentStep) : 1; } catch (error) {} }
onMounted(async () => { try { taskConfig.value = (await getTrainingTaskByKey(TASK_KEY))?.data || null; } catch (error) { taskConfig.value = null; } try { restore((await getStudentTrainingAnswer(TASK_KEY))?.data); } catch (error) {} }); onBeforeUnmount(() => { if (funnelChart.value.url.startsWith("blob:")) URL.revokeObjectURL(funnelChart.value.url); if (abChart.value.url.startsWith("blob:")) URL.revokeObjectURL(abChart.value.url); wordCloudChart?.dispose(); });
</script>
<style scoped lang="scss">
.diagnosis{display:grid;grid-template-columns:minmax(720px,1fr) 340px;gap:24px;min-height:calc(100vh - 76px);padding:24px 24px 32px;color:#eef5ff;background:#06111d}.training-core{min-width:0;padding:28px;border:1px solid rgba(0,180,255,.25);border-radius:36px;background:rgba(8,18,28,.6);box-shadow:inset 0 1px 0 rgba(169,229,255,.08),0 22px 48px rgba(0,0,0,.22)}.task-header{margin-bottom:24px}.task-badge{display:inline-flex;align-items:center;gap:8px;min-height:28px;padding:4px 14px;border:1px solid rgba(99,217,255,.55);border-radius:999px;color:#dff5ff;background:rgba(17,126,178,.36);font-size:12px;font-weight:700}.task-title{margin:12px 0 0;color:#fff;font-size:30px;font-weight:900;line-height:1.3}.task-card,.step-card{border:1px solid rgba(82,174,226,.42);border-radius:26px;background:rgba(0,25,42,.64)}.task-card{margin-top:28px;padding:18px 22px 22px}.workbench-head{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;margin-bottom:22px;padding-bottom:18px;border-bottom:1px solid rgba(0,180,255,.2)}.section-eyebrow{margin:0;color:#71dfff;font-size:12px;font-weight:800;line-height:1.4}.workbench-head h3{margin:4px 0 8px;color:#fff;font-size:26px;font-weight:800;line-height:1.3}.workbench-description{margin:0;color:#d7ecff;font-size:15px;line-height:1.8}.workbench-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:10px;flex-shrink:0}.outline-action,.btn-nav,.analysis-tab{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:40px;padding:0 18px;border:1px solid rgba(99,217,255,.55);border-radius:999px;color:#e9fbff;background:rgba(17,126,178,.36);font:inherit;font-size:14px;font-weight:800;cursor:pointer}.step-indicator{display:flex;gap:6px;min-height:58px;margin:0 0 32px;padding:6px;overflow-x:auto;border:1px solid rgba(0,180,255,.3);border-radius:999px;background:rgba(0,20,35,.65)}.step-tab{flex:1 0 max-content;min-height:46px;padding:0 18px;border:1px solid transparent;border-radius:999px;color:#9ab3d0;background:transparent;font:inherit;font-size:14px;font-weight:800;cursor:pointer}.step-tab.active,.analysis-tab.active{border-color:rgba(92,224,255,.7);color:#fff;background:linear-gradient(95deg,#0a6b9e,#0a5c86)}.step-card{padding:18px 22px 22px}.step-title-row{margin-bottom:14px}.step-title-row h3{margin:4px 0 0;color:#fff;font-size:22px;font-weight:900}.analysis-tabs{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:18px}.analysis-tab:disabled{cursor:not-allowed;opacity:.45}.business-workspace{min-width:0;overflow:hidden;border:1px solid rgba(0,180,255,.22);border-radius:18px;background:rgba(1,14,26,.72)}.step-navigation{display:flex;justify-content:center;gap:14px;margin-top:22px}.btn-nav.btn-save,.btn-nav.btn-primary{border-color:rgba(92,224,255,.7);color:#fff;background:linear-gradient(95deg,#0b84bd,#096491)}.btn-nav:disabled{cursor:not-allowed;opacity:.45}.training-actions{display:flex;flex-wrap:wrap;justify-content:center;gap:52px;margin-top:36px}.training-actions :deep(.training-action){min-width:172px;height:64px;padding:0 28px;border:1px solid #2c83aa;border-radius:6px;color:#dff7ff;background:rgba(13,64,88,.62);font-size:24px;font-weight:900}
.business-workspace :deep(.training-intro){display:none!important}.business-workspace :deep(.training-step-area){margin:0!important;padding:18px!important;border:0!important;border-radius:0!important;color:#d7ecff!important;background:transparent!important;box-shadow:none!important}.business-workspace :deep(.taskfooter){display:none!important}.business-workspace :deep(.five),.business-workspace :deep(.kano),.business-workspace :deep(.market),.business-workspace :deep(.traffic),.business-workspace :deep(.main-top),.business-workspace :deep(.app-main){color:#d7ecff!important;background:transparent!important}.business-workspace :deep(.el-scrollbar){height:auto!important}.business-workspace :deep(.el-table){--el-table-border-color:rgba(77,167,220,.2);--el-table-header-bg-color:rgba(8,55,82,.98);--el-table-header-text-color:#eaffff;--el-table-row-hover-bg-color:rgba(11,84,122,.45);--el-table-bg-color:rgba(1,14,26,.72);--el-table-tr-bg-color:rgba(1,14,26,.48);--el-table-text-color:#d7f1ff;overflow:hidden;border:1px solid rgba(0,180,255,.22);border-radius:16px;background:rgba(1,14,26,.72)!important}.business-workspace :deep(.el-table th.el-table__cell){color:#eaffff!important;background:rgba(8,55,82,.98)!important;font-weight:900}.business-workspace :deep(.el-table tr),.business-workspace :deep(.el-table td.el-table__cell){color:#d7f1ff!important;background:rgba(1,14,26,.48)!important}.business-workspace :deep(.el-input__wrapper),.business-workspace :deep(.el-textarea__inner),.business-workspace :deep(.el-select__wrapper){border:1px solid rgba(45,95,126,.95)!important;border-radius:14px!important;box-shadow:none!important;color:#eef5ff!important;background:#0f1a24!important}.business-workspace :deep(.el-input__inner),.business-workspace :deep(.el-textarea__inner){color:#eef5ff!important}.business-workspace :deep(.el-button){min-height:40px;padding:0 18px;border:1px solid rgba(92,224,255,.7)!important;border-radius:999px;color:#fff!important;font-size:14px;font-weight:800;background:linear-gradient(95deg,#0b84bd,#096491)!important}.business-workspace :deep(.caseBacktitle){display:inline-flex;align-items:center;min-height:28px;padding:4px 12px;border:1px solid rgba(106,226,255,.45);border-radius:999px;color:#71dfff;background:rgba(0,127,184,.25);font-size:12px;font-weight:800}.business-workspace :deep(.caseBacktitle img){display:none}.business-workspace :deep(.df),.business-workspace :deep(.left),.business-workspace :deep(.right){color:#d7ecff!important;background:transparent!important}
@media(max-width:1100px){.diagnosis{grid-template-columns:1fr}}@media(max-width:900px){.diagnosis{padding:14px}.training-core{padding:18px;border-radius:24px}.workbench-head,.workbench-actions,.step-navigation{flex-direction:column;align-items:stretch}.workbench-actions,.outline-action,.btn-nav,.training-actions :deep(.training-action){width:100%}.step-indicator{flex-direction:column;border-radius:26px}.step-tab{flex:none}}
.diagnosis .step-indicator { display:flex; align-items:center; gap:0; width:calc(100% + 24px); min-height:0; margin:0 -12px 32px; padding:18px 22px; overflow-x:auto; border:1px solid rgba(0,180,255,.3); border-radius:18px; background:linear-gradient(110deg,rgba(4,39,58,.9),rgba(2,20,33,.94)); box-shadow:inset 0 1px 0 rgba(169,229,255,.08); }
.diagnosis .step-tab { display:flex; flex:1 0 145px; min-width:0; align-items:center; min-height:66px; padding:8px 10px; border:1px solid rgba(68,141,177,.35); border-radius:8px; color:#9ab3d0; background:rgba(3,27,43,.52); text-align:left; }
.diagnosis .step-tab.active { border-color:rgba(89,226,255,.85); color:#fff; background:linear-gradient(115deg,rgba(4,109,155,0.78),rgba(5,62,102,0.64)); box-shadow:0 0 18px rgba(24,183,235,.22),inset 0 1px 0 rgba(176,246,255,.2); }
.diagnosis .step-marker { display:inline-flex; flex:0 0 38px; align-items:center; justify-content:center; width:38px; height:38px; border:1px solid rgba(113,223,255,.55); border-radius:50%; color:#bdefff; background:rgba(3,27,43,.72); font-size:13px; font-weight:900; }.diagnosis .step-copy { display:grid; min-width:0; gap:4px; margin-left:10px; }.diagnosis .step-copy strong { display:-webkit-box; overflow:hidden; color:inherit; font-size:14px; line-height:1.25; -webkit-box-orient:vertical; -webkit-line-clamp:2; }.diagnosis .step-action { color:#7ec7e7; font-size:12px; font-weight:700; }
.diagnosis .step-tab.active .step-marker { border-color:#72e8ff; color:#fff; background:linear-gradient(135deg,#08b7e8,#1375d0); box-shadow:0 0 0 5px rgba(25,179,237,.14),0 0 18px rgba(46,208,255,.58); }.diagnosis .step-tab.complete { color:#dffbff; }.diagnosis .step-tab.complete .step-marker { border-color:rgba(84,230,221,.85); color:#062b37; background:#6ef0e1; box-shadow:0 0 12px rgba(79,231,218,.35); }
.diagnosis .step-connector { position:relative; flex:0 1 40px; min-width:18px; height:2px; margin:0 8px; overflow:visible; background:rgba(77,167,220,.54); }.diagnosis .step-connector::after { position:absolute; top:50%; right:-1px; width:6px; height:6px; border-top:1px solid currentColor; border-right:1px solid currentColor; color:#78cfe9; content:""; transform:translateY(-50%) rotate(45deg); }.diagnosis .step-tab.complete + .step-connector { background:linear-gradient(90deg,#6ef0e1,#1cb8e9); box-shadow:0 0 9px rgba(53,218,235,.46); }.diagnosis .step-tab.complete + .step-connector::after { color:#63e6f1; }
@media(max-width:900px){.diagnosis .step-indicator { align-items:stretch; flex-direction:column; width:100%; margin-right:0; margin-left:0; overflow:visible; border-radius:20px; }.diagnosis .step-tab { flex:0 0 auto; width:100%; }.diagnosis .step-connector { flex:0 0 20px; min-width:0; width:2px; height:20px; margin:0 0 0 29px; }.diagnosis .step-connector::after { top:auto; right:50%; bottom:-1px; transform:translateX(50%) rotate(135deg); }}
.diagnosis .workbench-head--actions-only { justify-content:flex-end; margin-bottom:18px; padding-bottom:0; border-bottom:0; }
.ab-table{width:100%;min-width:860px;border-collapse:collapse}.ab-table th,.ab-table td{border:1px solid rgba(139,213,241,.45)}.ab-table thead th{padding:14px 10px;color:#fff;background:#079ed9;font-size:16px}.ab-table tbody th{width:25%;padding:14px 10px;color:#dff8ff;background:rgba(6,67,94,.34);text-align:center}.ab-table td{padding:9px 11px;text-align:center}.ab-table td:nth-child(2),.ab-table td:nth-child(3){width:18%}.ab-table td:last-child{width:39%}
</style>

@ -1,82 +1,359 @@
<template>
<div class="optimization student-training-shell">
<main class="training-core">
<section class="task-header"><div class="task-badge"><el-icon><TrendCharts /></el-icon> 核心实训 · 产品上线与运营推广</div><h1 class="task-title">{{ taskTitle }}</h1></section>
<section class="task-header">
<div class="task-badge"><el-icon><TrendCharts /></el-icon>核心实训 · 产品上线与运营推广</div>
<h1>{{ taskTitle }}</h1>
</section>
<TrainingTaskBrief :background="taskBackground" :goals="taskGoals" :requirements="taskRequirement" :show-material="false" />
<section class="task-card">
<div class="workbench-head workbench-head--actions-only"><div class="workbench-actions"><TrainingMaterialButton :material-name="taskConfig?.materialName" :material-url="taskConfig?.materialUrl" /><button type="button" class="outline-action" @click="exportOutcome"><el-icon><Download /></el-icon>导出实训成果</button></div></div>
<nav v-if="hasConfiguredSteps" class="step-indicator" aria-label="品类结构优化步骤"><template v-for="(step,index) in configuredSteps" :key="`${step}-${index}`"><button type="button" :class="['step-tab',{active:activeStep===index+1,complete:activeStep>index+1}]" :aria-current="activeStep===index+1?'step':undefined" :aria-label="`第${index+1}步:${step}`" @click="setCurrentStep(index+1)"><span class="step-marker">{{ String(index+1).padStart(2,'0') }}</span><span class="step-copy"><strong :title="step">{{ step }}</strong><span class="step-action">{{ activeStep===index+1?'当前步骤':'点击切换 →' }}</span></span></button><span v-if="index<configuredSteps.length-1" class="step-connector" aria-hidden="true"></span></template></nav>
<section v-show="!hasConfiguredSteps || activeStep <= 2" class="step-card">
<div class="step-title-row"><div><p v-if="hasConfiguredSteps" class="section-eyebrow">Step {{ String(activeStep).padStart(2,'0') }}</p><h3>{{ activeStepName }}</h3></div></div>
<div class="analysis-tabs" role="tablist" aria-label="品类分析工具"><button v-for="(item,index) in modules" :key="item.name" type="button" class="analysis-tab" :class="{active:activeModule===index}" :aria-selected="activeModule===index" role="tab" @click="setActiveModule(index)">{{ item.name }}</button></div>
<div class="workbench-head">
<div>
<p class="section-eyebrow">CATEGORY OPERATIONS</p>
<h2>{{ activeStepName }}</h2>
</div>
<div class="workbench-actions">
<TrainingMaterialButton :material-name="taskConfig?.materialName" :material-url="taskConfig?.materialUrl" />
<button class="outline-action" type="button" @click="exportOutcome"><el-icon><Download /></el-icon>导出实训成果</button>
</div>
</div>
<nav v-if="hasConfiguredSteps" class="step-indicator" aria-label="品类结构优化步骤">
<template v-for="(step, index) in configuredSteps" :key="`${step}-${index}`">
<button type="button" :class="['step-tab', { active: activeStep === index + 1, complete: activeStep > index + 1 }]" @click="setCurrentStep(index + 1)">
<span>{{ String(index + 1).padStart(2, '0') }}</span>{{ step }}
</button>
</template>
</nav>
<section v-if="isStepOne" class="category-step">
<p class="instruction">根据案例资料提供的店铺各品类近12个月营收数据,对各品类按照销售额从大到小进行排序,并计算营收占比和累计营收占比,完善数据表。</p>
<div class="table-toolbar">
<span>已填写 {{ categoryRows.length }} / 30 行</span>
<div>
<button type="button" class="action-button" :disabled="categoryRows.length >= MAX_ROWS" @click="addRow"><el-icon><Plus /></el-icon>增加一行</button>
<button type="button" class="action-button action-button--muted" :disabled="!categoryRows.length" @click="removeLastRow"><el-icon><Minus /></el-icon>删除一行</button>
</div>
</div>
<div class="category-table-wrap">
<table class="category-table">
<thead><tr><th>品类名称</th><th>近12个月营收(万元)</th><th>营收占比(%)</th><th>累计占比(%)</th><th>库存资金占用(万元)</th><th>资金占用率</th><th>近30天动销率</th><th>毛利率</th></tr></thead>
<tbody>
<tr v-for="(row, index) in categoryRows" :key="row.id">
<td><el-input v-model="row.categoryName" :aria-label="`第${index + 1}行品类名称`" /></td>
<td><el-input v-model="row.revenue" inputmode="decimal" :aria-label="`第${index + 1}行近12个月营收`" /></td>
<td><el-input v-model="row.revenueShare" inputmode="decimal" :aria-label="`第${index + 1}行营收占比`" /></td>
<td><el-input v-model="row.cumulativeShare" inputmode="decimal" :aria-label="`第${index + 1}行累计占比`" /></td>
<td><el-input v-model="row.inventoryCapital" inputmode="decimal" :aria-label="`第${index + 1}行库存资金占用`" /></td>
<td><el-input v-model="row.capitalOccupancyRate" inputmode="decimal" :aria-label="`第${index + 1}行资金占用率`" /></td>
<td><el-input v-model="row.sellThroughRate" inputmode="decimal" :aria-label="`第${index + 1}行近30天动销率`" /></td>
<td><el-input v-model="row.grossMargin" inputmode="decimal" :aria-label="`第${index + 1}行毛利率`" /></td>
</tr>
<tr v-if="!categoryRows.length"><td colspan="8" class="empty-cell">暂无数据,请根据案例资料增加行并填写。</td></tr>
</tbody>
</table>
</div>
<div class="rule-notice">
<strong>填写与校验说明</strong>
<p>默认无数据,学生自行根据案例资料增加行填写数据,最多30行。</p>
<p>近12个月营收须从高到低排列;营收占比 = 当前行近12个月营收 ÷ 所有品类营收之和;累计占比 = 从第一行到当前行的营收占比之和。三项逻辑不符时将提示,且不能进入下一步或提交任务。</p>
</div>
</section>
<section v-else-if="isStepTwo" class="category-step">
<p class="instruction">累计营收占比前70%的品类划分为A类核心品类,70%–90%区间的品类划分为B类潜力品类,最后10%的品类划分为C类长尾品类。请按照上述标准完成ABC分层。</p>
<div class="category-table-wrap">
<table class="category-table abc-table">
<thead><tr><th>品类名称</th><th>营收贡献(万元)</th><th>销售额占比(%)</th><th>累计占比(%)</th><th>ABC分层判定</th></tr></thead>
<tbody>
<tr v-for="(row, index) in categoryRows" :key="row.id">
<td>{{ row.categoryName }}</td>
<td>{{ row.revenue }}</td>
<td>{{ row.revenueShare }}</td>
<td>{{ row.cumulativeShare }}</td>
<td>
<el-select v-model="row.abcClassification" placeholder="请选择" :aria-label="`第${index + 1}行ABC分层判定`">
<el-option label="A类核心品类" value="A" />
<el-option label="B类潜力品类" value="B" />
<el-option label="C类长尾品类" value="C" />
</el-select>
</td>
</tr>
<tr v-if="!categoryRows.length"><td colspan="5" class="empty-cell">第一步暂无可同步的数据,请先完成第一步。</td></tr>
</tbody>
</table>
</div>
<div class="rule-notice">
<strong>ABC分层校验说明</strong>
<p>本表的品类名称、营收贡献、销售额占比和累计占比与第一步同步;仅需选择ABC分层判定。</p>
<p>累计营收占比 ≤70% 为A类,>70%且≤90%为B类,>90%为C类。判定不符合时将提示,且不能进入下一步或提交任务。</p>
</div>
</section>
<section v-else-if="isStepThree" class="category-step pareto-step">
<p class="instruction">根据步骤二数据,绘制帕累托图:柱状图为各品类营收贡献(降序排列),折线图为累计占比,并标注70%和90%分界线,直观展示ABC分层结果。</p>
<div class="pareto-upload-panel">
<input ref="paretoInput" class="file-input" type="file" accept="image/*" @change="handleParetoUpload" />
<template v-if="!paretoImage.url">
<el-icon class="upload-icon"><UploadFilled /></el-icon>
<strong>上传帕累托图</strong>
<span>支持 JPG、PNG、WEBP 等图片格式</span>
<button type="button" class="action-button" :disabled="uploadingPareto" @click="paretoInput?.click()">选择图片</button>
</template>
<template v-else>
<el-image class="pareto-preview" :src="paretoImage.url" :preview-src-list="[paretoImage.url]" fit="contain" preview-teleported />
<div class="upload-file-meta"><strong>{{ paretoImage.name || "已上传帕累托图" }}</strong><span>点击图片可预览大图</span></div>
<div class="pareto-actions"><button type="button" class="outline-action" :disabled="uploadingPareto" @click="paretoInput?.click()"><el-icon><UploadFilled /></el-icon>重新上传</button><button type="button" class="outline-action danger-action" @click="removeParetoImage"><el-icon><Delete /></el-icon>移除图片</button></div>
</template>
</div>
<div class="rule-notice">
<strong>上传校验说明</strong>
<p>帕累托图必须根据步骤二已完成的ABC分层数据绘制;未上传图片时,不能进入下一步或提交任务。</p>
</div>
</section>
<section v-else-if="isStepFour" class="category-step strategy-step">
<p class="instruction">作为战略新品,通常营收贡献较低而被归入C类长尾品类;但新品具备技术领先性和市场增长潜力,需要通过矩阵定位确定流量扶持与分层优化方向。</p>
<h3 class="subsection-heading">(一)品类矩阵定位</h3>
<p class="subsection-copy">根据案例资料及新品的目标客群,定位新品的品类矩阵。</p>
<div class="category-matrix" aria-label="市场增长率与客群重叠度矩阵">
<span class="matrix-y-title">市场增长率</span><span class="matrix-y-high">高</span><span class="matrix-y-low">低</span><span class="matrix-x-title">客群重叠度</span><span class="matrix-x-high">高</span><span class="matrix-x-low">低</span>
<div class="matrix-grid"><div class="matrix-cell"><strong>新品渠道孵化</strong><small>需独立引流</small></div><div class="matrix-cell"><strong>品类延伸</strong><small>可直接向老客推广</small></div><div class="matrix-cell"><strong>放弃或外包</strong></div><div class="matrix-cell"><strong>防御性布局</strong><small>保持存在即可</small></div></div>
</div>
<div class="matrix-inputs">
<label>新品矩阵位置<el-select v-model="stepFour.matrixQuadrant" placeholder="请选择矩阵位置"><el-option label="新品渠道孵化(高增长、低重叠)" value="新品渠道孵化" /><el-option label="品类延伸(高增长、高重叠)" value="品类延伸" /><el-option label="放弃或外包(低增长、低重叠)" value="放弃或外包" /><el-option label="防御性布局(低增长、高重叠)" value="防御性布局" /></el-select></label>
<label>定位原因<el-input v-model="stepFour.matrixReason" type="textarea" :rows="3" placeholder="说明新品位于该矩阵位置的原因" /></label>
</div>
<div class="rule-notice"><strong>矩阵阅读说明</strong><p>横轴、纵轴均从左下角原点开始:向右表示客群重叠度从低到高,向上表示市场增长率从低到高。</p></div>
<h3 class="subsection-heading">(二)新品分层优化</h3>
<p class="subsection-copy">结合品类分析结果,提出新品分层优化建议。</p>
<div class="category-table-wrap">
<table class="category-table strategy-table"><thead><tr><th>新品名称</th><th>当前分层</th><th>调整必要性分析</th><th>建议调整分层</th><th>流量扶持措施</th></tr></thead><tbody><tr><td><el-select v-model="stepFour.productName" placeholder="请选择新品" @change="handleProductChange"><el-option v-for="row in categoryRows" :key="row.id" :label="row.categoryName" :value="row.categoryName" /></el-select></td><td><span class="current-classification">{{ currentClassificationLabel }}</span></td><td><el-input v-model="stepFour.necessityAnalysis" type="textarea" :rows="3" placeholder="分析调整分层的必要性" /></td><td><el-select v-model="stepFour.recommendedClassification" placeholder="请选择"><el-option label="A类核心品类" value="A" :disabled="selectedProduct?.abcClassification === 'A'" /><el-option label="B类潜力品类" value="B" :disabled="selectedProduct?.abcClassification === 'B'" /><el-option label="C类长尾品类" value="C" :disabled="selectedProduct?.abcClassification === 'C'" /></el-select></td><td><el-input v-model="stepFour.trafficSupportMeasures" type="textarea" :rows="3" placeholder="填写流量扶持措施" /></td></tr></tbody></table>
</div>
<div class="rule-notice"><strong>同步与校验说明</strong><p>新品名称只能选择第一步已填写的品类;当前分层由第二步结果自动带出。建议调整分层必须不同于当前分层,且矩阵定位、原因、必要性分析与流量扶持措施均须完整填写。</p></div>
</section>
<section v-else-if="isStepFive" class="category-step strategy-step">
<p class="instruction">根据ABC分层结果,针对A类核心品类、B类潜力品类、C类长尾品类分别制定差异化的运营策略、库存策略和资源配置方案;结合案例资料提供的SKU数、动销率、毛利率、库存周转率等经营数据填写下表。</p>
<div class="category-table-wrap">
<table class="category-table operations-table"><thead><tr><th>ABC分层</th><th>运营策略</th><th>库存策略</th><th>资源配置优先级</th></tr></thead><tbody><tr v-for="row in strategyRows" :key="row.layer"><td class="layer-cell">{{ row.label }}</td><td><el-input v-model="row.operationStrategy" type="textarea" :rows="3" :placeholder="row.operationPlaceholder" /></td><td><el-input v-model="row.inventoryStrategy" type="textarea" :rows="3" :placeholder="row.inventoryPlaceholder" /></td><td><el-select v-model="row.resourcePriority" placeholder="请选择"><el-option label="最高优先级" value="最高优先级" /><el-option label="次级优先级" value="次级优先级" /><el-option label="较低优先级" value="较低优先级" /></el-select></td></tr></tbody></table>
</div>
<div class="rule-notice"><strong>填写说明</strong><p>A、B、C类和新品四行均需填写。运营、库存策略应结合案例经营数据;资源配置优先级需明确选择,未完整填写时不能提交任务。</p></div>
</section>
<section v-else class="analysis-step">
<div class="analysis-tabs" role="tablist" aria-label="品类分析工具">
<button v-for="(item, index) in modules" :key="item.name" type="button" :class="{ active: activeModule === index }" @click="setActiveModule(index)">{{ item.name }}</button>
</div>
<div class="business-workspace"><component :is="activeComponent" :key="workspaceKey" ref="businessComponent" /></div>
</section>
<section v-show="hasConfiguredSteps && activeStep > 2" class="step-card"><div class="step-title-row"><div><p class="section-eyebrow">Step {{ String(activeStep).padStart(2,'0') }}</p><h3>{{ activeStepName }}</h3></div></div><p class="step-desc">请复核帕累托/ABC 分析与购物篮分析结果,提炼高价值商品组合、货架陈列或促销策略后再提交成果。</p><div class="strategy-card"><strong>分析输出建议</strong><span>明确核心商品、关联销售组合、优化动作及预期业务价值。</span></div></section>
<div v-if="hasConfiguredSteps" class="step-navigation"><button type="button" class="btn-nav" :disabled="activeStep===1" @click="goPreviousStep"><el-icon><ArrowLeft /></el-icon>上一步</button><button type="button" class="btn-nav btn-save" :disabled="saving" @click="saveCurrentProgress"><el-icon><CircleCheck /></el-icon>保存当前进度</button><button type="button" class="btn-nav btn-primary" :disabled="activeStep===configuredSteps.length" @click="goNextStep">下一步<el-icon><ArrowRight /></el-icon></button></div>
<div class="training-actions"><el-button class="training-action" :loading="saving" @click="submitTask">提交任务</el-button><el-button class="training-action" :loading="saving" @click="resetTask">清空填写</el-button></div>
<div v-if="hasConfiguredSteps" class="step-navigation">
<button type="button" class="nav-button" :disabled="activeStep === 1" @click="setCurrentStep(activeStep - 1)"><el-icon><ArrowLeft /></el-icon>上一步</button>
<button type="button" class="nav-button nav-button--save" :disabled="saving" @click="saveCurrentProgress"><el-icon><CircleCheck /></el-icon>保存当前进度</button>
<button type="button" class="nav-button nav-button--primary" :disabled="activeStep === configuredSteps.length" @click="goNextStep">下一步<el-icon><ArrowRight /></el-icon></button>
</div>
<div class="training-actions">
<el-button class="training-action" :loading="saving" @click="submitTask">提交任务</el-button>
<el-button class="training-action" :loading="saving" @click="resetTask">清空填写</el-button>
</div>
</section>
</main>
<TrainingAiSidebar study-tip="当前任务建议:先完成 ABC 分类识别核心商品,再通过购物篮分析确定可联合销售的商品组合。" :progress-items="progressItems" />
<TrainingAiSidebar study-tip="先完成品类营收数据排序和占比校验,再开展后续分析。" :progress-items="progressItems" />
</div>
</template>
<script setup>
import { computed, defineAsyncComponent, onMounted, ref } from "vue";
import { ArrowLeft, ArrowRight, CircleCheck, Download, TrendCharts } from "@element-plus/icons-vue";
import { ArrowLeft, ArrowRight, CircleCheck, Delete, Download, Minus, Plus, TrendCharts, UploadFilled } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
import { getTrainingTaskByKey } from "@/api/trainingTask";
import { getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
import { getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile, validateCategoryOptimizationStepOne, validateCategoryOptimizationStepTwo, validateCategoryOptimizationStepThree, validateCategoryOptimizationStepFour, validateCategoryOptimizationStepFive } from "@/api/studentTrainingAnswer";
import { parseTrainingArray } from "@/views/training/taskKeyMap";
import { isStudentDemo } from "@/utils/studentDemo";
const TASK_KEY = "category-optimization";
const taskConfig = ref(null); const activeStep = ref(1); const activeModule = ref(0); const saving = ref(false); const workspaceKey = ref(0); const businessComponent = ref(null);
const modules = [{ name: "帕累托/ABC 分析", component: defineAsyncComponent(() => import("../portraitModel/components/abcAnalysis.vue")) }, { name: "购物篮分析", component: defineAsyncComponent(() => import("../portraitModel/components/basketAnalysis.vue")) }];
const MAX_ROWS = 30;
const taskConfig = ref(null);
const activeStep = ref(1);
const activeModule = ref(0);
const saving = ref(false);
const workspaceKey = ref(0);
const businessComponent = ref(null);
const categoryRows = ref([]);
const paretoInput = ref(null);
const uploadingPareto = ref(false);
const paretoImage = ref({ url: "", name: "" });
const stepFour = ref(createStepFour());
const strategyRows = ref(createStrategyRows());
const modules = [
{ name: "帕累托 / ABC 分析", component: defineAsyncComponent(() => import("../portraitModel/components/abcAnalysis.vue")) },
{ name: "购物篮分析", component: defineAsyncComponent(() => import("../portraitModel/components/basketAnalysis.vue")) },
];
const activeComponent = computed(() => modules[activeModule.value].component);
const taskTitle = computed(() => taskConfig.value?.taskName || "品类结构优化");
const taskBackground = computed(() => taskConfig.value?.background || "通过调整和优化产品品类结构,提高平台运营效率、满足消费者需求,并增强市场竞争力。");
const taskRequirement = computed(() => taskConfig.value?.requirements || "通过 ABC 分析对商品进行分类管理,通过购物篮分析优化商品组合。" );
const taskGoals = computed(() => parseTrainingArray(taskConfig.value?.objectives, ["理解 ABC 分析与购物篮分析方法", "能够识别核心商品与高价值商品组合", "形成可执行的品类结构优化建议"]));
const configuredSteps = computed(() => parseTrainingArray(taskConfig.value?.steps)); const hasConfiguredSteps = computed(() => configuredSteps.value.length > 0); const activeStepName = computed(() => configuredSteps.value[activeStep.value - 1] || "品类结构优化"); const progressItems = computed(() => configuredSteps.value.map((text,index) => ({ text, status:index+1<activeStep.value?"done":index+1===activeStep.value?"doing":"" })));
function normalizeActiveStep(value) { if (!configuredSteps.value.length) return 1; return Math.min(Math.max(Number(value)||1,1),configuredSteps.value.length); }
function buildProgress() { return { activeStep:activeStep.value, activeModule:activeModule.value, updatedAt:new Date().toISOString() }; }
function applyProgress(value) { if (!value || typeof value!=="object") return; activeStep.value=normalizeActiveStep(value.activeStep); activeModule.value=Math.min(Math.max(Number(value.activeModule)||0,0),modules.length-1); }
async function persistProgress(saveAction="SAVE") { await saveStudentTrainingAnswer(TASK_KEY,{ saveAction,currentStep:activeStep.value,step1Answer:JSON.stringify(buildProgress()) }); }
function setCurrentStep(step) { activeStep.value=normalizeActiveStep(step); persistProgress("SAVE").catch(()=>{}); }
function setActiveModule(index) { activeModule.value=index; persistProgress("SAVE").catch(()=>{}); }
function goPreviousStep(){ setCurrentStep(activeStep.value-1); } function goNextStep(){ setCurrentStep(activeStep.value+1); }
async function saveCurrentProgress(){ if(saving.value)return; saving.value=true; try{await persistProgress();}finally{saving.value=false;} }
async function submitTask(){ if(saving.value)return; saving.value=true; try{businessComponent.value?.submitData?.();await persistProgress("SAVE");}finally{saving.value=false;} }
async function resetTask(){ if(saving.value)return; saving.value=true; try{businessComponent.value?.retrain?.();activeStep.value=1;activeModule.value=0;workspaceKey.value+=1;await persistProgress("RESET");}finally{saving.value=false;} }
function exportOutcome(){ const blob=new Blob([JSON.stringify(buildProgress(),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); }
onMounted(async()=>{ try{const res=await getTrainingTaskByKey(TASK_KEY);taskConfig.value=res?.data||null;activeStep.value=normalizeActiveStep(activeStep.value);}catch(error){taskConfig.value=null;} try{const res=await getStudentTrainingAnswer(TASK_KEY);if(res?.data?.step1Answer)applyProgress(JSON.parse(res.data.step1Answer));}catch(error){} });
</script>
const taskRequirement = computed(() => taskConfig.value?.requirements || "根据案例数据完成品类营收排序、占比与累计占比分析。");
const taskGoals = computed(() => parseTrainingArray(taskConfig.value?.objectives, ["理解品类营收结构", "掌握累计营收占比计算", "形成可执行的品类结构优化建议"]));
const configuredSteps = computed(() => parseTrainingArray(taskConfig.value?.steps));
const hasConfiguredSteps = computed(() => configuredSteps.value.length > 0);
const activeStepName = computed(() => configuredSteps.value[activeStep.value - 1] || "品类营收数据整理");
const isStepOne = computed(() => !hasConfiguredSteps.value || activeStep.value === 1);
const isStepTwo = computed(() => hasConfiguredSteps.value && activeStep.value === 2);
const isStepThree = computed(() => hasConfiguredSteps.value && activeStep.value === 3);
const isStepFour = computed(() => hasConfiguredSteps.value && activeStep.value === 4);
const isStepFive = computed(() => hasConfiguredSteps.value && activeStep.value === 5);
const selectedProduct = computed(() => categoryRows.value.find((row) => row.categoryName === stepFour.value.productName) || null);
const currentClassificationLabel = computed(() => selectedProduct.value?.abcClassification ? `${selectedProduct.value.abcClassification}类${selectedProduct.value.abcClassification === "A" ? "核心品类" : selectedProduct.value.abcClassification === "B" ? "潜力品类" : "长尾品类"}` : "选择新品后自动带出");
const progressItems = computed(() => configuredSteps.value.map((text, index) => ({ text, status: index + 1 < activeStep.value ? "done" : index + 1 === activeStep.value ? "doing" : "" })));
<style scoped lang="scss">
.optimization{display:grid;grid-template-columns:minmax(720px,1fr) 340px;gap:24px;min-height:calc(100vh - 76px);padding:24px 24px 32px;color:#eef5ff;background:#06111d}.training-core{min-width:0;padding:28px;border:1px solid rgba(0,180,255,.25);border-radius:36px;background:rgba(8,18,28,.6);box-shadow:inset 0 1px 0 rgba(169,229,255,.08),0 22px 48px rgba(0,0,0,.22)}.task-header{margin-bottom:24px}.task-badge{display:inline-flex;align-items:center;gap:8px;min-height:28px;padding:4px 14px;border:1px solid rgba(99,217,255,.55);border-radius:999px;color:#dff5ff;background:rgba(17,126,178,.36);font-size:12px;font-weight:700}.task-title{margin:12px 0 0;color:#fff;font-size:30px;font-weight:900;line-height:1.3}.task-card,.step-card{border:1px solid rgba(82,174,226,.42);border-radius:26px;background:rgba(0,25,42,.64)}.task-card{margin-top:28px;padding:18px 22px 22px}.workbench-head{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;margin-bottom:22px;padding-bottom:18px;border-bottom:1px solid rgba(0,180,255,.2)}.section-eyebrow{margin:0;color:#71dfff;font-size:12px;font-weight:800;line-height:1.4}.workbench-head h3{margin:4px 0 8px;color:#fff;font-size:26px;font-weight:800;line-height:1.3}.workbench-description,.step-desc{margin:0;color:#d7ecff;font-size:15px;line-height:1.8}.workbench-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:10px;flex-shrink:0}.outline-action,.btn-nav,.analysis-tab{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:40px;padding:0 18px;border:1px solid rgba(99,217,255,.55);border-radius:999px;color:#e9fbff;background:rgba(17,126,178,.36);font:inherit;font-size:14px;font-weight:800;cursor:pointer}.step-indicator{display:flex;gap:6px;min-height:58px;margin:0 0 32px;padding:6px;overflow-x:auto;border:1px solid rgba(0,180,255,.3);border-radius:999px;background:rgba(0,20,35,.65)}.step-tab{flex:1 0 max-content;min-height:46px;padding:0 18px;border:1px solid transparent;border-radius:999px;color:#9ab3d0;background:transparent;font:inherit;font-size:14px;font-weight:800;cursor:pointer}.step-tab.active,.analysis-tab.active{border-color:rgba(92,224,255,.7);color:#fff;background:linear-gradient(95deg,#0a6b9e,#0a5c86)}.step-card{margin-top:32px;padding:18px 22px 22px}.step-indicator+.step-card,.workbench-head+.step-card{margin-top:0}.step-title-row{margin-bottom:14px}.step-title-row h3{margin:4px 0 0;color:#fff;font-size:22px;font-weight:900}.analysis-tabs{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:18px}.business-workspace{min-width:0;overflow:hidden;border:1px solid rgba(0,180,255,.22);border-radius:18px;background:rgba(1,14,26,.72)}.strategy-card{display:grid;gap:6px;margin-top:18px;padding:16px;border:1px solid rgba(0,180,255,.22);border-radius:16px;background:rgba(1,14,26,.72)}.strategy-card strong{color:#71dfff}.strategy-card span{color:#d7ecff;font-size:14px;line-height:1.7}.step-navigation{display:flex;justify-content:center;gap:14px;margin-top:22px}.btn-nav.btn-save,.btn-nav.btn-primary{border-color:rgba(92,224,255,.7);color:#fff;background:linear-gradient(95deg,#0b84bd,#096491)}.btn-nav:disabled{cursor:not-allowed;opacity:.45}.training-actions{display:flex;flex-wrap:wrap;justify-content:center;gap:52px;margin-top:36px}.training-actions :deep(.training-action){min-width:172px;height:64px;padding:0 28px;border:1px solid #2c83aa;border-radius:6px;color:#dff7ff;background:rgba(13,64,88,.62);font-size:24px;font-weight:900}@media(max-width:1100px){.optimization{grid-template-columns:1fr}}@media(max-width:900px){.optimization{padding:14px}.training-core{padding:18px;border-radius:24px}.workbench-head,.workbench-actions,.step-navigation{flex-direction:column;align-items:stretch}.workbench-actions,.outline-action,.btn-nav,.training-actions :deep(.training-action){width:100%}.step-indicator{flex-direction:column;border-radius:26px}.step-tab{flex:none}}
.optimization {
.workbench-actions { margin-left: auto; }
.step-indicator { display:flex; align-items:center; gap:12px; width:calc(100% + 24px); min-height:0; margin:0 -12px 32px; padding:18px 22px; overflow-x:auto; border:1px solid rgba(0,180,255,.3); border-radius:18px; background:linear-gradient(110deg,rgba(4,39,58,.9),rgba(2,20,33,.94)); box-shadow:inset 0 1px 0 rgba(169,229,255,.08); }
.step-tab { display:flex; flex:1 0 145px; align-items:center; min-height:66px; padding:8px 10px; border:1px solid rgba(68,141,177,.35); border-radius:8px; color:#9ab3d0; background:rgba(3,27,43,.52); text-align:left; transition:transform .2s,color .2s,background .2s,border-color .2s,box-shadow .2s; }
.step-tab:hover,.step-tab:focus-visible { transform:translateY(-2px); border-color:rgba(89,223,255,.82); color:#fff; background:rgba(8,92,131,.52); box-shadow:0 8px 18px rgba(0,0,0,.2); outline:none; }
.step-tab.active { border-color:rgba(89,226,255,.85); color:#fff; background:linear-gradient(95deg,#0a6b9e,#0a5c86); box-shadow:0 8px 20px rgba(0,120,170,.22); }
.step-marker { display:inline-flex; flex:0 0 38px; align-items:center; justify-content:center; width:38px; height:38px; border:1px solid rgba(113,223,255,.55); border-radius:50%; color:#bdefff; background:rgba(3,27,43,.72); font-size:13px; font-weight:900; }
.step-copy { display:grid; min-width:0; gap:4px; margin-left:10px; }.step-copy strong { display:-webkit-box; overflow:hidden; color:inherit; font-size:14px; line-height:1.25; -webkit-box-orient:vertical; -webkit-line-clamp:2; }.step-copy span { color:#7ec7e7; font-size:12px; font-weight:700; }.step-tab.active .step-marker { border-color:#a8f3ff; color:#fff; background:linear-gradient(135deg,#0bb2ed,#0a6b9e); box-shadow:0 0 0 4px rgba(77,215,255,.13),0 0 18px rgba(77,215,255,.4); }.step-tab.complete .step-marker { border-color:#59e0ca; color:#dffbff; background:rgba(13,132,119,.5); }.step-connector { flex:0 0 28px; height:2px; background:rgba(77,167,220,.54); color:#78cfe9; font-size:16px; line-height:0; text-align:right; }.step-connector.complete { background:#55ddcc; color:#75f0df; }
function createRow(source = {}) { return { id: source.id || `${Date.now()}-${Math.random().toString(16).slice(2)}`, categoryName: source.categoryName ?? "", revenue: source.revenue ?? "", revenueShare: source.revenueShare ?? "", cumulativeShare: source.cumulativeShare ?? "", inventoryCapital: source.inventoryCapital ?? "", capitalOccupancyRate: source.capitalOccupancyRate ?? "", sellThroughRate: source.sellThroughRate ?? "", grossMargin: source.grossMargin ?? "", abcClassification: source.abcClassification ?? "" }; }
function createStepFour(source = {}) { return { productName: source.productName ?? "", matrixQuadrant: source.matrixQuadrant ?? "", matrixReason: source.matrixReason ?? "", necessityAnalysis: source.necessityAnalysis ?? "", recommendedClassification: source.recommendedClassification ?? "", trafficSupportMeasures: source.trafficSupportMeasures ?? "" }; }
function createStrategyRows(savedRows = []) { const saved = new Map((Array.isArray(savedRows) ? savedRows : []).map((row) => [row?.layer, row])); return [{ layer: "A", label: "A类核心品类", operationPlaceholder: "例如:重点投入流量与推广预算,持续优化转化率", inventoryPlaceholder: "例如:安全库存上限,高频补货,优先保障供货稳定" }, { layer: "B", label: "B类潜力品类", operationPlaceholder: "例如:开展AB测试,针对潜力品类配置流量扶持", inventoryPlaceholder: "例如:密切关注运营效果,活动前适当备货" }, { layer: "C", label: "C类长尾品类", operationPlaceholder: "例如:控制流量预算,通过组合活动逐步清退滞销SKU", inventoryPlaceholder: "例如:停止主动补货,清退滞销SKU" }, { layer: "NEW", label: "新产品", operationPlaceholder: "例如:设置新品测试活动,重点推广运营", inventoryPlaceholder: "例如:提前备货,根据渠道测试情况重点备货" }].map((row) => ({ ...row, operationStrategy: saved.get(row.layer)?.operationStrategy ?? "", inventoryStrategy: saved.get(row.layer)?.inventoryStrategy ?? "", resourcePriority: saved.get(row.layer)?.resourcePriority ?? "" })); }
function normalizeActiveStep(value) { return !configuredSteps.value.length ? 1 : Math.min(Math.max(Number(value) || 1, 1), configuredSteps.value.length); }
function buildProgress() { return { version: 1, activeStep: activeStep.value, activeModule: activeModule.value, categoryRows: categoryRows.value.map(({ abcClassification, ...row }) => ({ ...row })), updatedAt: new Date().toISOString() }; }
function buildStepTwoProgress() { return { version: 1, rows: categoryRows.value.map((row) => ({ id: row.id, abcClassification: row.abcClassification || "" })), updatedAt: new Date().toISOString() }; }
function buildStepThreeProgress() { return { version: 1, imageUrl: paretoImage.value.url, imageName: paretoImage.value.name, updatedAt: new Date().toISOString() }; }
function buildStepFourProgress() { return { version: 1, ...stepFour.value, updatedAt: new Date().toISOString() }; }
function buildStepFiveProgress() { return { version: 1, rows: strategyRows.value.map(({ layer, operationStrategy, inventoryStrategy, resourcePriority }) => ({ layer, operationStrategy, inventoryStrategy, resourcePriority })), updatedAt: new Date().toISOString() }; }
function applyProgress(value) {
if (!value || typeof value !== "object") return;
activeStep.value = normalizeActiveStep(value.activeStep);
activeModule.value = Math.min(Math.max(Number(value.activeModule) || 0, 0), modules.length - 1);
categoryRows.value = Array.isArray(value.categoryRows) ? value.categoryRows.slice(0, MAX_ROWS).map(createRow) : [];
}
@media (max-width:1100px) { .optimization { grid-template-columns:1fr; } } @media (max-width:900px) { .optimization { padding:14px; .training-core { padding:18px; border-radius:24px; }.workbench-head,.workbench-actions,.step-navigation { flex-direction:column; align-items:stretch; }.workbench-actions,.outline-action,.btn-nav,.training-actions :deep(.training-action) { width:100%; }.step-indicator { align-items:stretch; flex-direction:column; width:100%; margin-right:0; margin-left:0; overflow:visible; border-radius:20px; }.step-tab { flex:0 0 auto; width:100%; }.step-connector { flex:0 0 22px; width:2px; height:22px; margin-left:29px; text-align:center; writing-mode:vertical-rl; }.training-actions { gap:14px; } } }
.optimization .step-indicator { display:flex; align-items:center; gap:0; width:calc(100% + 24px); min-height:0; margin:0 -12px 32px; padding:18px 22px; overflow-x:auto; border:1px solid rgba(0,180,255,.3); border-radius:18px; background:linear-gradient(110deg,rgba(4,39,58,.9),rgba(2,20,33,.94)); box-shadow:inset 0 1px 0 rgba(169,229,255,.08); }
.optimization .step-tab { display:flex; flex:1 0 145px; min-width:0; align-items:center; min-height:66px; padding:8px 10px; border:1px solid rgba(68,141,177,.35); border-radius:8px; color:#9ab3d0; background:rgba(3,27,43,.52); text-align:left; }
.optimization .step-tab.active { border-color:rgba(89,226,255,.85); color:#fff; background:linear-gradient(115deg,rgba(4,109,155,0.78),rgba(5,62,102,0.64)); box-shadow:0 0 18px rgba(24,183,235,.22),inset 0 1px 0 rgba(176,246,255,.2); }
.optimization .step-marker { display:inline-flex; flex:0 0 38px; align-items:center; justify-content:center; width:38px; height:38px; border:1px solid rgba(113,223,255,.55); border-radius:50%; color:#bdefff; background:rgba(3,27,43,.72); font-size:13px; font-weight:900; }
.optimization .step-copy { display:grid; min-width:0; gap:4px; margin-left:10px; }.optimization .step-copy strong { display:-webkit-box; overflow:hidden; color:inherit; font-size:14px; line-height:1.25; -webkit-box-orient:vertical; -webkit-line-clamp:2; }.optimization .step-action { color:#7ec7e7; font-size:12px; font-weight:700; }
.optimization .step-tab.active .step-marker { border-color:#72e8ff; color:#fff; background:linear-gradient(135deg,#08b7e8,#1375d0); box-shadow:0 0 0 5px rgba(25,179,237,.14),0 0 18px rgba(46,208,255,.58); }.optimization .step-tab.complete { color:#dffbff; }.optimization .step-tab.complete .step-marker { border-color:rgba(84,230,221,.85); color:#062b37; background:#6ef0e1; box-shadow:0 0 12px rgba(79,231,218,.35); }
.optimization .step-connector { position:relative; flex:0 1 40px; min-width:18px; height:2px; margin:0 8px; overflow:visible; background:rgba(77,167,220,.54); }.optimization .step-connector::after { position:absolute; top:50%; right:-1px; width:6px; height:6px; border-top:1px solid currentColor; border-right:1px solid currentColor; color:#78cfe9; content:""; transform:translateY(-50%) rotate(45deg); }.optimization .step-tab.complete + .step-connector { background:linear-gradient(90deg,#6ef0e1,#1cb8e9); box-shadow:0 0 9px rgba(53,218,235,.46); }.optimization .step-tab.complete + .step-connector::after { color:#63e6f1; }
@media (max-width:900px) { .optimization .step-indicator { align-items:stretch; flex-direction:column; width:100%; margin-right:0; margin-left:0; overflow:visible; border-radius:20px; }.optimization .step-tab { flex:0 0 auto; width:100%; }.optimization .step-connector { flex:0 0 20px; min-width:0; width:2px; height:20px; margin:0 0 0 29px; }.optimization .step-connector::after { top:auto; right:50%; bottom:-1px; transform:translateX(50%) rotate(135deg); } }
.workbench-head--actions-only {
justify-content: flex-end;
margin-bottom: 18px;
padding-bottom: 0;
border-bottom: 0;
function applyStepTwoProgress(value) { const classifications = new Map((Array.isArray(value?.rows) ? value.rows : []).map((row) => [row?.id, row?.abcClassification || ""])); categoryRows.value.forEach((row) => { row.abcClassification = classifications.get(row.id) || ""; }); }
function applyStepThreeProgress(value) { paretoImage.value = { url: String(value?.imageUrl || ""), name: String(value?.imageName || "") }; }
function applyStepFourProgress(value) { stepFour.value = createStepFour(value); }
function applyStepFiveProgress(value) { strategyRows.value = createStrategyRows(value?.rows); }
async function persistProgress(saveAction = "SAVE") { await saveStudentTrainingAnswer(TASK_KEY, { saveAction, currentStep: activeStep.value, step1Answer: JSON.stringify(buildProgress()), step2Answer: JSON.stringify(buildStepTwoProgress()), step3Answer: JSON.stringify(buildStepThreeProgress()), step4Answer: JSON.stringify(buildStepFourProgress()), step5Answer: JSON.stringify(buildStepFiveProgress()) }); }
function addRow() { if (categoryRows.value.length >= MAX_ROWS) return; categoryRows.value.push(createRow()); }
function removeLastRow() { categoryRows.value.pop(); }
async function validateStepOne() {
try {
const response = await validateCategoryOptimizationStepOne({ rows: categoryRows.value });
if (response?.data?.valid === false) throw new Error(response.data.message || "数据校验未通过");
return true;
} catch (error) {
ElMessage.error(error?.message || error?.msg || "数据校验未通过,请检查填写内容");
return false;
}
}
async function validateStepTwo() {
try {
const rows = categoryRows.value.map((row) => ({ categoryName: row.categoryName, revenue: row.revenue, revenueShare: row.revenueShare, cumulativeShare: row.cumulativeShare, abcClassification: row.abcClassification }));
const response = await validateCategoryOptimizationStepTwo({ rows });
if (response?.data?.valid === false) throw new Error(response.data.message || "ABC分层校验未通过");
return true;
} catch (error) {
ElMessage.error(error?.message || error?.msg || "ABC分层校验未通过,请检查填写内容");
return false;
}
}
async function validateStepThree() {
try {
const response = await validateCategoryOptimizationStepThree({ imageUrl: paretoImage.value.url });
if (response?.data?.valid === false) throw new Error(response.data.message || "帕累托图校验未通过");
return true;
} catch (error) {
ElMessage.error(error?.message || error?.msg || "帕累托图校验未通过,请上传图片");
return false;
}
}
async function handleParetoUpload(event) {
const file = event?.target?.files?.[0];
if (event?.target) event.target.value = "";
if (!file) return;
if (!file.type?.startsWith("image/")) { ElMessage.error("请上传图片格式的帕累托图"); return; }
uploadingPareto.value = true;
try {
let url = "";
if (isStudentDemo()) url = URL.createObjectURL(file);
else { const formData = new FormData(); formData.append("file", file); const response = await uploadStudentTrainingFile(formData); url = response?.url || response?.data?.url || ""; }
if (!url) throw new Error("帕累托图上传失败,请重试");
removeParetoImage();
paretoImage.value = { url, name: file.name };
ElMessage.success("帕累托图已上传,可点击预览");
} catch (error) { ElMessage.error(error?.message || "帕累托图上传失败,请重试"); } finally { uploadingPareto.value = false; }
}
function removeParetoImage() { if (paretoImage.value.url.startsWith("blob:")) URL.revokeObjectURL(paretoImage.value.url); paretoImage.value = { url: "", name: "" }; }
function handleProductChange() { if (stepFour.value.recommendedClassification === selectedProduct.value?.abcClassification) stepFour.value.recommendedClassification = ""; }
async function validateStepFour() {
try {
const response = await validateCategoryOptimizationStepFour({ ...stepFour.value, availableRows: categoryRows.value });
if (response?.data?.valid === false) throw new Error(response.data.message || "新品分层优化校验未通过");
return true;
} catch (error) { ElMessage.error(error?.message || error?.msg || "新品分层优化校验未通过,请检查填写内容"); return false; }
}
async function validateStepFive() {
try { const response = await validateCategoryOptimizationStepFive({ rows: strategyRows.value }); if (response?.data?.valid === false) throw new Error(response.data.message || "差异化运营策略校验未通过"); return true; } catch (error) { ElMessage.error(error?.message || error?.msg || "差异化运营策略校验未通过,请检查填写内容"); return false; }
}
async function setCurrentStep(step) {
const nextStep = normalizeActiveStep(step);
if (activeStep.value === 1 && nextStep > 1 && !(await validateStepOne())) return;
if ((activeStep.value === 2 && nextStep > 2 || activeStep.value === 1 && nextStep > 2) && !(await validateStepTwo())) return;
if ((activeStep.value === 3 && nextStep > 3 || activeStep.value === 2 && nextStep > 3 || activeStep.value === 1 && nextStep > 3) && !(await validateStepThree())) return;
if ((activeStep.value === 4 && nextStep > 4 || activeStep.value === 3 && nextStep > 4 || activeStep.value === 2 && nextStep > 4 || activeStep.value === 1 && nextStep > 4) && !(await validateStepFour())) return;
if ((activeStep.value === 5 && nextStep > 5 || activeStep.value === 4 && nextStep > 5 || activeStep.value === 3 && nextStep > 5 || activeStep.value === 2 && nextStep > 5 || activeStep.value === 1 && nextStep > 5) && !(await validateStepFive())) return;
activeStep.value = nextStep;
persistProgress("SAVE").catch(() => {});
}
function setActiveModule(index) { activeModule.value = index; persistProgress("SAVE").catch(() => {}); }
async function goNextStep() { await setCurrentStep(activeStep.value + 1); }
async function saveCurrentProgress() {
if (saving.value) return;
saving.value = true;
try { await persistProgress(); ElMessage.success("当前进度已保存"); } finally { saving.value = false; }
}
async function submitTask() {
if (saving.value || !(await validateStepOne()) || (hasConfiguredSteps.value && configuredSteps.value.length > 1 && !(await validateStepTwo())) || (hasConfiguredSteps.value && configuredSteps.value.length > 2 && !(await validateStepThree())) || (hasConfiguredSteps.value && configuredSteps.value.length > 3 && !(await validateStepFour())) || (hasConfiguredSteps.value && configuredSteps.value.length > 4 && !(await validateStepFive()))) return;
saving.value = true;
try { await businessComponent.value?.submitData?.(); await persistProgress("SAVE"); ElMessage.success("任务已提交"); } finally { saving.value = false; }
}
async function resetTask() {
if (saving.value) return;
categoryRows.value = [];
removeParetoImage();
stepFour.value = createStepFour();
strategyRows.value = createStrategyRows();
activeStep.value = 1;
activeModule.value = 0;
workspaceKey.value += 1;
saving.value = true;
try { await persistProgress("RESET"); ElMessage.success("填写内容已清空"); } finally { saving.value = false; }
}
function exportOutcome() {
const blob = new Blob([JSON.stringify(buildProgress(), 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);
}
onMounted(async () => {
try { const res = await getTrainingTaskByKey(TASK_KEY); taskConfig.value = res?.data || null; activeStep.value = normalizeActiveStep(activeStep.value); } catch (error) { taskConfig.value = null; }
try { const res = await getStudentTrainingAnswer(TASK_KEY); if (res?.data?.step1Answer) applyProgress(JSON.parse(res.data.step1Answer)); if (res?.data?.step2Answer) applyStepTwoProgress(JSON.parse(res.data.step2Answer)); if (res?.data?.step3Answer) applyStepThreeProgress(JSON.parse(res.data.step3Answer)); if (res?.data?.step4Answer) applyStepFourProgress(JSON.parse(res.data.step4Answer)); if (res?.data?.step5Answer) applyStepFiveProgress(JSON.parse(res.data.step5Answer)); } catch (error) {}
});
</script>
<style scoped lang="scss">
.optimization { --ink:#edfaff; --muted:#9ec0cf; --line:rgba(99,217,255,.27); --accent:#27c7f2; display:grid; grid-template-columns:minmax(0,1fr) 340px; gap:24px; min-height:calc(100vh - 76px); padding:24px; color:var(--ink); background:radial-gradient(circle at 8% 0%,rgba(28,164,211,.18),transparent 32rem),linear-gradient(135deg,#061923,#082633 46%,#061923); }
.training-core { min-width:0; padding:28px; border:1px solid var(--line); border-radius:28px; background:rgba(5,25,37,.82); box-shadow:0 22px 48px rgba(0,0,0,.22); }.task-header{margin-bottom:20px}.task-badge{display:inline-flex;align-items:center;gap:8px;padding:5px 12px;border:1px solid rgba(99,217,255,.55);border-radius:999px;color:#dff5ff;background:rgba(17,126,178,.3);font-size:12px;font-weight:800}.task-header h1{margin:10px 0 0;font-size:30px}.task-card{margin-top:24px;overflow:hidden;border:1px solid var(--line);border-radius:20px;background:rgba(3,22,34,.78)}
.workbench-head{display:flex;justify-content:space-between;gap:18px;padding:22px 24px;border-bottom:1px solid var(--line)}.section-eyebrow{margin:0;color:#71dfff;font-size:12px;font-weight:800;letter-spacing:.12em}.workbench-head h2{margin:5px 0 0;font-size:24px}.workbench-actions,.table-toolbar>div,.step-navigation,.training-actions{display:flex;flex-wrap:wrap;align-items:center;gap:10px}.workbench-actions{justify-content:flex-end}.outline-action,.action-button,.nav-button{display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:40px;padding:0 16px;border:1px solid rgba(99,217,255,.5);border-radius:9px;color:#e9fbff;background:rgba(17,126,178,.34);font:inherit;font-weight:700;cursor:pointer}.outline-action:hover,.action-button:hover,.nav-button:hover{border-color:#7deaff;background:rgba(20,148,193,.48)}button:disabled{cursor:not-allowed;opacity:.45}
.step-indicator{display:flex;gap:9px;padding:16px 24px;border-bottom:1px solid var(--line);overflow-x:auto}.step-tab{display:inline-flex;align-items:center;gap:8px;min-width:max-content;padding:8px 12px;border:1px solid var(--line);border-radius:999px;color:var(--muted);background:transparent;font:inherit;cursor:pointer}.step-tab span{display:grid;width:21px;height:21px;place-items:center;border-radius:50%;color:#bdefff;background:rgba(16,117,154,.35);font-size:11px;font-weight:800}.step-tab.active{border-color:#71dfff;color:white;background:rgba(12,114,159,.48)}.step-tab.complete{color:#d5fff8}.step-tab.complete span{background:#2ba995}
.category-step{padding:24px}.instruction{margin:0 0 20px;color:#d8edf5;font-size:15px;line-height:1.75}.table-toolbar{display:flex;justify-content:space-between;gap:14px;margin-bottom:12px;color:var(--muted);font-size:13px}.action-button--muted{border-color:rgba(158,192,207,.35);background:rgba(75,101,112,.25)}.category-table-wrap{overflow-x:auto;border:1px solid var(--line);border-radius:12px}.category-table{width:100%;min-width:1260px;border-collapse:collapse}.category-table th,.category-table td{padding:10px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);text-align:center}.category-table th{color:#e6f9ff;background:rgba(23,112,150,.42);font-size:13px;white-space:nowrap}.category-table td{background:rgba(2,18,28,.32)}.category-table tr:last-child td{border-bottom:0}.category-table th:last-child,.category-table td:last-child{border-right:0}.empty-cell{height:88px;color:var(--muted)}.abc-table{min-width:900px}.abc-table :deep(.el-select){width:100%;min-width:180px}
.rule-notice{margin-top:20px;padding:16px 18px;border:1px solid rgba(255,112,112,.46);border-radius:12px;background:rgba(126,31,35,.15);color:#ffd5d5}.rule-notice strong{color:#ff9292}.rule-notice p{margin:8px 0 0;line-height:1.65}.analysis-step{padding:24px}.analysis-tabs{display:flex;gap:10px;margin-bottom:18px}.analysis-tabs button{padding:9px 14px;border:1px solid var(--line);border-radius:8px;color:var(--muted);background:transparent;font:inherit;cursor:pointer}.analysis-tabs button.active{color:white;border-color:#71dfff;background:rgba(12,114,159,.48)}.business-workspace{overflow:hidden;border:1px solid var(--line);border-radius:14px}.step-navigation{justify-content:center;padding:20px 24px 0}.nav-button--save,.nav-button--primary{border-color:#71dfff;background:linear-gradient(100deg,#0d80b7,#09638f)}.training-actions{justify-content:center;gap:28px;padding:26px 24px}.training-actions :deep(.training-action){min-width:160px;height:50px;border-color:#2c83aa;color:#dff7ff;background:rgba(13,64,88,.62);font-size:17px;font-weight:800}
.pareto-upload-panel{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;min-height:330px;padding:26px;border:1px dashed rgba(113,223,255,.7);border-radius:16px;background:linear-gradient(135deg,rgba(5,47,69,.7),rgba(4,23,35,.58));text-align:center}.file-input{display:none}.upload-icon{font-size:42px;color:#71dfff}.pareto-upload-panel>strong{font-size:20px}.pareto-upload-panel>span,.upload-file-meta span{color:var(--muted);font-size:13px}.pareto-preview{width:min(100%,760px);height:270px;border:1px solid var(--line);border-radius:10px;background:rgba(0,10,18,.65);cursor:zoom-in}.upload-file-meta{display:grid;gap:5px;max-width:100%;word-break:break-all}.pareto-actions{display:flex;flex-wrap:wrap;justify-content:center;gap:10px}.danger-action{border-color:rgba(255,145,145,.6);color:#ffd7d7;background:rgba(130,43,43,.23)}
.strategy-step{display:grid;gap:16px}.subsection-heading{margin:8px 0 0;font-size:18px}.subsection-copy{margin:-8px 0 0;color:var(--muted);line-height:1.6}.category-matrix{position:relative;width:min(100%,820px);height:430px;margin:12px auto 22px;padding:38px 52px 52px 78px}.matrix-grid{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;width:100%;height:100%;border-left:2px solid rgba(185,240,255,.82);border-bottom:2px solid rgba(185,240,255,.82);background:linear-gradient(135deg,rgba(24,169,209,.12),rgba(3,25,38,.16))}.matrix-cell{display:grid;place-content:center;gap:7px;padding:18px;border-right:1px solid rgba(185,240,255,.65);border-bottom:1px solid rgba(185,240,255,.65);text-align:center}.matrix-cell:nth-child(2n){border-right:0}.matrix-cell:nth-child(n+3){border-bottom:0}.matrix-cell strong{font-size:18px}.matrix-cell small{color:#8dddf5}.matrix-y-title,.matrix-x-title,.matrix-y-high,.matrix-y-low,.matrix-x-high,.matrix-x-low{position:absolute;color:#d9f8ff;font-weight:800}.matrix-y-title{top:5px;left:78px}.matrix-x-title{right:0;bottom:15px}.matrix-y-high{top:63px;left:39px}.matrix-y-low{bottom:61px;left:39px}.matrix-x-low{bottom:29px;left:79px}.matrix-x-high{right:44px;bottom:29px}.matrix-inputs{display:grid;grid-template-columns:minmax(210px,.42fr) 1fr;gap:16px}.matrix-inputs label{display:grid;gap:8px;color:#d9f8ff;font-weight:700}.strategy-table{min-width:1120px}.strategy-table td{vertical-align:top}.strategy-table :deep(.el-select){width:100%;min-width:160px}.strategy-table :deep(.el-textarea__inner){min-height:78px}.current-classification{display:inline-block;padding:8px 10px;border-radius:8px;color:#a5efff;background:rgba(17,126,178,.24);white-space:nowrap}
.operations-table{min-width:1050px}.operations-table th{color:#fff;background:linear-gradient(100deg,#08a6df,#098bc1);font-size:16px}.operations-table td{vertical-align:middle}.operations-table td:first-child{width:17%;font-weight:800}.operations-table td:nth-child(2){width:34%}.operations-table td:nth-child(3){width:25%}.operations-table td:last-child{width:24%}.operations-table :deep(.el-textarea__inner){min-height:86px;text-align:left}.operations-table :deep(.el-select){width:100%;min-width:160px}.layer-cell{color:#dff8ff;font-size:16px;background:rgba(15,103,137,.18)!important}
:deep(.el-input__wrapper){background:rgba(1,15,25,.76);box-shadow:0 0 0 1px rgba(94,213,245,.28) inset}:deep(.el-input__inner){color:#fff;text-align:center}@media(max-width:1100px){.optimization{grid-template-columns:1fr}}@media(max-width:720px){.optimization{padding:12px}.training-core{padding:16px;border-radius:20px}.workbench-head,.table-toolbar{flex-direction:column;align-items:stretch}.workbench-actions,.table-toolbar>div,.step-navigation,.training-actions{justify-content:stretch}.workbench-actions>*,.action-button,.nav-button,.training-actions :deep(.training-action){flex:1;width:100%}.category-step,.analysis-step{padding:16px}}
</style>

@ -8,121 +8,92 @@
<nav v-if="hasConfiguredSteps" class="step-indicator" aria-label="采购需求预测步骤"><template v-for="(step,index) in configuredSteps" :key="`${step}-${index}`"><button type="button" :class="['step-tab',{active:activeStep===index+1,complete:activeStep>index+1}]" :aria-current="activeStep===index+1?'step':undefined" :aria-label="`第${index+1}步:${step}`" @click="setCurrentStep(index+1)"><span class="step-marker">{{ String(index+1).padStart(2,'0') }}</span><span class="step-copy"><strong class="step-name" :title="step">{{ step }}</strong><span class="step-action">{{ activeStep===index+1?'当前步骤':'点击切换' }} <span aria-hidden="true">→</span></span></span></button><span v-if="index<configuredSteps.length-1" class="step-connector" aria-hidden="true"></span></template></nav>
<section v-show="isSectionVisible(1)" class="step-card"><div class="step-title-row"><div><p class="section-eyebrow">Step {{ String(activeStep).padStart(2,'0') }}</p><h3>{{ activeStepName }}</h3></div></div>
<p style="font-size: 16px; font-weight: normal">季节指数预测法,亦称“温斯特法”,是根据时间序列中的数据资料所呈现的季节变动规律性,对预测目标未来状况作出预测的方法。它首先建立预测模型来求得历史上各期的趋势值,然后用实际值除以趋势值来求出季节指数。若是有两个以上的波动周期,还需要求出相同时期的季节指数平均值,再用它去修正预测值。</p>
<div class="caseBacktitle">
<img src="@/assets/images/步骤.png" alt="" />
<span>实验实训</span>
<p class="linear-forecast-intro">线性预测的核心是拟合趋势线(y=a+bt),是基于历史数据预测未来销量的有效方法。根据案例资料及数据,预测案例店铺产品未来 3 个月市场需求数据。</p>
<div class="linear-forecast-panel">
<el-table :data="linearForecastRows" border class="linear-forecast-table">
<el-table-column prop="label" label="预测月份" align="center" width="310" />
<el-table-column label="预测案例店铺市场需求量(取整)" align="center">
<template #default="{ row }">
<el-input-number v-model="row.demand" :min="0" :precision="0" :step="1" :controls="false" placeholder="请输入非负整数" class="linear-forecast-input" />
</template>
</el-table-column>
</el-table>
</div>
<p>假设某电商平台店铺主营夏季产品,如泳装、泳帽、泳镜、游泳圈等产品,这类产品具有明显的季节波动性,公司需要预测未来12个月的采购需求,请使用季节指数预测法完成任务。</p>
<p>
应用步骤
<br />
数据收集:收集历史采购数据,确保数据覆盖多个完整的季节周期。
<br />
数据预处理:清理数据,处理缺失值和异常值。
<br />
计算移动平均值:计算每个时间点的移动平均值,以平滑数据。
<br />
计算季节指数:通过将实际值除以移动平均值,计算每个月份的季节指数。
<br />
预测未来需求:利用季节指数和趋势数据预测未来的采购需求。
<br />
</p>
<div class="online">
<span>1.数据准备</span>
<el-button type="primary" class="ml50" @click="downloadData">数据下载</el-button>
<section class="linear-forecast-guide">
<div>
<p class="guide-title">可参考以下方法:</p>
<ol>
<li>下载案例数据表,使用 Excel 中的 FORECAST.LINEAR 函数(WPS 为 FORECAST 函数),自行预测未来 3 个月市场需求。</li>
<li>导出实训数据表,上传到通用 AI 大模型,通过大模型计算结果。参考提示词如下:请根据 12 个月销量数据,利用线性预测法预测未来三个月的销量数据,给出计算过程。</li>
</ol>
</div>
<p>公司2022到2024年三年期间月度采购量如下所示:</p>
<el-table
:data="advertisementData"
border
style="width: 90%"
:header-cell-style="{
color: '#fff',
fontSize: '14px',
backgroundColor: '#6A7DE9!important',
}"
>
<el-table-column prop="year" label="年份" align="center"></el-table-column>
<el-table-column prop="jan" label="1月" align="center"></el-table-column>
<el-table-column prop="feb" label="2月" align="center"></el-table-column>
<el-table-column prop="mar" label="3月" align="center"></el-table-column>
<el-table-column prop="apr" label="4月" align="center"></el-table-column>
<el-table-column prop="may" label="5月" align="center"></el-table-column>
<el-table-column prop="jun" label="6月" align="center"></el-table-column>
<el-table-column prop="jul" label="7月" align="center"></el-table-column>
<el-table-column prop="aug" label="8月" align="center"></el-table-column>
<el-table-column prop="sep" label="9月" align="center"></el-table-column>
<el-table-column prop="oct" label="10月" align="center"></el-table-column>
<el-table-column prop="nov" label="11月" align="center"></el-table-column>
<el-table-column prop="dec" label="12月" align="center"></el-table-column>
</el-table>
<p>
数据特征说明‌:
<br />
‌季节性规律‌
<br />
‌旺季(6-8月)‌:夏季需求高峰,采购量逐月攀升,7月达年度峰值(如2023年7月920件);
<br />
‌淡季(12-2月)‌:冬季需求低迷,采购量显著低于其他月份(如2024年1月140件)。
<br />
</p>
<p>
年度增长趋势‌
<br />
每年采购量整体增长约 ‌5%-10%‌,符合行业稳定扩张特征(如2023年6月较2022年增长8.3%);
<br />
冬季淡季增速较低(如2023年12月较2022年增长5.6%),夏季旺季增速较高(如2024年7月较2023年增长3.3%)。
<br />
</p>
<p>
过渡月份波动‌
<br />
春季(3-5月)和秋季(9-11月)为过渡期,采购量随气温变化逐步上升或下降(如4月采购量介于300-400件);
<br />
9月后需求快速回落,反映季节性商品换季特征(如2022年9月采购量环比下降33%)
<br />
</p>
<el-button type="primary" class="practice-guide-button" @click="downloadPracticeGuide">下载实训操作说明</el-button>
</section>
</section><section v-show="isSectionVisible(2)" class="step-card"><div class="step-title-row"><div><p class="section-eyebrow">Step {{ String(activeStep).padStart(2,'0') }}</p><h3>{{ activeStepName }}</h3></div></div>
<p>月度数据我们采用12期移动平均,计算简单移动平均值数据,计算结果取整:</p>
<el-table
:data="tableData"
border
style="width: 50%"
height="600"
:header-cell-style="{
color: '#fff',
fontSize: '14px',
backgroundColor: '#6A7DE9!important',
}"
>
<el-table-column prop="month" label="月份" align="center"></el-table-column>
<el-table-column prop="actual" label="实际采购量" align="center"></el-table-column>
<el-table-column prop="ma12" label="MA(12)12期移动平均值" align="center">
<p class="forecast-visual-intro">计算未来 3 个月需求量后,使用散点图 + 趋势线绘制需求量预测趋势图,呈现已知的 12 个月销量以及预测数据的整体走势。</p>
<section class="forecast-visual-upload-card">
<label v-if="!demandForecastChart.url" class="forecast-visual-dropzone" :class="{ 'is-uploading': uploadingDemandForecastChart }">
<input type="file" accept=".png,.jpg,.jpeg,.webp,image/png,image/jpeg,image/webp" :disabled="uploadingDemandForecastChart" @change="handleDemandForecastChartUpload" />
<span class="forecast-visual-upload-icon" aria-hidden="true">↥</span>
<strong>{{ uploadingDemandForecastChart ? "正在上传图形..." : "上传需求预测可视化图形" }}</strong>
<span>支持 PNG、JPG、JPEG、WEBP 格式,上传后可点击缩略图预览</span>
</label>
<div v-else class="forecast-visual-preview">
<el-image :src="demandForecastChart.url" :preview-src-list="[demandForecastChart.url]" :initial-index="0" fit="cover" preview-teleported />
<div class="forecast-visual-file-meta">
<strong>{{ demandForecastChart.name || "需求预测可视化图形" }}</strong>
<span>点击图形可放大预览</span>
<button type="button" class="forecast-visual-replace" @click="clearDemandForecastChart">重新上传</button>
</div>
</div>
</section>
<section class="forecast-visual-guide">
<div>
<p class="guide-title">可参考以下方法:</p>
<ol>
<li>使用 Excel/WPS 绘图功能实现可视化,可参考《实训操作说明》完成。</li>
<li>下载实训数据表,加上预测的三个月需求数据,表格整体数据上传到通用 AI 大模型,通过大模型计算结果生成可视化图形。参考提示词:请根据表格数据,生成数据趋势图,要求使用散点图加趋势图绘制数据走势。</li>
</ol>
</div>
<el-button type="primary" class="practice-guide-button" @click="downloadPracticeGuide">下载实训操作说明</el-button>
</section>
</section><section v-show="isSectionVisible(3)" class="step-card"><div class="step-title-row"><div><p class="section-eyebrow">Step {{ String(activeStep).padStart(2,'0') }}</p><h3>{{ activeStepName }}</h3></div></div>
<p class="procurement-plan-intro">基于步骤一预测的未来3个月销量,结合安全库存和期初库存,计算各月采购量,填报采购预测表。</p>
<section class="procurement-plan-rules">
<h4>采购规则</h4>
<ol>
<li>安全库存:按预测销量的 10% 预留,应对突发订单波动(安全库存 = 预测销量 × 10%,四舍五入取整)。</li>
<li>补货周期:按月采购,每月月初下达采购订单。</li>
<li>期初库存:第 12 月末剩余库存为 30 台,作为预测第 1 个月(t=13)的期初库存;此后各月期初库存为上月末剩余库存。</li>
</ol>
<p class="procurement-plan-formula">月度采购量 = 预测销量 + 安全库存 - 期初库存</p>
</section>
<div class="procurement-plan-table-wrap">
<el-table :data="procurementPlanRows" border class="procurement-plan-table">
<el-table-column prop="label" label="预测月份" align="center" min-width="220" />
<el-table-column label="预测销量(台)" align="center" min-width="160">
<template #default="scope">
<el-input v-model="scope.row.ma12" placeholder=""></el-input>
<span class="procurement-plan-readonly">{{ procurementPlanExpectedRows[scope.$index]?.demand ?? '请先完成第 1 步' }}</span>
</template>
</el-table-column>
</el-table>
</section><section v-show="isSectionVisible(3)" class="step-card"><div class="step-title-row"><div><p class="section-eyebrow">Step {{ String(activeStep).padStart(2,'0') }}</p><h3>{{ activeStepName }}</h3></div></div>
<p>计算季节指数,先用上述各月份实际值除以移动平均值,计算季节性不规则成分,然后按月份分组求均值,得出初版季节性指数,最后再用各月份初版季节性指数除以初版季节性指数的均值,得出标准化季节性指数,请在下表填写标准化季节性指数(结果保留3位小数):</p>
<el-table
:data="tableData1"
border
style="width: 50%"
:header-cell-style="{
color: '#fff',
fontSize: '14px',
backgroundColor: '#6A7DE9!important',
}"
>
<el-table-column prop="month" label="月份" align="center"></el-table-column>
<el-table-column prop="index" label="标准化季节性指数" align="center">
<el-table-column label="安全库存(台)" align="center" min-width="165">
<template #default="scope">
<el-input v-model="scope.row.index" placeholder=""></el-input>
<el-input-number v-model="scope.row.safetyStock" :min="0" :precision="0" :controls="false" class="procurement-plan-input" placeholder="请输入" />
</template>
</el-table-column>
<el-table-column label="期初库存(台)" align="center" min-width="165">
<template #default="scope">
<el-input-number v-model="scope.row.openingStock" :min="0" :precision="0" :controls="false" class="procurement-plan-input" placeholder="请输入" />
</template>
</el-table-column>
<el-table-column label="采购量(台)" align="center" min-width="165">
<template #default="scope">
<el-input-number v-model="scope.row.purchaseQuantity" :precision="0" :controls="false" class="procurement-plan-input" placeholder="请输入" />
</template>
</el-table-column>
</el-table>
</div>
<p class="procurement-plan-hint">预测销量由第 1 步同步带入。提交时将按题设规则校验安全库存、期初库存和采购量。</p>
</section><section v-show="isSectionVisible(4)" class="step-card"><div class="step-title-row"><div><p class="section-eyebrow">Step {{ String(activeStep).padStart(2,'0') }}</p><h3>{{ activeStepName }}</h3></div></div>
<p>未来需求预测,使用线性回归模型预测2025年采购量:</p>
<el-table
@ -160,7 +131,7 @@
</pop-model>
</section>
</main>
<TrainingAiSidebar study-tip="当前任务建议:依次完成移动平均、季节指数和未来采购量预测,再使用图表检查预测趋势。" :progress-items="progressItems" />
<TrainingAiSidebar study-tip="当前任务建议:先完成未来 3 个月需求预测,再上传包含历史销量与预测值的散点趋势图。" :progress-items="progressItems" />
</div>
</template>
<script setup>
@ -174,8 +145,9 @@ import * as echarts from "echarts";
import useUserStore from "@/store/modules/user";
import { ArrowLeft, ArrowRight, CircleCheck, Download, TrendCharts } from "@element-plus/icons-vue";
import { getTrainingTaskByKey } from "@/api/trainingTask";
import { getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
import { getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile, validatePurchaseDemandForecastStepOne } from "@/api/studentTrainingAnswer";
import { parseTrainingArray } from "@/views/training/taskKeyMap";
import { isStudentDemo } from "@/utils/studentDemo";
const userStore = useUserStore();
const dialogVisible = ref(false);
import codemirror from "@/components/codemirror/index.vue";
@ -398,6 +370,50 @@ const tableData1 = ref([
{ month: "11月", index: null },
{ month: "12月", index: null },
]);
const createLinearForecastRows = () => [
{ time: 13, label: "预测第1个月(t=13)", demand: null },
{ time: 14, label: "预测第2个月(t=14)", demand: null },
{ time: 15, label: "预测第3个月(t=15)", demand: null },
];
const linearForecastRows = ref(createLinearForecastRows());
const PROCUREMENT_INITIAL_STOCK = 30;
const createProcurementPlanRows = () =>
createLinearForecastRows().map(({ time, label }) => ({
time,
label,
safetyStock: null,
openingStock: null,
purchaseQuantity: null,
}));
const procurementPlanRows = ref(createProcurementPlanRows());
const procurementPlanExpectedRows = computed(() => {
let expectedOpeningStock = PROCUREMENT_INITIAL_STOCK;
return linearForecastRows.value.map((forecastRow, index) => {
const demand = Number(forecastRow?.demand);
const hasValidDemand = Number.isInteger(demand) && demand >= 0;
const safetyStock = hasValidDemand ? Math.round(demand * 0.1) : null;
const openingStock = hasValidDemand ? expectedOpeningStock : null;
const purchaseQuantity = hasValidDemand
? demand + safetyStock - openingStock
: null;
if (hasValidDemand) {
expectedOpeningStock = safetyStock;
}
return {
time: forecastRow?.time ?? index + 13,
label: forecastRow?.label || `预测第${index + 1}个月(t=${index + 13})`,
demand: hasValidDemand ? demand : null,
safetyStock,
openingStock,
purchaseQuantity,
};
});
});
const demandForecastChart = ref({ url: "", name: "" });
const uploadingDemandForecastChart = ref(false);
const forecastData = ref([
{ month: "2025年01月", forecast: "" },
{ month: "2025年02月", forecast: "" },
@ -520,16 +536,96 @@ const drawForecastChart = () => {
// 响应式调整
window.addEventListener("resize", () => myChart.resize());
};
// 下载数据
const downloadData = () => {
window.open(`${import.meta.env.VITE_APP_BASE_API}api/cgxqyc/exportCgxqycData`);
};
function downloadPracticeGuide() {
const content = "采购需求预测实训操作说明\n\n1. 根据案例给出的 12 个月销量数据,使用 FORECAST.LINEAR(WPS 可使用 FORECAST)函数预测 t=13、t=14、t=15。\n2. 将每个月的预测结果取整后,填写到预测表中。\n3. 将已知 12 个月销量和未来 3 个月预测值制作成散点图,并添加趋势线。\n4. 导出图形后上传至第 2 步,确认图中能清晰呈现历史销量、预测值和整体走势。";
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "采购需求预测-实训操作说明.txt";
link.click();
URL.revokeObjectURL(url);
}
function clearDemandForecastChart() {
const imageUrl = String(demandForecastChart.value?.url || "");
if (imageUrl.startsWith("blob:")) URL.revokeObjectURL(imageUrl);
demandForecastChart.value = { url: "", name: "" };
}
async function handleDemandForecastChartUpload(event) {
const file = event?.target?.files?.[0];
if (event?.target) event.target.value = "";
if (!file) return;
const acceptedTypes = ["image/png", "image/jpeg", "image/webp"];
if (!acceptedTypes.includes(file.type)) {
proxy?.$modal?.msgWarning("请上传 PNG、JPG、JPEG 或 WEBP 格式的图片");
return;
}
if (file.size > 5 * 1024 * 1024) {
proxy?.$modal?.msgWarning("图片大小不能超过 5MB");
return;
}
uploadingDemandForecastChart.value = true;
try {
let url = "";
if (isStudentDemo()) {
url = URL.createObjectURL(file);
} else {
const formData = new FormData();
formData.append("file", file);
const response = await uploadStudentTrainingFile(formData);
url = response?.url || response?.data?.url || "";
if (!url) throw new Error("上传成功但未返回文件地址");
}
clearDemandForecastChart();
demandForecastChart.value = { url, name: file.name };
proxy?.$modal?.msgSuccess("需求预测可视化图形已上传,可点击缩略图预览");
} catch (error) {
proxy?.$modal?.msgError(error?.message || "上传失败,请稍后重试");
} finally {
uploadingDemandForecastChart.value = false;
}
}
function normalizeActiveStep(value) { if (!configuredSteps.value.length) return 1; return Math.min(Math.max(Number(value) || 1, 1), configuredSteps.value.length); }
function isSectionVisible(section) { return !hasConfiguredSteps.value || activeStep.value === section; }
function buildProgress() { return { activeStep: activeStep.value, tableData: tableData.value, tableData1: tableData1.value, forecastData: forecastData.value, updatedAt: new Date().toISOString() }; }
function buildProgress() {
return {
activeStep: activeStep.value,
linearForecastRows: linearForecastRows.value,
procurementPlanRows: procurementPlanRows.value,
demandForecastChart: { ...demandForecastChart.value },
tableData: tableData.value,
tableData1: tableData1.value,
forecastData: forecastData.value,
updatedAt: new Date().toISOString(),
};
}
function applyProgress(value) {
if (!value || typeof value !== "object") return;
activeStep.value = normalizeActiveStep(value.activeStep);
if (Array.isArray(value.linearForecastRows)) {
const demandByTime = new Map(value.linearForecastRows.map((item) => [Number(item?.time), item?.demand]));
linearForecastRows.value = createLinearForecastRows().map((row) => ({ ...row, demand: demandByTime.get(row.time) ?? null }));
}
if (Array.isArray(value.procurementPlanRows)) {
const planByTime = new Map(value.procurementPlanRows.map((item) => [Number(item?.time), item]));
procurementPlanRows.value = createProcurementPlanRows().map((row) => {
const savedRow = planByTime.get(row.time) || {};
return {
...row,
safetyStock: savedRow.safetyStock ?? null,
openingStock: savedRow.openingStock ?? null,
purchaseQuantity: savedRow.purchaseQuantity ?? null,
};
});
}
if (value.demandForecastChart && typeof value.demandForecastChart === "object") {
demandForecastChart.value = {
url: String(value.demandForecastChart.url || ""),
name: String(value.demandForecastChart.name || ""),
};
}
if (Array.isArray(value.tableData)) tableData.value = value.tableData;
if (Array.isArray(value.tableData1)) tableData1.value = value.tableData1;
if (Array.isArray(value.forecastData)) forecastData.value = value.forecastData;
@ -546,8 +642,88 @@ async function loadTaskConfig() { try { const res = await getTrainingTaskByKey(T
async function loadSavedProgress() { try { const res = await getStudentTrainingAnswer(TASK_KEY); const raw = res?.data?.step1Answer; if (raw) applyProgress(JSON.parse(raw)); } catch (error) { /* Historical progress is optional. */ } }
function exportOutcome() { const blob = new Blob([JSON.stringify(buildProgress(), 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); }
function closeCode() { runResultShow.value = false; }
function isInteger(value) {
return Number.isInteger(Number(value));
}
function validateProcurementPlan() {
const expectedRows = procurementPlanExpectedRows.value;
if (expectedRows.some((row) => row.demand === null)) {
proxy?.$modal?.msgWarning("请先完成第 1 步,填写未来 3 个月的预测销量");
return false;
}
for (let index = 0; index < expectedRows.length; index += 1) {
const actualRow = procurementPlanRows.value[index];
const expectedRow = expectedRows[index];
const monthName = `第 ${index + 1} 个月`;
if (
!isInteger(actualRow?.safetyStock)
|| !isInteger(actualRow?.openingStock)
|| !isInteger(actualRow?.purchaseQuantity)
) {
proxy?.$modal?.msgWarning(`请将${monthName}的安全库存、期初库存和采购量填写为整数`);
return false;
}
if (Number(actualRow.safetyStock) !== expectedRow.safetyStock) {
proxy?.$modal?.msgWarning(`${monthName}安全库存计算不正确,应为 ${expectedRow.safetyStock} 台`);
return false;
}
if (Number(actualRow.openingStock) !== expectedRow.openingStock) {
proxy?.$modal?.msgWarning(`${monthName}期初库存不正确,应为 ${expectedRow.openingStock} 台`);
return false;
}
if (Number(actualRow.purchaseQuantity) !== expectedRow.purchaseQuantity) {
proxy?.$modal?.msgWarning(`${monthName}采购量计算不正确,应为 ${expectedRow.purchaseQuantity} 台`);
return false;
}
}
return true;
}
// 提交
const submitTask = async () => {
if (activeStep.value === 1) {
if (saving.value) return;
saving.value = true;
try {
const response = await validatePurchaseDemandForecastStepOne({
items: linearForecastRows.value.map(({ time, demand }) => ({ time, demand })),
});
const normalizedItems = response?.data?.items;
if (Array.isArray(normalizedItems)) {
const demandByTime = new Map(normalizedItems.map((item) => [Number(item.time), item.demand]));
linearForecastRows.value = createLinearForecastRows().map((row) => ({ ...row, demand: demandByTime.get(row.time) ?? null }));
}
await persistProgress("SUBMIT");
} finally {
saving.value = false;
}
return;
}
if (activeStep.value === 2) {
if (!demandForecastChart.value.url) {
proxy?.$modal?.msgWarning("请上传需求预测可视化图形");
return;
}
if (saving.value) return;
saving.value = true;
try {
await persistProgress("SUBMIT");
} finally {
saving.value = false;
}
return;
}
if (activeStep.value === 3) {
if (!validateProcurementPlan() || saving.value) return;
saving.value = true;
try {
await persistProgress("SUBMIT");
} finally {
saving.value = false;
}
return;
}
// 获取monthlyData月度数据
const monthlyData = tableData.value.map((item) => item.ma12);
// 获取forecastData预测数据
@ -593,6 +769,9 @@ const resetTask = async () => {
if (saving.value) return;
saving.value = true;
try {
linearForecastRows.value = createLinearForecastRows();
procurementPlanRows.value = createProcurementPlanRows();
clearDemandForecastChart();
await analysisAPI.procurementDemandForecastReTrain({ userId: userStore.userInfo.userId });
// 清空对应数据
forecastData.value.forEach((item) => {
@ -777,4 +956,290 @@ onMounted(async () => {
padding-bottom: 0;
border-bottom: 0;
}
.linear-forecast-intro {
margin: 0 0 22px;
color: #d7ecff;
font-size: 16px;
line-height: 1.85;
}
.linear-forecast-panel {
overflow: hidden;
border-radius: 16px;
}
.linear-forecast-table {
min-width: 640px;
margin-top: 0 !important;
}
.linear-forecast-input {
width: min(100%, 320px);
}
.linear-forecast-guide {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
margin-top: 28px;
padding: 18px 20px;
border: 1px solid rgba(44, 141, 182, 0.3);
border-radius: 14px;
background: rgba(1, 17, 27, 0.3);
}
.linear-forecast-guide ol {
margin: 8px 0 0;
padding-left: 22px;
color: #b9d8e8;
line-height: 1.8;
}
.procurement-plan-intro {
margin: 0 0 18px;
color: #d7ecff;
font-size: 16px;
line-height: 1.85;
}
.procurement-plan-rules {
margin-bottom: 20px;
padding: 16px 20px;
border: 1px solid rgba(44, 141, 182, 0.3);
border-radius: 14px;
background: rgba(1, 17, 27, 0.3);
}
.procurement-plan-rules h4 {
margin: 0;
color: #e9faff;
font-size: 16px;
}
.procurement-plan-rules ol {
margin: 10px 0 8px;
padding-left: 22px;
color: #b9d8e8;
line-height: 1.8;
}
.procurement-plan-formula {
margin: 0;
color: #7ee6ff !important;
font-weight: 800;
}
.procurement-plan-table-wrap {
overflow-x: auto;
border-radius: 16px;
}
.procurement-plan-table {
min-width: 960px;
margin-top: 0 !important;
}
.procurement-plan-readonly {
color: #82e9ff;
font-weight: 800;
}
.procurement-plan-input {
width: min(100%, 160px);
}
.procurement-plan-hint {
margin: 14px 0 0;
color: #8eb8ca !important;
font-size: 14px !important;
line-height: 1.7;
}
.guide-title {
margin: 0;
color: #e9faff !important;
font-weight: 800;
}
.practice-guide-button {
flex: 0 0 auto;
}
.forecast-visual-intro {
margin: 0 0 22px;
color: #d7ecff;
font-size: 16px;
line-height: 1.85;
}
.forecast-visual-upload-card {
display: grid;
min-height: 330px;
padding: 28px;
border: 1px solid rgba(53, 194, 255, 0.25);
border-radius: 16px;
background: radial-gradient(circle at 50% 40%, rgba(18, 163, 220, 0.13), rgba(5, 25, 43, 0.62) 68%);
place-items: center;
}
.forecast-visual-dropzone {
position: relative;
display: grid;
width: min(100%, 620px);
min-height: 210px;
padding: 28px;
overflow: hidden;
border: 1px dashed rgba(79, 218, 255, 0.72);
border-radius: 14px;
color: #e4f6ff;
background: rgba(4, 38, 62, 0.58);
cursor: pointer;
place-items: center;
gap: 10px;
text-align: center;
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
}
.forecast-visual-dropzone:hover {
border-color: #84ebff;
background: rgba(9, 70, 103, 0.65);
box-shadow: 0 0 24px rgba(40, 204, 255, 0.16);
}
.forecast-visual-dropzone.is-uploading {
cursor: wait;
opacity: 0.72;
}
.forecast-visual-dropzone input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
.forecast-visual-dropzone strong {
font-size: 22px;
}
.forecast-visual-dropzone span:last-child {
color: #98c8dc;
font-size: 13px;
}
.forecast-visual-upload-icon {
color: #7ee6ff;
font-size: 34px;
font-weight: 900;
line-height: 1;
}
.forecast-visual-preview {
display: flex;
align-items: center;
width: min(100%, 760px);
min-height: 210px;
gap: 24px;
padding: 18px;
border: 1px solid rgba(83, 220, 255, 0.42);
border-radius: 14px;
background: rgba(4, 38, 62, 0.58);
}
.forecast-visual-preview :deep(.el-image) {
flex: 0 0 330px;
width: 330px;
height: 190px;
border: 1px solid rgba(106, 229, 255, 0.48);
border-radius: 10px;
background: #081722;
cursor: zoom-in;
}
.forecast-visual-file-meta {
display: flex;
flex-direction: column;
min-width: 0;
gap: 10px;
}
.forecast-visual-file-meta strong {
color: #e9faff;
line-height: 1.6;
word-break: break-all;
}
.forecast-visual-file-meta span {
color: #98c8dc;
font-size: 13px;
}
.forecast-visual-replace {
align-self: flex-start;
padding: 7px 13px;
border: 1px solid rgba(96, 221, 255, 0.56);
border-radius: 7px;
color: #dcf8ff;
background: rgba(10, 107, 151, 0.38);
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 700;
}
.forecast-visual-replace:hover {
border-color: #8deeff;
background: rgba(13, 133, 183, 0.52);
}
.forecast-visual-guide {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
margin-top: 28px;
padding: 18px 20px;
border: 1px solid rgba(44, 141, 182, 0.3);
border-radius: 14px;
background: rgba(1, 17, 27, 0.3);
}
.forecast-visual-guide ol {
margin: 8px 0 0;
padding-left: 22px;
color: #b9d8e8;
line-height: 1.8;
}
@media (max-width: 900px) {
.linear-forecast-guide {
align-items: stretch;
flex-direction: column;
}
.procurement-plan-rules {
padding: 14px 16px;
}
.forecast-visual-preview,
.forecast-visual-guide {
align-items: stretch;
flex-direction: column;
}
.forecast-visual-preview :deep(.el-image) {
flex-basis: auto;
width: 100%;
height: 190px;
}
.practice-guide-button {
width: 100%;
}
}
</style>

File diff suppressed because it is too large Load Diff

@ -1,40 +1,39 @@
<template>
<el-button class="material-download" :plain="!material?.url" @click="handleDownload">
<el-icon><Download /></el-icon>
下载案例资料
<div class="material-actions">
<el-button v-if="material && previewable" class="material-button" @click="previewVisible = true">
<el-icon><View /></el-icon>预览案例资料
</el-button>
<el-button class="material-button" :plain="!material?.url" @click="handleDownload">
<el-icon><Download /></el-icon>下载案例资料
</el-button>
</div>
<el-dialog v-model="previewVisible" :title="material?.name || '案例资料预览'" width="min(920px, 92vw)" destroy-on-close append-to-body>
<div v-if="isImage" class="image-preview"><img :src="material.url" :alt="material.name" /></div>
<iframe v-else-if="isPdf" class="pdf-preview" :src="material.url" :title="material.name"></iframe>
</el-dialog>
</template>
<script setup>
import { computed } from "vue";
import { computed, ref } from "vue";
import { ElMessage } from "element-plus";
import { Download } from "@element-plus/icons-vue";
const props = defineProps({
materialName: {
type: String,
default: "",
},
materialUrl: {
type: String,
default: "",
},
});
import { Download, View } from "@element-plus/icons-vue";
const props = defineProps({ materialName: { type: String, default: "" }, materialUrl: { type: String, default: "" } });
const previewVisible = ref(false);
const material = computed(() => {
const name = String(props.materialName || "").trim();
const url = String(props.materialUrl || "").trim();
if (!name || !url || (name === "案例资料.rar" && url === "/file/example.rar")) {
return null;
}
if (!name || !url || (name === "案例资料.rar" && url === "/file/example.rar")) return null;
return { name, url };
});
const extension = computed(() => String(material.value?.name || material.value?.url || "").split(/[?#]/)[0].split(".").pop().toLowerCase());
const isImage = computed(() => ["jpg", "jpeg", "png", "gif", "webp", "bmp", "svg"].includes(extension.value));
const isPdf = computed(() => extension.value === "pdf");
const previewable = computed(() => isImage.value || isPdf.value);
function handleDownload() {
if (!material.value?.url) {
ElMessage.warning("暂无案例文档");
return;
}
if (!material.value?.url) { ElMessage.warning("暂无案例资料"); return; }
const link = document.createElement("a");
link.href = material.value.url;
link.target = "_blank";
@ -43,32 +42,10 @@ function handleDownload() {
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
if (!previewable.value) ElMessage.info("该文件类型暂不支持在线预览,已为您打开下载。");
}
</script>
<style lang="scss" scoped>
.material-download {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
max-width: 260px;
min-height: 40px;
padding: 0 18px;
border-color: rgba(99, 217, 255, 0.55);
border-radius: 999px;
color: #e9fbff;
background: rgba(17, 126, 178, 0.36);
font-size: 14px;
font-weight: 800;
line-height: 1;
box-shadow: none;
}
.material-download:hover {
transform: translateY(-1px);
color: #e9fbff;
border-color: rgba(116, 235, 255, 0.85);
background: rgba(18, 146, 204, 0.5);
}
.material-actions{display:flex;flex-wrap:wrap;gap:10px}.material-button{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:40px;padding:0 16px;border-color:rgba(99,217,255,.55);border-radius:9px;color:#e9fbff;background:rgba(17,126,178,.36);font-size:14px;font-weight:800}.material-button:hover{color:#e9fbff;border-color:rgba(116,235,255,.85);background:rgba(18,146,204,.5)}.image-preview{display:flex;justify-content:center;max-height:68vh;overflow:auto;background:#0b1720}.image-preview img{display:block;max-width:100%;height:auto;object-fit:contain}.pdf-preview{width:100%;height:68vh;border:0;background:#fff}
</style>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

@ -1,5 +1,527 @@
<template><ProductStandardTrainingPage task-key="requirements-doc" /></template>
<template>
<div class="requirements-doc-page student-training-shell">
<main class="training-core">
<section class="task-header">
<div class="task-badge"><el-icon><Document /></el-icon> 产品规划与设计</div>
<h1 class="task-title">{{ taskTitle }}</h1>
</section>
<TrainingTaskBrief :background="taskBackground" :goals="taskGoals" :requirements="taskRequirement" :show-material="false" />
<section class="task-card">
<div class="workbench-head">
<div>
<p class="section-eyebrow">{{ currentStep === 3 ? 'PRD document delivery' : currentStep === 2 ? 'PRD detailed specification' : 'PRD module matching' }}</p>
<h2>{{ currentStep === 3 ? '生成并提交产品需求文档' : currentStep === 2 ? '填写 PRD 七大模块详细信息' : '认识 PRD 的核心模块' }}</h2>
</div>
<div class="workbench-actions">
<TrainingMaterialButton :material-name="taskConfig?.materialName" :material-url="taskConfig?.materialUrl" />
<button type="button" class="outline-action" @click="exportOutcome"><el-icon><Download /></el-icon>导出实训成果</button>
</div>
</div>
<nav class="step-indicator" aria-label="产品需求文档撰写步骤">
<template v-for="(step, index) in trainingSteps" :key="`${step}-${index}`">
<button
type="button"
:class="['step-tab', { active: currentStep === index + 1, complete: index === 0 && stepOnePassed }]"
@click="switchStep(index + 1)"
>
<span class="step-marker">{{ String(index + 1).padStart(2, '0') }}</span>
<span class="step-copy"><strong>{{ step }}</strong><small>{{ stepStateText(index) }}</small></span>
</button>
<span v-if="index < trainingSteps.length - 1" class="step-connector" aria-hidden="true" />
</template>
</nav>
<section v-if="currentStep === 1" class="step-card">
<div class="step-title-row">
<div>
<p class="section-eyebrow">Step 01</p>
<h3>{{ trainingSteps[0] }}</h3>
</div>
<span class="step-chip">拖拽匹配判题</span>
</div>
<p class="step-desc">PRD 是产品开发的纲领性文件,通常包含文档说明、产品概述、用户需求与场景、核心功能需求、非功能需求、硬件规格与设计需求、测试需求、项目规划和需求变更管理等模块。请把右侧被打乱的“核心作用”卡片拖入左侧对应模块;全部匹配正确后才能进入第 2 步。</p>
<div class="matching-tip" role="status">
<el-icon><InfoFilled /></el-icon>
<span>可直接拖拽卡片,也可先单击右侧卡片,再单击左侧空白槽位完成匹配。</span>
</div>
<div class="matching-workspace">
<section class="module-list" aria-label="PRD 模块匹配区">
<article
v-for="(module, index) in MODULES"
:key="module.id"
class="module-row"
:class="{ 'module-row--over': dragOverModuleId === module.id, 'module-row--filled': assignments[module.id] }"
@dragover.prevent="dragOverModuleId = module.id"
@dragleave="dragOverModuleId = ''"
@drop.prevent="dropIntoModule(module.id)"
@click="assignSelected(module.id)"
>
<div class="module-label"><span>{{ String(index + 1).padStart(2, '0') }}</span><strong>{{ module.name }}</strong></div>
<div class="module-drop-zone">
<article v-if="assignedPurpose(module.id)" class="purpose-card purpose-card--assigned" draggable="true" @dragstart.stop="startDrag(assignedPurpose(module.id).id)" @dragend="finishDrag">
<span>{{ assignedPurpose(module.id).text }}</span>
<button type="button" :aria-label="`移除${module.name}的匹配`" @click.stop="removeAssignment(module.id)"><el-icon><Close /></el-icon></button>
</article>
<span v-else class="drop-placeholder">{{ selectedPurposeId ? '单击此处放入已选卡片' : '将核心作用拖到这里' }}</span>
</div>
</article>
</section>
<aside class="purpose-bank" :class="{ 'purpose-bank--over': dragOverModuleId === 'bank' }" @dragover.prevent="dragOverModuleId = 'bank'" @dragleave="dragOverModuleId = ''" @drop.prevent="returnDraggedToBank">
<div class="purpose-bank__head">
<div><p class="section-eyebrow">待匹配核心作用</p><h4>拖拽卡片到左侧模块</h4></div>
<strong>{{ unassignedPurposes.length }}</strong>
</div>
<p>卡片顺序已被打乱;标准答案仅在服务端保存,提交后由系统判题。</p>
<div class="purpose-card-list">
<button v-for="purpose in unassignedPurposes" :key="purpose.id" type="button" class="purpose-card" :class="{ selected: selectedPurposeId === purpose.id, dragging: draggingPurposeId === purpose.id }" draggable="true" @click="togglePurposeSelection(purpose.id)" @dragstart="startDrag(purpose.id)" @dragend="finishDrag">
<span class="drag-grip">⠿</span><span>{{ purpose.text }}</span>
</button>
<div v-if="!unassignedPurposes.length" class="purpose-bank__empty">全部核心作用已放入匹配区</div>
</div>
</aside>
</div>
<p v-if="checkResult" class="result-message" :class="{ success: checkResult.allCorrect }">{{ checkResult.message }}</p>
<div class="task-actions">
<button type="button" class="btn-nav" :disabled="saving" @click="saveCurrentMatching">保存当前匹配</button>
<button type="button" class="btn-nav btn-submit" :disabled="checking || !allAssigned" @click="checkAndSave">{{ checking ? '判题中…' : '检查匹配并保存' }}</button>
<button type="button" class="btn-nav" :disabled="checking || saving" @click="resetMatching"><el-icon><RefreshLeft /></el-icon>重新打乱</button>
<button type="button" class="btn-nav btn-primary" :disabled="!stepOnePassed" @click="goNextStep">进入第 2 步 <el-icon><ArrowRight /></el-icon></button>
</div>
</section>
<section v-else-if="currentStep === 2" class="step-card prd-detail-card">
<div class="step-title-row">
<div>
<p class="section-eyebrow">Step 02</p>
<h3>{{ trainingSteps[1] }}</h3>
</div>
<span class="step-chip">可编辑 PRD 架构表</span>
</div>
<p class="step-desc">PRD 的每一个模块都需要言之有物、数据支撑。请基于案例资料和前期实训成果,完成以下七大模块的详细信息。</p>
<div class="prd-note"><el-icon><InfoFilled /></el-icon><span>灰色文字为参考示例,可点击“填入示例”后再修改。导出表格会同时包含示例内容和你的填写内容。</span></div>
<div class="prd-table-wrap">
<table class="prd-detail-table">
<thead><tr><th>序号</th><th>模块</th><th>子模块</th><th>填写内容</th></tr></thead>
<tbody>
<template v-for="module in PRD_DETAIL_MODULES" :key="module.id">
<tr v-for="(row, index) in module.rows" :key="row.id">
<td v-if="index === 0" :rowspan="module.rows.length" class="module-number">{{ module.order }}</td>
<td v-if="index === 0" :rowspan="module.rows.length" class="module-name">{{ module.name }}</td>
<td class="submodule-name">{{ row.name }}</td>
<td class="detail-editor-cell">
<div v-if="row.example" class="example-bar"><span>示例:{{ row.example }}</span><button type="button" @click="fillExample(row)">填入示例</button></div>
<textarea v-model="stepTwoContents[row.id]" :aria-label="`${module.name}-${row.name}`" placeholder="请基于案例资料和前期实训成果填写" />
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="task-actions">
<button type="button" class="btn-nav" :disabled="saving" @click="saveStepTwo">{{ saving ? '保存中…' : '保存详细信息' }}</button>
<button type="button" class="btn-nav btn-submit" @click="exportStepTwoTable"><el-icon><Download /></el-icon>导出 PRD 表格</button>
<button type="button" class="btn-nav btn-primary" :disabled="saving" @click="goToStepThree">进入第 3 步<el-icon><ArrowRight /></el-icon></button>
</div>
</section>
<section v-else-if="currentStep === 3" class="step-card prd-upload-card">
<div class="step-title-row">
<div>
<p class="section-eyebrow">Step 03</p>
<h3>{{ trainingSteps[2] || '生成产品需求文档' }}</h3>
</div>
<span class="step-chip">Word / PDF 在线预览</span>
</div>
<p class="step-desc">PRD 是跨团队协作的基准文档。请基于第 2 步的核心模块信息,结合案例资料中的完整数据,生成一份完整的产品需求文档后上传。</p>
<div class="prd-delivery-guide">
<ol>
<li>下载 Word 版 PRD 空白模板后自行撰写。</li>
<li>也可借助 AI:请根据产品需求文档框架和案例文档信息,撰写一份产品需求说明书。</li>
</ol>
<span>支持 .docx、.pdf,单个文件不超过 20MB;上传后可在线预览。</span>
</div>
<input ref="documentInput" class="file-input-hidden" type="file" accept=".docx,.pdf,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document" @change="handleDocumentUpload" />
<div v-if="!stepThreeDocument.name" class="document-drop-zone" role="button" tabindex="0" @click="triggerDocumentUpload" @keyup.enter="triggerDocumentUpload">
<div class="document-drop-zone__icon"><el-icon><UploadFilled /></el-icon></div>
<strong>{{ documentUploading ? '正在解析并上传…' : '上传产品需求文档' }}</strong>
<span>Word(.docx)或 PDF</span>
</div>
<article v-else class="uploaded-document-card">
<div class="uploaded-document-icon"><el-icon><Document /></el-icon></div>
<div class="uploaded-document-main">
<strong>{{ stepThreeDocument.name }}</strong>
<span>{{ stepThreeDocument.fileType === 'PDF' ? 'PDF 文档' : 'Word 文档' }} · {{ formatFileSize(stepThreeDocument.size) }}</span>
</div>
<div class="uploaded-document-actions">
<button type="button" class="btn-nav btn-submit" @click="showDocumentPreview = true"><el-icon><View /></el-icon>在线预览</button>
<button type="button" class="btn-nav" :disabled="documentUploading" @click="triggerDocumentUpload">替换</button>
<button type="button" class="btn-nav btn-danger" :disabled="documentUploading" @click="removeDocument"><el-icon><Delete /></el-icon>删除</button>
</div>
</article>
<div class="task-actions">
<button type="button" class="btn-nav" :disabled="!isDocumentReady || saving || documentUploading" @click="saveStepThree">{{ saving ? '保存中…' : '保存文档' }}</button>
<button type="button" class="btn-nav btn-submit" :disabled="!isDocumentReady || documentUploading" @click="showDocumentPreview = true"><el-icon><View /></el-icon>预览文档</button>
</div>
</section>
<section v-else class="step-card upcoming-card">
<p class="section-eyebrow">Step {{ String(currentStep).padStart(2, '0') }}</p>
<h3>{{ trainingSteps[currentStep - 1] || '后续步骤' }}</h3>
<p>该步骤将在对应实训页面继续完成。</p>
<button type="button" class="btn-nav" @click="switchStep(2)">返回第 2 步</button>
</section>
</section>
<el-dialog v-model="showDocumentPreview" :title="stepThreeDocument.name || '产品需求文档预览'" width="min(1040px, 94vw)" class="prd-preview-dialog" append-to-body>
<div v-if="stepThreeDocument.fileType === 'PDF' && stepThreeDocument.url" class="prd-pdf-preview">
<iframe :src="stepThreeDocument.url" :title="`${stepThreeDocument.name} PDF 预览`" />
</div>
<div v-else-if="stepThreeDocument.previewContent" class="prd-text-preview">{{ stepThreeDocument.previewContent }}</div>
<p v-else class="prd-preview-empty">暂未解析到可预览的文本内容,可下载原文件查看。</p>
</el-dialog>
</main>
<TrainingAiSidebar
:study-tip="sidebarStudyTip"
:reference-title="currentStep === 3 ? 'PRD 文档交付提示' : currentStep === 2 ? 'PRD 详细信息填写提示' : 'PRD 模块判断提示'"
:reference-text="sidebarReferenceText"
:progress-items="progressItems"
/>
</div>
</template>
<script setup>
import ProductStandardTrainingPage from "@/views/product/components/ProductStandardTrainingPage.vue";
import { computed, getCurrentInstance, onMounted, ref } from "vue";
import { ArrowRight, Close, Delete, Document, Download, InfoFilled, RefreshLeft, UploadFilled, View } from "@element-plus/icons-vue";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
import { checkRequirementsDocumentStepOne, getStudentTrainingAnswer, previewRequirementsDocumentFile, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
import { getTrainingTaskByKey } from "@/api/trainingTask";
import { parseTrainingArray } from "@/views/training/taskKeyMap";
import { isStudentDemo } from "@/utils/studentDemo";
const TASK_KEY = "requirements-doc";
const { proxy } = getCurrentInstance();
const MODULES = [
{ id: "document-info", name: "文档说明" }, { id: "product-overview", name: "产品概述" }, { id: "user-needs-scenarios", name: "用户需求与场景" },
{ id: "core-functional-requirements", name: "核心功能需求" }, { id: "non-functional-requirements", name: "非功能需求" }, { id: "hardware-spec-design", name: "硬件规格与设计需求" },
{ id: "testing-requirements", name: "测试需求" }, { id: "project-planning", name: "项目规划" }, { id: "change-management", name: "需求变更管理" },
];
const PURPOSES = [
{ id: "functional-scope", text: "详细规定产品具体做什么,是开发与测试的直接依据。" },
{ id: "acceptance-criteria", text: "明确验收标准,避免测试与产品理解不一致。" },
{ id: "quality-attributes", text: "定义产品的“质量属性”,决定产品好不好用、稳不稳定。" },
{ id: "document-governance", text: "建立文档本身的权威性与可追溯性,确保所有人看到的是同一份有效版本。" },
{ id: "change-control", text: "确保每一次变更都经过评估、记录和通知。" },
{ id: "user-context", text: "从用户视角出发,让团队在开发时能代入真实使用情境。" },
{ id: "product-boundary", text: "回答“为什么做”和“做成什么样”,赋予开发团队价值感,划定产品边界。" },
{ id: "hardware-constraints", text: "明确硬件与软件的通信协议及设计约束。" },
{ id: "resource-plan", text: "将产品需求转化为时间与资源计划,用于管理预期。" },
];
const PRD_DETAIL_MODULES = [
{ id: "overview", order: "一", name: "产品概述", rows: [
{ id: "backgroundPain", name: "1. 项目背景与痛点", example: "年轻租房群体夜间起夜开灯不便,需摸黑找开关;母婴家庭夜间喂奶、换尿布需柔和不刺眼光源。传统小夜灯功能单一,无法满足差异化场景需求,市场存在智能多场景小夜灯的空白机会。" },
{ id: "positioningTarget", name: "2. 产品定位与目标用户", example: "一句话概括,如:面向 X 人群的 Y 类型产品" },
{ id: "coreGoal", name: "3. 核心目标", example: "可量化的业务指标" },
{ id: "productScope", name: "4. 产品范围", example: "明确本期做与不做的内容。" },
] },
{ id: "user", order: "二", name: "用户需求与场景", rows: [
{ id: "persona", name: "1. 用户画像", example: "职场妈妈李婷,30 岁,宝宝 8 个月。夜间需频繁起夜喂奶/安抚,对光线敏感度极高,希望灯光柔和且能声控、手机控,不打扰宝宝睡眠节奏。" },
{ id: "usageScenario", name: "2. 用户场景", example: "在什么时间、地点、触发条件下使用" },
{ id: "painAndNeed", name: "3. 用户痛点与诉求", example: "用户表面的抱怨和底层的真实渴望" },
{ id: "journeyMap", name: "4. 用户旅程地图", example: "用户完成任务的全流程触点" },
] },
{ id: "core", order: "三", name: "核心功能需求", rows: [
{ id: "featureOverview", name: "1. 功能模块概览", example: "①语音控制模块(离线唤醒+指令识别)②智能调光模块(亮度和色温多档可调)③场景联动模块(定时/感应/APP 远程)④睡眠辅助模块(白噪声/助眠灯光模式)" },
{ id: "featureDetails", name: "2. 详细功能描述", example: "优先级:P0(必须有)、P1(应该有)、P2(可以有);说明交互流程、操作路径与软硬件交互规则。" },
] },
{ id: "nonfunctional", order: "四", name: "非功能需求", rows: [
{ id: "performance", name: "1. 性能需求", example: "核心功能的性能要求,如响应时间" },
{ id: "security", name: "2. 安全需求", example: "如:产品防火防水、数据加密等" },
{ id: "usability", name: "3. 可用性与易用性", example: "操作容错率、学习成本" },
{ id: "compatibility", name: "4. 兼容性", example: "软硬件跟主流系统之间的兼容性" },
] },
{ id: "hardware", order: "五", name: "硬件规格与设计需求", rows: [
{ id: "hardwareSpecs", name: "1. 硬件参数", example: "如:芯片型号、传感器规格、屏幕尺寸/分辨率" },
{ id: "interfaces", name: "2. 接口与协议", example: "蓝牙、Wi-Fi、串口通信的数据格式(如:JSON/XML 结构)" },
{ id: "powerHeat", name: "3. 功耗与散热", example: "续航时间要求、温度控制范围" },
{ id: "structureConstraints", name: "4. 结构设计约束", example: "对软件 UI 的物理限制(如:必须适配圆形屏幕)" },
] },
{ id: "testing", order: "六", name: "测试需求", rows: [
{ id: "acceptance", name: "1. 验收标准", example: "针对每个核心功能的通过条件" },
{ id: "testingFocus", name: "2. 测试重点", example: "高风险模块、复杂交互逻辑的测试关注点" },
{ id: "testMatrix", name: "3. 兼容性测试矩阵", example: "需要覆盖的设备/系统组合" },
] },
{ id: "planning", order: "七", name: "项目规划", rows: [
{ id: "milestones", name: "1. 里程碑", example: "需求评审日、交互定稿日、提测日、上线日" },
{ id: "resourceDependencies", name: "2. 资源依赖", example: "需要 UI 设计、后端接口、外部第三方合作的配合时间点" },
{ id: "riskAssessment", name: "3. 风险评估", example: "当前识别的最大风险点" },
] },
];
const DEFAULT_STEPS = ["PRD 模块核心作用匹配", "PRD 信息架构梳理", "生成产品需求文档", "PRD 文档评审"];
const fallbackTask = {
title: "产品需求文档撰写",
background: "PRD 是连接产品、设计、研发和测试的纲领性文件。本实训先建立对 PRD 模块职责的清晰认识,再逐步完成需求文档撰写。",
goals: ["识别 PRD 的核心模块", "理解各模块在产品开发中的作用", "建立结构化撰写产品需求文档的基础"],
requirement: "完成 PRD 核心模块匹配及七大模块详细信息填写。",
};
const taskConfig = ref(null);
const currentStep = ref(1);
const assignments = ref({});
const stepTwoContents = ref({});
const stepThreeDocument = ref({ name: "", url: "", fileType: "", size: 0, previewContent: "" });
const documentInput = ref(null);
const documentUploading = ref(false);
const showDocumentPreview = ref(false);
const selectedPurposeId = ref("");
const draggingPurposeId = ref("");
const dragOverModuleId = ref("");
const checking = ref(false);
const saving = ref(false);
const stepOnePassed = ref(false);
const checkResult = ref(null);
const taskTitle = computed(() => taskConfig.value?.taskName || fallbackTask.title);
const taskBackground = computed(() => taskConfig.value?.background || fallbackTask.background);
const taskGoals = computed(() => parseTrainingArray(taskConfig.value?.objectives, fallbackTask.goals));
const taskRequirement = computed(() => taskConfig.value?.requirements || fallbackTask.requirement);
const trainingSteps = computed(() => parseTrainingArray(taskConfig.value?.steps, DEFAULT_STEPS));
const progressItems = computed(() => trainingSteps.value.map((text, index) => ({ text, status: index === 0 ? (stepOnePassed.value ? "done" : "doing") : index === 1 && currentStep.value >= 2 ? "done" : index === 2 && currentStep.value >= 3 ? "doing" : "" })));
const unassignedPurposes = computed(() => PURPOSES.filter((purpose) => !Object.values(assignments.value).includes(purpose.id)));
const allAssigned = computed(() => MODULES.every((module) => Boolean(assignments.value[module.id])) && Object.keys(assignments.value).length === MODULES.length);
const isDocumentReady = computed(() => Boolean(stepThreeDocument.value.name && stepThreeDocument.value.url));
const sidebarStudyTip = computed(() => currentStep.value === 3
? "先依据第 2 步的模块信息整理成完整 PRD,再上传 .docx 或 .pdf。上传完成后请打开在线预览,确认文档内容和版本无误。"
: currentStep.value === 2
? "先用前期实训的真实资料填入七大模块。优先把用户、场景、痛点和功能的链路写清楚,再补充指标、边界、测试与规划。"
: "先辨识每个 PRD 模块的职责边界:用户需求解释使用情境,核心功能定义产品要做什么,非功能需求定义质量属性;再完成匹配。"
);
const sidebarReferenceText = computed(() => currentStep.value === 3
? "• 文档应覆盖产品概述、用户与场景、核心功能、非功能需求、硬件规格、测试与项目规划<br />• 支持 .docx 和 PDF 在线预览;替换文件会保留最新提交版本<br />• 保存后可再次进入本步骤查看或替换文档"
: currentStep.value === 2
? "• 填写内容应基于案例资料或前期实训结果,避免只复述标题<br />• 示例是格式参考,可复制后结合自己的产品修改<br />• 导出的 PRD 表会保留示例参考与学生填写内容"
: "• 文档说明:版本、作者、状态等文档治理信息<br />• 核心功能需求:开发和测试可直接执行的功能范围<br />• 非功能需求:性能、稳定性、易用性等质量属性<br />• 需求变更管理:评估、记录、通知每次需求变更"
);
onMounted(async () => { await loadTaskConfig(); await loadSavedProgress(); });
function assignedPurpose(moduleId) { return PURPOSES.find((purpose) => purpose.id === assignments.value[moduleId]) || null; }
function parseSavedAnswer(raw) { try { return raw ? (typeof raw === "string" ? JSON.parse(raw) : raw) : null; } catch (_) { return null; } }
function normalizeAssignments(value) {
if (!value || typeof value !== "object") return null;
const copied = {};
for (const module of MODULES) {
const purposeId = String(value[module.id] || "");
if (PURPOSES.some((purpose) => purpose.id === purposeId)) copied[module.id] = purposeId;
}
return new Set(Object.values(copied)).size === Object.keys(copied).length ? copied : null;
}
function normalizeStepTwoContents(value) {
const saved = value?.contents && typeof value.contents === "object" ? value.contents : value;
if (!saved || typeof saved !== "object") return {};
const validIds = new Set(PRD_DETAIL_MODULES.flatMap((module) => module.rows.map((row) => row.id)));
return Object.fromEntries(Object.entries(saved).filter(([key, text]) => validIds.has(key) && typeof text === "string"));
}
function normalizeStepThreeDocument(value) {
const document = value?.document && typeof value.document === "object" ? value.document : value;
if (!document || typeof document !== "object" || !String(document.name || "").trim() || !String(document.url || "").trim()) return null;
return {
name: String(document.name),
url: String(document.url),
fileType: String(document.fileType || "").toUpperCase(),
size: Number(document.size || 0),
previewContent: String(document.previewContent || ""),
};
}
function stepStateText(index) {
if (index === 0) return stepOnePassed.value ? "已通过" : "当前步骤";
if (index === 1) return stepOnePassed.value ? (currentStep.value === 2 ? "当前步骤" : "可填写") : "完成第 1 步后解锁";
if (index === 2) return stepOnePassed.value ? (currentStep.value === 3 ? "当前步骤" : isDocumentReady.value ? "已上传" : "可填写") : "完成第 1 步后解锁";
return currentStep.value > index + 1 ? "已完成" : "后续步骤";
}
function invalidateCheck() { checkResult.value = null; stepOnePassed.value = false; }
function togglePurposeSelection(purposeId) { selectedPurposeId.value = selectedPurposeId.value === purposeId ? "" : purposeId; }
function startDrag(purposeId) { if (!checking.value && !saving.value) { draggingPurposeId.value = purposeId; selectedPurposeId.value = purposeId; } }
function finishDrag() { draggingPurposeId.value = ""; dragOverModuleId.value = ""; }
function placePurpose(moduleId, purposeId) {
if (!moduleId || !purposeId) return;
const next = { ...assignments.value };
Object.keys(next).forEach((key) => { if (next[key] === purposeId) delete next[key]; });
next[moduleId] = purposeId;
assignments.value = next;
selectedPurposeId.value = "";
invalidateCheck();
}
function assignSelected(moduleId) { if (selectedPurposeId.value) placePurpose(moduleId, selectedPurposeId.value); }
function dropIntoModule(moduleId) { placePurpose(moduleId, draggingPurposeId.value || selectedPurposeId.value); finishDrag(); }
function returnDraggedToBank() {
const moduleId = Object.keys(assignments.value).find((key) => assignments.value[key] === draggingPurposeId.value);
if (moduleId) { const next = { ...assignments.value }; delete next[moduleId]; assignments.value = next; invalidateCheck(); }
finishDrag();
}
function removeAssignment(moduleId) { const next = { ...assignments.value }; delete next[moduleId]; assignments.value = next; invalidateCheck(); }
function resetMatching() { assignments.value = {}; selectedPurposeId.value = ""; invalidateCheck(); }
function buildStepOneAnswer() { return { assignments: { ...assignments.value }, passed: stepOnePassed.value, checkResult: checkResult.value, updatedAt: new Date().toISOString() }; }
function buildStepTwoAnswer() { return { contents: { ...stepTwoContents.value }, updatedAt: new Date().toISOString() }; }
function buildStepThreeAnswer() { return { document: { ...stepThreeDocument.value }, updatedAt: new Date().toISOString() }; }
function buildSavePayload(current = currentStep.value) {
return { currentStep: current, step1Answer: JSON.stringify(buildStepOneAnswer()), step2Answer: JSON.stringify(buildStepTwoAnswer()), step3Answer: JSON.stringify(buildStepThreeAnswer()), submitted: false, saveAction: "SAVE" };
}
async function persist(current = currentStep.value) { await saveStudentTrainingAnswer(TASK_KEY, buildSavePayload(current)); }
async function saveCurrentMatching() {
if (saving.value) return;
saving.value = true;
try { await persist(stepOnePassed.value ? 2 : 1); proxy?.$modal?.msgSuccess("当前匹配已保存"); } catch (error) { proxy?.$modal?.msgError(error?.message || "保存失败,请稍后重试"); } finally { saving.value = false; }
}
async function checkAndSave() {
if (!allAssigned.value || checking.value) return;
checking.value = true;
try {
const response = await checkRequirementsDocumentStepOne({ assignments: { ...assignments.value } });
checkResult.value = response?.data || response;
stepOnePassed.value = Boolean(checkResult.value?.allCorrect);
await persist(stepOnePassed.value ? 2 : 1);
if (stepOnePassed.value) proxy?.$modal?.msgSuccess("匹配正确,已解锁第 2 步");
} catch (error) {
checkResult.value = { allCorrect: false, message: error?.message || "判题失败,请稍后重试" };
} finally { checking.value = false; }
}
function fillExample(row) { stepTwoContents.value = { ...stepTwoContents.value, [row.id]: row.example }; }
async function saveStepTwo() {
if (saving.value) return;
saving.value = true;
try { await persist(2); proxy?.$modal?.msgSuccess("PRD 详细信息已保存"); } catch (error) { proxy?.$modal?.msgError(error?.message || "保存失败,请稍后重试"); } finally { saving.value = false; }
}
async function goToStepThree() {
if (saving.value) return;
saving.value = true;
try { await persist(2); currentStep.value = 3; } catch (error) { proxy?.$modal?.msgError(error?.message || "保存失败,请稍后重试"); } finally { saving.value = false; }
}
function triggerDocumentUpload() { if (!documentUploading.value) documentInput.value?.click(); }
function formatFileSize(size) {
const value = Number(size || 0);
if (!value) return "已上传";
return value < 1024 * 1024 ? `${Math.max(1, Math.round(value / 1024))} KB` : `${(value / (1024 * 1024)).toFixed(1)} MB`;
}
async function handleDocumentUpload(event) {
const file = event.target.files?.[0];
if (!file) return;
if (!/\.(docx|pdf)$/i.test(file.name)) { proxy?.$modal?.msgWarning("请上传 .docx 或 .pdf 格式的产品需求文档"); event.target.value = ""; return; }
if (file.size > 20 * 1024 * 1024) { proxy?.$modal?.msgWarning("产品需求文档大小不能超过 20MB"); event.target.value = ""; return; }
documentUploading.value = true;
try {
const previewData = new FormData(); previewData.append("file", file);
const uploadData = new FormData(); uploadData.append("file", file);
const previewResponse = await previewRequirementsDocumentFile(previewData);
const preview = previewResponse?.data || previewResponse || {};
let url = "";
if (isStudentDemo()) url = URL.createObjectURL(file);
else {
const uploadResponse = await uploadStudentTrainingFile(uploadData);
url = uploadResponse?.url || uploadResponse?.fileName || uploadResponse?.data?.url || uploadResponse?.data?.fileName || "";
}
if (!url) throw new Error("未获取到文件地址,请重新上传");
stepThreeDocument.value = { name: file.name, url, fileType: String(preview.fileType || (file.name.toLowerCase().endsWith(".pdf") ? "PDF" : "DOCX")).toUpperCase(), size: file.size, previewContent: String(preview.content || "") };
await persist(3);
showDocumentPreview.value = true;
proxy?.$modal?.msgSuccess("产品需求文档已上传并保存");
} catch (error) { proxy?.$modal?.msgError(error?.message || "文件上传或解析失败,请稍后重试"); }
finally { documentUploading.value = false; event.target.value = ""; }
}
async function removeDocument() {
stepThreeDocument.value = { name: "", url: "", fileType: "", size: 0, previewContent: "" };
showDocumentPreview.value = false;
try { await persist(3); proxy?.$modal?.msgSuccess("已删除产品需求文档"); }
catch (error) { proxy?.$modal?.msgError(error?.message || "删除状态保存失败,请稍后重试"); }
}
async function saveStepThree() {
if (!isDocumentReady.value || saving.value) return;
saving.value = true;
try { await persist(3); proxy?.$modal?.msgSuccess("产品需求文档已保存"); } catch (error) { proxy?.$modal?.msgError(error?.message || "保存失败,请稍后重试"); } finally { saving.value = false; }
}
async function exportStepTwoTable() {
try {
const XLSX = await import("xlsx");
const header = ["序号", "模块", "子模块", "填写内容"];
const rows = [];
PRD_DETAIL_MODULES.forEach((module) => module.rows.forEach((row, index) => {
const reference = row.example ? `示例参考:${row.example}` : "示例参考:—";
const written = stepTwoContents.value[row.id]?.trim() || "(未填写)";
rows.push([index === 0 ? module.order : "", index === 0 ? module.name : "", row.name, `${reference}\n\n学生填写:${written}`]);
}));
const sheet = XLSX.utils.aoa_to_sheet([header, ...rows]);
sheet["!cols"] = [{ wch: 8 }, { wch: 18 }, { wch: 28 }, { wch: 88 }];
sheet["!rows"] = [{ hpt: 26 }, ...rows.map(() => ({ hpt: 78 }))];
sheet["!merges"] = [];
let offset = 1;
PRD_DETAIL_MODULES.forEach((module) => { if (module.rows.length > 1) { sheet["!merges"].push({ s: { r: offset, c: 0 }, e: { r: offset + module.rows.length - 1, c: 0 } }, { s: { r: offset, c: 1 }, e: { r: offset + module.rows.length - 1, c: 1 } }); } offset += module.rows.length; });
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, sheet, "PRD详细信息");
XLSX.writeFile(workbook, "PRD详细信息表.xlsx");
proxy?.$modal?.msgSuccess("PRD 表格已导出,示例内容已一并保留");
} catch (error) { proxy?.$modal?.msgError(error?.message || "导出失败,请稍后重试"); }
}
async function loadTaskConfig() { try { taskConfig.value = (await getTrainingTaskByKey(TASK_KEY))?.data || null; } catch (_) { taskConfig.value = null; } }
async function loadSavedProgress() {
try {
const answer = (await getStudentTrainingAnswer(TASK_KEY))?.data;
const savedStepOne = parseSavedAnswer(answer?.step1Answer);
const normalized = normalizeAssignments(savedStepOne?.assignments);
if (normalized) assignments.value = normalized;
stepOnePassed.value = Boolean(savedStepOne?.passed);
checkResult.value = savedStepOne?.checkResult || null;
stepTwoContents.value = normalizeStepTwoContents(parseSavedAnswer(answer?.step2Answer));
const savedStepThree = normalizeStepThreeDocument(parseSavedAnswer(answer?.step3Answer));
if (savedStepThree) stepThreeDocument.value = savedStepThree;
if (stepOnePassed.value && Number(answer?.currentStep || 1) >= 3) currentStep.value = 3;
else if (stepOnePassed.value && Number(answer?.currentStep || 1) >= 2) currentStep.value = 2;
} catch (_) { /* 首次进入时保留空白答题区 */ }
}
function switchStep(step) {
if (step === 1) { currentStep.value = 1; return; }
if (step === 2) {
if (!stepOnePassed.value) { proxy?.$modal?.msgInfo("请先完成第 1 步匹配并通过判题。"); return; }
currentStep.value = 2;
return;
}
if (step === 3) {
if (!stepOnePassed.value) { proxy?.$modal?.msgInfo("请先完成第 1 步匹配并通过判题。"); return; }
currentStep.value = 3;
return;
}
proxy?.$modal?.msgInfo("请先完成当前步骤后再进入后续实训。");
}
function goNextStep() { switchStep(2); }
function exportOutcome() { if (currentStep.value === 2) { exportStepTwoTable(); } else if (currentStep.value === 3 && isDocumentReady.value) { showDocumentPreview.value = true; } else { proxy?.$modal?.msgInfo("请进入第 2 步导出 PRD 详细信息表。"); } }
</script>
<style scoped>
.requirements-doc-page { min-height: 100vh; color: #dcebf8; background: radial-gradient(circle at 15% -10%, rgba(23, 134, 205, .22), transparent 36%), #071521; }
.training-core { width: min(1480px, calc(100% - 48px)); margin: 0 auto; padding: 30px 0 60px; }
.task-header { display: flex; align-items: center; gap: 14px; margin-bottom: 18px; }.task-badge, .step-chip { display: inline-flex; align-items: center; gap: 7px; padding: 7px 12px; border: 1px solid rgba(68, 203, 255, .42); border-radius: 999px; color: #8ee7ff; background: rgba(6, 86, 129, .28); font-size: 13px; }.task-title { margin: 0; color: #f1f8ff; font-size: 26px; letter-spacing: .02em; }
.task-card { padding: 26px; border: 1px solid rgba(66, 181, 237, .42); border-radius: 24px; background: rgba(4, 24, 39, .78); box-shadow: 0 18px 42px rgba(0, 0, 0, .2); }.workbench-head, .step-title-row, .purpose-bank__head, .task-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; }.workbench-head h2, .step-title-row h3, .purpose-bank h4 { margin: 4px 0 0; color: #f2f9ff; }.section-eyebrow { margin: 0; color: #55d8ff; font-size: 12px; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; }
.workbench-actions, .task-actions { display: flex; flex-wrap: wrap; }.outline-action, .btn-nav { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 38px; padding: 0 15px; border: 1px solid rgba(98, 208, 255, .48); border-radius: 9px; color: #dff6ff; background: rgba(10, 87, 126, .35); cursor: pointer; transition: .2s ease; }.outline-action:hover, .btn-nav:hover:not(:disabled) { border-color: #56dcff; background: rgba(18, 137, 190, .48); transform: translateY(-1px); }.btn-submit { border-color: #26c9ed; color: #fff; background: linear-gradient(135deg, #087baa, #18b7df); }.btn-primary { border-color: #8a72ff; background: linear-gradient(135deg, #5a4fc9, #8c6ef1); }.btn-nav:disabled { cursor: not-allowed; opacity: .48; }
.step-indicator { display: flex; align-items: stretch; margin: 26px 0; padding: 10px; overflow-x: auto; border: 1px solid rgba(58, 170, 220, .35); border-radius: 18px; background: rgba(0, 15, 28, .45); }.step-tab { display: flex; min-width: 185px; flex: 1; align-items: center; gap: 10px; padding: 10px 12px; border: 0; border-radius: 12px; color: #8baabd; background: transparent; text-align: left; cursor: pointer; }.step-tab.active { color: #fff; background: linear-gradient(90deg, #087eb3, #12a7d5); box-shadow: inset 0 0 0 1px rgba(157, 239, 255, .55); }.step-tab.complete .step-marker { border-color: #53f1d5; color: #53f1d5; }.step-marker { display: inline-grid; width: 33px; height: 33px; flex: 0 0 33px; place-items: center; border: 1px solid #4c91b5; border-radius: 50%; font-weight: 700; }.step-copy { display: grid; gap: 3px; }.step-copy small { font-size: 11px; }.step-connector { width: 22px; min-width: 22px; align-self: center; height: 1px; background: #287596; }
.step-card { padding: 25px; border: 1px solid rgba(50, 162, 208, .42); border-radius: 18px; background: rgba(2, 18, 30, .58); }.step-desc { margin: 18px 0; color: #c0d2df; line-height: 1.8; }.matching-tip, .prd-note { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 20px; padding: 10px 13px; border-left: 3px solid #2ed4f0; border-radius: 5px; color: #a9ddeb; background: rgba(28, 122, 157, .15); font-size: 14px; line-height: 1.65; }
.matching-workspace { display: grid; grid-template-columns: minmax(0, 1.22fr) minmax(350px, .78fr); gap: 24px; align-items: start; }.module-list { display: grid; gap: 10px; }.module-row { display: grid; grid-template-columns: minmax(185px, .31fr) minmax(0, .69fr); min-height: 84px; overflow: hidden; border: 1px solid rgba(124, 166, 189, .45); border-radius: 10px; background: rgba(11, 39, 56, .48); transition: .18s ease; cursor: pointer; }.module-row--over { border-color: #48e3ff; box-shadow: 0 0 0 3px rgba(55, 215, 248, .16); }.module-row--filled { border-color: rgba(74, 220, 182, .56); }.module-label { display: flex; align-items: center; gap: 12px; padding: 16px; border-right: 1px solid rgba(124, 166, 189, .35); color: #eef9ff; background: rgba(13, 81, 113, .28); }.module-label span { display: grid; width: 27px; height: 27px; place-items: center; border-radius: 50%; color: #7ee6ff; background: rgba(11, 172, 214, .16); font-size: 12px; }.module-drop-zone { display: flex; align-items: center; padding: 10px 12px; }.drop-placeholder { color: #7696a8; font-size: 14px; }
.purpose-bank { position: sticky; top: 22px; padding: 18px; border: 1px solid rgba(66, 178, 226, .45); border-radius: 14px; background: linear-gradient(180deg, rgba(10, 71, 100, .38), rgba(7, 29, 45, .72)); transition: .18s ease; }.purpose-bank--over { border-color: #ffba67; box-shadow: 0 0 0 3px rgba(255, 186, 103, .16); }.purpose-bank__head strong { display: grid; width: 34px; height: 34px; place-items: center; border: 1px solid rgba(97, 222, 255, .58); border-radius: 50%; color: #8eeaff; }.purpose-bank > p { margin: 12px 0 15px; color: #9cb8c7; font-size: 13px; line-height: 1.65; }.purpose-card-list { display: grid; gap: 9px; max-height: 570px; overflow: auto; padding-right: 3px; }.purpose-card { display: flex; align-items: center; width: 100%; gap: 8px; padding: 11px 12px; border: 1px solid rgba(111, 158, 179, .5); border-radius: 8px; color: #e4f4fb; background: rgba(7, 31, 47, .72); text-align: left; line-height: 1.55; cursor: grab; transition: .18s ease; }.purpose-card:hover, .purpose-card.selected { border-color: #53ddfb; background: rgba(14, 116, 153, .43); }.purpose-card.dragging { opacity: .5; }.purpose-card--assigned { cursor: grab; }.purpose-card--assigned span { flex: 1; }.purpose-card--assigned button { display: inline-grid; width: 26px; height: 26px; flex: 0 0 26px; place-items: center; border: 0; border-radius: 50%; color: #b9ebfa; background: rgba(104, 196, 224, .17); cursor: pointer; }.purpose-card--assigned button:hover { color: #fff; background: #bd5068; }.drag-grip { color: #5fd5ed; letter-spacing: -3px; }.purpose-bank__empty { padding: 22px 8px; color: #82b3c8; text-align: center; }.result-message { margin: 20px 0 0; padding: 11px 14px; border: 1px solid rgba(255, 168, 112, .52); border-radius: 8px; color: #ffd5ad; background: rgba(157, 77, 35, .14); }.result-message.success { border-color: rgba(84, 230, 187, .56); color: #a6f4d8; background: rgba(16, 134, 99, .16); }.task-actions { justify-content: flex-end; margin-top: 21px; }
.prd-detail-card { overflow: hidden; }.prd-table-wrap { overflow: auto; border: 1px solid rgba(102, 184, 223, .5); border-radius: 12px; }.prd-detail-table { width: 100%; min-width: 1000px; border-collapse: separate; border-spacing: 0; table-layout: fixed; }.prd-detail-table th { padding: 13px; color: #effbff; background: linear-gradient(90deg, #087da9, #0b9bca); font-weight: 700; }.prd-detail-table td { padding: 12px; border-top: 1px solid rgba(115, 165, 188, .38); border-right: 1px solid rgba(115, 165, 188, .38); vertical-align: top; background: rgba(9, 38, 57, .44); }.prd-detail-table th:not(:last-child) { border-right: 1px solid rgba(212, 246, 255, .25); }.prd-detail-table td:last-child { border-right: 0; }.prd-detail-table th:nth-child(1), .module-number { width: 72px; text-align: center; }.prd-detail-table th:nth-child(2), .module-name { width: 155px; }.prd-detail-table th:nth-child(3), .submodule-name { width: 245px; }.module-number, .module-name, .submodule-name { color: #dff6ff; vertical-align: middle !important; }.module-name { background: rgba(10, 90, 122, .28) !important; font-weight: 700; }.submodule-name { background: rgba(20, 69, 92, .28) !important; }.detail-editor-cell { min-width: 480px; }.example-bar { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 9px; color: #7fa6ba; font-size: 13px; line-height: 1.6; }.example-bar button { flex: 0 0 auto; padding: 4px 8px; border: 1px solid rgba(96, 201, 237, .5); border-radius: 6px; color: #9deaff; background: rgba(5, 86, 124, .24); cursor: pointer; }.example-bar button:hover { color: #fff; background: rgba(12, 139, 182, .6); }.detail-editor-cell textarea { display: block; width: 100%; min-height: 92px; resize: vertical; box-sizing: border-box; padding: 10px; border: 1px solid rgba(108, 182, 215, .36); border-radius: 8px; outline: none; color: #eaf8ff; background: rgba(1, 18, 29, .62); font: inherit; line-height: 1.65; }.detail-editor-cell textarea:focus { border-color: #3ddff9; box-shadow: 0 0 0 3px rgba(55, 215, 248, .12); }.detail-editor-cell textarea::placeholder { color: #66899c; }.upcoming-card { text-align: center; }.upcoming-card h3 { margin: 8px 0; }.upcoming-card p:not(.section-eyebrow) { color: #a6c2d2; }
.file-input-hidden { display: none; }.prd-upload-card { overflow: hidden; }.prd-delivery-guide { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 22px; align-items: center; padding: 20px 22px; border: 1px solid rgba(72, 201, 255, .2); border-radius: 16px; background: linear-gradient(135deg, rgba(20, 86, 127, .28), rgba(8, 32, 54, .2)); }.prd-delivery-guide h3 { margin: 0 0 10px; font-size: 18px; color: #eef9ff; }.prd-delivery-guide ol { margin: 0; padding-left: 20px; color: #b8d3e2; line-height: 1.8; }.document-drop-zone { display: grid; min-height: 270px; place-content: center; justify-items: center; gap: 14px; padding: 30px; margin-top: 20px; border: 1px dashed rgba(81, 207, 255, .5); border-radius: 18px; background: radial-gradient(circle at 50% 0, rgba(41, 180, 235, .16), transparent 52%), rgba(8, 33, 52, .56); text-align: center; transition: .2s ease; }.document-drop-zone:hover { border-color: #4ad4ff; background-color: rgba(14, 65, 95, .66); transform: translateY(-2px); }.document-upload-icon { font-size: 42px; color: #42d8ff; }.document-drop-zone h3 { margin: 0; color: #eefaff; font-size: 21px; }.document-drop-zone p { max-width: 560px; margin: 0; color: #a6c2d2; line-height: 1.7; }.uploaded-document-card { display: flex; align-items: center; gap: 16px; padding: 18px 20px; margin-top: 20px; border: 1px solid rgba(69, 206, 255, .3); border-radius: 16px; background: rgba(9, 45, 70, .72); }.document-file-icon { flex: 0 0 auto; font-size: 32px; color: #55dcff; }.uploaded-document-main { min-width: 0; flex: 1; }.uploaded-document-main h3 { overflow: hidden; margin: 0 0 5px; color: #f0fbff; font-size: 17px; text-overflow: ellipsis; white-space: nowrap; }.uploaded-document-main p { margin: 0; color: #9cc1d4; font-size: 13px; }.uploaded-document-actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; gap: 9px; }.btn-danger { border: 1px solid rgba(255, 115, 127, .45); color: #ffacb3; background: rgba(116, 32, 48, .25); }.btn-danger:hover { border-color: #ff7884; background: rgba(150, 38, 55, .38); color: #fff; }.prd-pdf-preview { height: min(68vh, 780px); overflow: hidden; border: 1px solid #d8e4ea; border-radius: 10px; background: #fff; }.prd-pdf-preview iframe { width: 100%; height: 100%; border: 0; }.prd-text-preview { max-height: min(68vh, 780px); overflow: auto; padding: 20px; border: 1px solid #d8e4ea; border-radius: 10px; color: #263849; background: #f8fbfd; font-family: "Microsoft YaHei", sans-serif; line-height: 1.8; white-space: pre-wrap; word-break: break-word; }
@media (max-width: 1020px) { .matching-workspace { grid-template-columns: 1fr; }.purpose-bank { position: static; }.purpose-card-list { max-height: 360px; }.workbench-head { align-items: flex-start; flex-direction: column; } }
@media (max-width: 680px) { .training-core { width: min(100% - 24px, 1480px); padding-top: 16px; }.task-card, .step-card { padding: 15px; }.module-row { grid-template-columns: 1fr; }.module-label { border-right: 0; border-bottom: 1px solid rgba(124, 166, 189, .35); }.task-header { align-items: flex-start; flex-direction: column; }.task-title { font-size: 22px; }.step-indicator { margin: 18px 0; }.step-tab { min-width: 165px; }.workbench-actions, .task-actions { width: 100%; }.outline-action, .btn-nav { flex: 1; }.purpose-bank { padding: 14px; }.example-bar { flex-direction: column; } }
</style>

File diff suppressed because one or more lines are too long

@ -1,720 +1,226 @@
<template>
<ProductStandardTrainingPage class="test" task-key="release-channel-evaluation" title="发布渠道评估" badge-text="核心实训 · 产品上线与运营推广" background="电商产品发布渠道评估需要综合用户规模、用户质量、转化率、投资回报率、用户参与度及渠道成本,选择最具价值的投放渠道。" requirement="使用熵值法,根据曝光量、点击量、广告费用、订单金额等数据,评估各投放渠道优劣,为后续重点渠道加大投放提供决策依据。" :goals="taskGoals" @save="saveTask" @submit="submitTask" @reset="resetTask">
<template #step-content="{ activeStep, hasConfiguredSteps }">
<section v-show="!hasConfiguredSteps || activeStep === 1" class="release-phase">
<template>
<div class="release-channel-page student-training-shell">
<main class="training-core">
<section class="task-header">
<div class="task-badge"><el-icon><Document /></el-icon> 核心实训 · 产品上线与运营推广</div>
<h1>{{ taskTitle }}</h1>
</section>
<TrainingTaskBrief :background="taskBackground" :goals="taskGoals" :requirements="taskRequirement" :show-material="false" />
<p class="release-lead">
在电商产品投放测试中,熵值法可以用于评估不同投放策略或广告内容的效果。
<br />
<br />
通过收集多个反映投放效果的关键指标数据,如点击率、转化率、订单成交量等,利用熵值法计算各指标的权重,从而确定哪些指标对投放效果的影响更大。这有助于优化投放策略,提高广告效果和投资回报率。
</p>
<div class="caseBacktitle">
<span>实验实训</span>
<section class="task-card">
<header class="workbench-head">
<div>
<p class="section-eyebrow">STEP {{ String(activeStep).padStart(2, "0") }}</p>
<h2>{{ stepTitle }}</h2>
</div>
<TrainingMaterialButton :material-name="taskConfig?.materialName" :material-url="taskConfig?.materialUrl" />
</header>
<nav class="step-tabs" aria-label="产品发布渠道评估步骤">
<button type="button" :class="{ active: activeStep === 1 }" @click="activeStep = 1">01 权重设置</button>
<button type="button" :class="{ active: activeStep === 2 }" @click="activeStep = 2">02 渠道数据</button>
<button type="button" :class="{ active: activeStep === 3 }" @click="openStepThree">03 归一化评分</button>
<button type="button" :class="{ active: activeStep === 4 }" @click="openStepFour">04 渠道排名</button>
</nav>
<section v-if="activeStep === 1">
<p class="lead">不同维度对新品渠道效果的影响不一样,请给以下核心评估维度设置权重,按照重要性分配权重,总权重合计 100%。</p>
<div class="table-actions">
<el-button type="primary" @click="addWeightRow"><el-icon><Plus /></el-icon>增加一行</el-button>
<el-button @click="weightRows.pop()" :disabled="!weightRows.length"><el-icon><Minus /></el-icon>删除一行</el-button>
</div>
<p>任务背景:某仿真花店铺计划推出新品花束,因商品特性,计划在淘宝、抖音、拼多多三个渠道进行产品投放,初步投放测试数据如下,请评估上述渠道优劣,为后续重点投放提供决策依据。</p>
<el-table
:data="tableData"
border
style="width: 60%"
:span-method="arraySpanMethod"
>
<el-table-column prop="channel" label="渠道" align="center" />
<el-table-column prop="exposure" label="曝光量" align="center" />
<el-table-column prop="clicks" label="点击量" align="center" />
<el-table-column prop="cost" label="花费" align="center"></el-table-column>
<el-table-column prop="orderAmount" label="订单金额" align="center"></el-table-column>
</el-table>
<div class="table-wrap">
<table class="weight-table">
<thead><tr><th>评估维度</th><th>维度说明</th><th>权重占比(%)</th><th class="operation-column">操作</th></tr></thead>
<tbody>
<tr v-for="(row, index) in weightRows" :key="row.id">
<td><el-input v-model="row.dimension" maxlength="50" placeholder="填写评估维度" /></td>
<td><el-input v-model="row.description" maxlength="200" placeholder="填写维度说明" /></td>
<td><el-input-number v-model="row.weight" :min="0" :max="100" :precision="2" :step="1" controls-position="right" /><small v-if="row.suggestion">建议权重:{{ row.suggestion }}</small></td>
<td class="operation-column"><el-button link type="danger" @click="weightRows.splice(index, 1)">删除本行</el-button></td>
</tr>
<tr v-if="!weightRows.length"><td colspan="4" class="empty-row">暂无评估维度,请增加一行后填写。</td></tr>
</tbody>
<tfoot><tr><th colspan="2">当前权重合计</th><td :class="{ 'total-error': weightTotal !== 100 }">{{ displayWeightTotal }}%</td><td></td></tr></tfoot>
</table>
</div>
<p class="hint">默认展示建议的评估维度和权重占比。可修改任意单元格,也可逐行新增、删除;点击下一步时系统校验所有权重之和必须等于 100%。</p>
<footer class="footer-actions">
<el-button :loading="saving" @click="saveWeightStep">保存当前进度</el-button>
<el-button type="primary" :loading="validating" @click="goStepTwo">下一步<el-icon><ArrowRight /></el-icon></el-button>
</footer>
</section>
<section v-show="!hasConfiguredSteps || activeStep === 2" class="release-phase">
<p>数据标准化:</p>
<p>
对于正向指标(点击量、曝光量、订单金额),我们采用极差标准化方法:
<br />
标准化公式为:Yij = (Xij - min(Xi)) / (max(Xi) - min(Xi)) 例如,某渠道的点击量极差标准化值=(某渠道点击量-点击量最小值)/(点击量最大值-点击量最小值)
<br />
对于负向指标(数值越低越好),标准化公式为:Yij = (max(Xi) - Xij) / (max(Xi) - min(Xi))。 例如,某渠道的广告费用极差标准化值=(广告费最大值-某渠道广告费用)/(广告费用最大值-广告费用最小值)
</p>
<p>数据标准化结果如下所示:</p>
<el-table
:data="tableData1"
border
style="width: 60%"
:span-method="arraySpanMethod"
>
<el-table-column prop="channel" label="渠道" align="center" />
<el-table-column prop="exposureData" label="曝光量" align="center">
<template #default="{ row }">
<el-input v-model="row.exposureData" placeholder=""></el-input>
</template>
</el-table-column>
<el-table-column prop="clickData" label="点击量" align="center">
<template #default="{ row }">
<el-input v-model="row.clickData" placeholder=""></el-input>
</template>
</el-table-column>
<el-table-column prop="adCost" label="花费" align="center">
<template #default="{ row }">
<el-input v-model="row.adCost" placeholder=""></el-input>
</template>
</el-table-column>
<el-table-column prop="orderAmount" label="订单金额" align="center">
<template #default="{ row }">
<el-input v-model="row.orderAmount" placeholder=""></el-input>
</template>
</el-table-column>
</el-table>
<p>计算各指标在各方案下的比值:计算第j项指标在第i个样本中占该指标的比重,即pij = Yij / ΣYij。 例如,某渠道点击量比重=某渠道点击量极差标准化值/各渠道点击量极差标准化值之和</p>
<p>计算每个指标在每个渠道中的比重:</p>
<el-table
:data="tableData2"
border
style="width: 60%"
:span-method="arraySpanMethod"
>
<el-table-column prop="channel" label="渠道" align="center" />
<el-table-column prop="exposureData" label="曝光量" align="center">
<template #default="{ row }">
<el-input v-model="row.exposureData" placeholder=""></el-input>
</template>
</el-table-column>
<el-table-column prop="clickData" label="点击量" align="center">
<template #default="{ row }">
<el-input v-model="row.clickData" placeholder=""></el-input>
</template>
</el-table-column>
<el-table-column prop="adCost" label="花费" align="center">
<template #default="{ row }">
<el-input v-model="row.adCost" placeholder=""></el-input>
</template>
</el-table-column>
<el-table-column prop="orderAmount" label="订单金额" align="center">
<template #default="{ row }">
<el-input v-model="row.orderAmount" placeholder=""></el-input>
</template>
</el-table-column>
</el-table>
<section v-else-if="activeStep === 2">
<p class="lead">根据案例资料提供的 5 大渠道近 3 个月数据,汇总填写下表。</p>
<div class="table-actions">
<el-button type="primary" @click="addChannelRow"><el-icon><Plus /></el-icon>增加一行</el-button>
<el-button @click="channelRows.pop()" :disabled="!channelRows.length"><el-icon><Minus /></el-icon>删除一行</el-button>
<el-button type="primary" plain @click="exportChannelRows"><el-icon><Download /></el-icon>导出</el-button>
</div>
<div class="table-wrap">
<table class="channel-table">
<thead><tr><th>渠道</th><th>日均总搜索量</th><th>供需比</th><th>平均点击率(%)</th><th>平均CPC(元)</th><th>平均转化率(%)</th><th>现有相关访客数</th><th>匹配度(1-5)</th><th class="operation-column">操作</th></tr></thead>
<tbody>
<tr v-for="(row, index) in channelRows" :key="row.id">
<td><el-input v-model="row.channel" maxlength="30" placeholder="填写渠道" /></td>
<td><el-input-number v-model="row.dailySearchVolume" :min="0" :precision="0" controls-position="right" /></td>
<td><el-input-number v-model="row.supplyDemandRatio" :min="0" :precision="4" :step="0.1" controls-position="right" /></td>
<td><el-input-number v-model="row.averageClickRate" :min="0" :max="100" :precision="2" controls-position="right" /></td>
<td><el-input-number v-model="row.averageCpc" :min="0" :precision="2" controls-position="right" /></td>
<td><el-input-number v-model="row.averageConversionRate" :min="0" :max="100" :precision="2" controls-position="right" /></td>
<td><el-input-number v-model="row.existingVisitorCount" :min="0" :precision="0" controls-position="right" /></td>
<td><el-input-number v-model="row.matchingScore" :min="1" :max="5" :precision="0" controls-position="right" /></td>
<td class="operation-column"><el-button link type="danger" @click="channelRows.splice(index, 1)">删除本行</el-button></td>
</tr>
<tr v-if="!channelRows.length"><td colspan="9" class="empty-row">暂无渠道数据,请增加一行后填写。</td></tr>
</tbody>
</table>
</div>
<p class="hint">默认显示天猫、京东、抖音、拼多多、唯品会 5 个渠道;可继续新增或删除任意渠道。保存当前进度不拦截,点击下一步时由系统校验全部指标。</p>
<footer class="footer-actions">
<el-button @click="activeStep = 1">上一步</el-button>
<el-button :loading="saving" @click="saveChannelStep">保存当前进度</el-button>
<el-button type="primary" :loading="validating" @click="validateChannelStep">下一步<el-icon><ArrowRight /></el-icon></el-button>
</footer>
</section>
<section v-show="!hasConfiguredSteps || activeStep === 3" class="release-phase">
<p>计算各指标的信息熵:根据信息熵的定义,计算各指标的信息熵Ej。公式为:Ej = -k * Σ(pij * ln(pij)),其中k = 1 / ln(m),m为样本数量。</p>
<p>例如,我们评估了三种渠道,那么公式中m为3,例如点击量信息熵=-k*(各渠道点击量比重乘以各渠道点击量比重的自然对数之和)</p>
<p>计算每个指标的信息熵:</p>
<el-table
:data="tableData3"
border
style="width: 60%"
:span-method="arraySpanMethod"
>
<el-table-column prop="exposureData" label="曝光量" align="center">
<template #default="{ row }">
<el-input v-model="row.exposureData" placeholder=""></el-input>
</template>
</el-table-column>
<el-table-column prop="clickData" label="点击量" align="center">
<template #default="{ row }">
<el-input v-model="row.clickData" placeholder=""></el-input>
</template>
</el-table-column>
<el-table-column prop="adCost" label="花费" align="center">
<template #default="{ row }">
<el-input v-model="row.adCost" placeholder=""></el-input>
</template>
</el-table-column>
<el-table-column prop="orderAmount" label="订单金额" align="center">
<template #default="{ row }">
<el-input v-model="row.orderAmount" placeholder=""></el-input>
</template>
</el-table-column>
</el-table>
<p>确定各指标的权重:根据信息熵计算各指标的权重。权重计算公式为:wj = (1 - Ej) / Σ(1 - Ej)。其中Ej源于上一步计算,为各指标信息熵。</p>
<p>计算每个指标的权重:</p>
<el-table
:data="tableData4"
border
style="width: 60%"
:span-method="arraySpanMethod"
>
<el-table-column prop="exposureData" label="曝光量" align="center">
<template #default="{ row }">
<el-input v-model="row.exposureData" placeholder=""></el-input>
</template>
</el-table-column>
<el-table-column prop="clickData" label="点击量" align="center">
<template #default="{ row }">
<el-input v-model="row.clickData" placeholder=""></el-input>
</template>
</el-table-column>
<el-table-column prop="adCost" label="花费" align="center">
<template #default="{ row }">
<el-input v-model="row.adCost" placeholder=""></el-input>
</template>
</el-table-column>
<el-table-column prop="orderAmount" label="订单金额" align="center">
<template #default="{ row }">
<el-input v-model="row.orderAmount" placeholder=""></el-input>
</template>
</el-table-column>
</el-table>
<section v-else-if="activeStep === 3">
<p class="scoring-guide">通过 Min-Max 归一法将各维度原始数据转换为 0–10 分标准化得分。正向指标(搜索量、点击率、转化率、访客数、匹配度)按“渠道值 − 最小值”计算;逆向指标(供需比、CPC)按“最大值 − 渠道值”计算。请根据前两步数据手工填写得分与加权得分。</p>
<div class="table-wrap scoring-table-wrap">
<table class="scoring-table">
<thead>
<tr>
<th rowspan="2">渠道</th>
<th v-for="metric in scoringMetrics" :key="metric.key" colspan="2">{{ metric.label }}</th>
<th rowspan="2">加权得分</th>
</tr>
<tr>
<template v-for="metric in scoringMetrics" :key="`${metric.key}-sub`"><th>得分</th><th>权重</th></template>
</tr>
</thead>
<tbody>
<tr v-for="row in scoreRows" :key="row.id">
<th scope="row">{{ row.channel }}</th>
<template v-for="metric in row.dimensions" :key="metric.key">
<td><el-input-number v-model="metric.score" :min="0" :max="10" :precision="1" :step="0.1" controls-position="right" /></td>
<td class="weight-cell">{{ metric.weight ?? "—" }}%</td>
</template>
<td><el-input-number v-model="row.weightedScore" :min="0" :precision="2" :step="0.01" controls-position="right" /></td>
</tr>
<tr v-if="!scoreRows.length"><td :colspan="2 + scoringMetrics.length * 2" class="empty-row">请先完成并保存第二步渠道数据。</td></tr>
</tbody>
</table>
</div>
<p class="hint">渠道名称同步第二步,权重同步第一步。每项得分乘以对应权重(按百分比折算)并相加得到加权得分;下一步会按已保存的前两步数据重新计算并校验。</p>
<footer class="footer-actions">
<el-button @click="activeStep = 2">上一步</el-button>
<el-button :loading="saving" @click="saveScoreStep">保存当前进度</el-button>
<el-button type="primary" :loading="validating" @click="validateScoreStep">下一步<el-icon><ArrowRight /></el-icon></el-button>
</footer>
</section>
<section v-show="!hasConfiguredSteps || activeStep >= 4" class="release-phase">
<p>综合评分:根据权重和指标数据,计算广告效果的综合评分。公式为:Zi = Σ(wj * Yij)。 例如,某渠道综合评分等于渠道各指标权重乘以渠道各指标极差标准值的和。</p>
<el-table
:data="tableData5"
border
style="width: 60%"
:span-method="arraySpanMethod"
>
<el-table-column prop="channel" label="渠道" align="center"></el-table-column>
<el-table-column prop="keywords" label="综合评分" align="center">
<template #default="{ row }">
<el-input v-model="row.keywords" placeholder=""></el-input>
</template>
</el-table-column>
</el-table>
<p>请根据评分情况分析各渠道优劣,分析熵值法优缺点。</p>
<el-input :autosize="{ minRows: 11, maxRows: 11 }" type="textarea" v-model="scenePromotion" placeholder="" />
<p>上述实训也可以用Python代码实现,可点击查看示例代码,在在线Python环境中运行代码,得出结果。</p>
<el-button @click="runResultShow = true">查看代码</el-button>
<section v-else>
<p class="lead">根据步骤一设置的权重和步骤三的归一化得分,填写各渠道加权综合得分并排序,明确前两位核心渠道。</p>
<div class="table-wrap ranking-table-wrap">
<table class="ranking-table">
<thead><tr><th>排名</th><th>渠道</th><th>加权综合得分</th><th>是否选为核心渠道</th></tr></thead>
<tbody>
<tr v-for="(row, index) in rankingRows" :key="row.id">
<td class="rank-cell">{{ index + 1 }}</td>
<td><el-input v-model="row.channel" maxlength="30" placeholder="填写渠道" /></td>
<td><el-input-number v-model="row.weightedScore" :min="0" :precision="2" :step="0.01" controls-position="right" /></td>
<td class="core-cell"><span :class="{ core: index < 2 }">{{ index < 2 ? "是" : "否" }}</span></td>
</tr>
<tr v-if="!rankingRows.length"><td colspan="4" class="empty-row">请先完成并保存第三步归一化评分。</td></tr>
</tbody>
</table>
</div>
<label class="analysis-field"><span>请根据以上计算结果,结合案例文档内容,分析所选核心渠道的优势及运营建议:</span><el-input v-model="coreChannelAnalysis" type="textarea" :autosize="{ minRows: 7, maxRows: 14 }" placeholder="填写核心渠道优势、资源投入重点和运营建议" /></label>
<p class="hint">渠道数量同步第三步,排名自动从 1 往下排列。渠道与加权综合得分需由学生填写,并与第三步数据逐项一致;得分必须按从高到低排序,前两名自动显示为核心渠道。</p>
<footer class="footer-actions">
<el-button @click="activeStep = 3">上一步</el-button>
<el-button :loading="saving" @click="saveRankingStep">保存当前进度</el-button>
<el-button type="primary" :loading="validating" @click="validateRankingStep">提交任务</el-button>
</footer>
</section>
</template>
</ProductStandardTrainingPage>
<pop-model :showModel="runResultShow" title="设置代码" @closePop="closeCode">
<template v-slot:content>
<div>
<codemirror :code="code"></codemirror>
</section>
</main>
<TrainingAiSidebar :study-tip="'先确定可解释的评估权重,再完整汇总各渠道近三个月数据。'" :progress-items="progressItems" />
</div>
</template>
</pop-model>
</template>
<script setup>
import ProductStandardTrainingPage from "@/views/product/components/ProductStandardTrainingPage.vue";
import useUserStore from "@/store/modules/user";
const userStore = useUserStore();
import * as analysisAPI from "@/api/market-demand-mining.js";
import codemirror from "@/components/codemirror/index.vue";
import popModel from "@/views/components/popModal.vue";
import { getCurrentInstance, onMounted, ref } from "vue";
const { proxy } = getCurrentInstance();
const taskGoals = ["理解熵值法的指标标准化、比重和信息熵计算过程", "能够计算渠道指标权重与综合评分", "基于评价结果形成可执行的发布渠道投放建议"];
const code = `import numpy as np
import pandas as pd
# 数据
data = {
'渠道': ['淘宝', '抖音', '拼多多'],
'点击量': [952, 161, 239],
'曝光量': [25379, 4719, 24202],
'花费': [3617, 1213, 425],
'订单金额': [12293, 5206, 880]
}
df = pd.DataFrame(data)
df.set_index('渠道', inplace=True)
# 数据标准化
def normalize_data(df):
normalized_df = df.copy()
for column in df.columns:
max_val = df[column].max()
min_val = df[column].min()
if max_val == min_val:
normalized_df[column] = 0
else:
normalized_df[column] = (df[column] - min_val) / (max_val - min_val)
return normalized_df
normalized_df = normalize_data(df)
# 计算各指标在各方案下的比值
def calculate_pij(normalized_df):
pij = normalized_df.copy()
for column in pij.columns:
column_sum = pij[column].sum()
pij[column] = pij[column] / column_sum
return pij
pij = calculate_pij(normalized_df)
# 计算各指标的信息熵
def calculate_entropy(pij, m):
k = 1 / np.log(m)
entropy = {}
for column in pij.columns:
entropy_sum = 0
for value in pij[column]:
if value > 0:
entropy_sum += value * np.log(value)
entropy[column] = -k * entropy_sum
return entropy
m = len(pij)
entropy = calculate_entropy(pij, m)
# 计算各指标的权重
def calculate_weights(entropy):
weights = {}
total_entropy = sum(1 - value for value in entropy.values())
for column, value in entropy.items():
weights[column] = (1 - value) / total_entropy
return weights
weights = calculate_weights(entropy)
# 计算综合评分
def calculate_comprehensive_score(normalized_df, weights):
comprehensive_score = pd.Series(index=normalized_df.index, dtype=float)
for index, row in normalized_df.iterrows():
score = 0
for column, weight in weights.items():
score += weight * row[column]
comprehensive_score[index] = score
return comprehensive_score
comprehensive_score = calculate_comprehensive_score(normalized_df, weights)
# 输出结果
print("标准化后的数据:")
print(normalized_df)
print("各指标的信息熵:")
print(entropy)
print("各指标的权重:")
print(weights)
print("综合评分:")
print(comprehensive_score)`;
const runResultShow = ref(false);
const tableData = [
{
channel: "淘宝",
exposure: 25379,
clicks: 952,
cost: 3617,
orderAmount: 12293,
},
{
channel: "抖音",
exposure: 4719,
clicks: 161,
cost: 1213,
orderAmount: 5206,
},
{
channel: "拼多多",
exposure: 24202,
clicks: 459,
cost: 625,
orderAmount: 1280,
},
<script setup>
import { computed, onMounted, ref } from "vue";
import { ArrowRight, Document, Download, Minus, Plus } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
import { getTrainingTaskByKey } from "@/api/trainingTask";
import { getStudentTrainingAnswer, saveStudentTrainingAnswer, validateReleaseChannelEvaluationStepOne, validateReleaseChannelEvaluationStepTwo, validateReleaseChannelEvaluationStepThree, validateReleaseChannelEvaluationStepFour } from "@/api/studentTrainingAnswer";
import { parseTrainingArray } from "@/views/training/taskKeyMap";
const TASK_KEY = "release-channel-evaluation";
const activeStep = ref(1);
const taskConfig = ref(null);
const saving = ref(false);
const validating = ref(false);
let nextId = 1;
const makeRow = (values = {}) => ({ id: nextId++, ...values });
const defaultWeightRows = () => [
["市场规模潜力", "对应关键词日均总搜索量,代表渠道用户需求大盘空间", 20, "20~25"], ["竞争激烈程度", "供需比(在线商品数/日均搜索量),数值越低渠道竞争越宽松", 15, "15~20"], ["流量点击效率", "品类平均点击率,代表渠道用户对该品类的点击意愿强弱", 15, "12~18"], ["流量获取成本", "平均CPC,数值越低获取精准用户的推广成本越低", 15, "15~20"], ["流量转化效率", "品类平均转化率,代表渠道用户的最终下单意愿", 15, "12~18"], ["现有用户基础", "店铺现有该品类相关访客数,代表品牌存量用户承接能力", 10, "5~10"], ["匹配度", "渠道属性与产品定位的适配度打分(1-10分)", 10, "5~10"],
].map(([dimension, description, weight, suggestion]) => makeRow({ dimension, description, weight, suggestion }));
const defaultChannelRows = () => ["天猫", "京东", "抖音", "拼多多", "唯品会"].map((channel) => makeRow({ channel }));
const weightSuggestions = Object.fromEntries(defaultWeightRows().map((row) => [row.dimension, row.suggestion]));
const weightRows = ref(defaultWeightRows());
const channelRows = ref(defaultChannelRows());
const scoreRows = ref([]);
const rankingRows = ref([]);
const coreChannelAnalysis = ref("");
const scoringMetrics = [
{ key: "marketScale", dimension: "市场规模潜力", label: "市场规模得分" },
{ key: "competition", dimension: "竞争激烈程度", label: "竞争程度得分" },
{ key: "click", dimension: "流量点击效率", label: "点击效率得分" },
{ key: "cost", dimension: "流量获取成本", label: "获客成本得分" },
{ key: "conversion", dimension: "流量转化效率", label: "转化效率得分" },
{ key: "userBase", dimension: "现有用户基础", label: "用户基础得分" },
{ key: "match", dimension: "匹配度", label: "匹配度得分" },
];
const tableData1 = ref([
{
channel: "淘宝",
exposureData: "",
clickData: "",
adCost: "",
orderAmount: "",
},
{
channel: "抖音",
exposureData: "",
clickData: "",
adCost: "",
orderAmount: "",
},
{
channel: "拼多多",
exposureData: "",
clickData: "",
adCost: "",
orderAmount: "",
},
]);
const tableData2 = ref([
{
channel: "淘宝",
exposureData: "",
clickData: "",
adCost: "",
orderAmount: "",
},
{
channel: "抖音",
exposureData: "",
clickData: "",
adCost: "",
orderAmount: "",
},
{
channel: "拼多多",
exposureData: "",
clickData: "",
adCost: "",
orderAmount: "",
},
]);
const tableData3 = ref([
{
exposureData: "",
clickData: "",
adCost: "",
orderAmount: "",
},
]);
const tableData4 = ref([
{
exposureData: "",
clickData: "",
adCost: "",
orderAmount: "",
},
]);
const tableData5 = ref([
{
channel: "淘宝",
keywords: "",
},
{
channel: "抖音",
keywords: "",
},
{
channel: "拼多多",
keywords: "",
},
]);
const scenePromotion = ref("");
function checkEmptyValues(tables) {
const result = {
hasEmpty: false,
emptyItems: [],
};
tables.forEach((table, tableIndex) => {
if (!Array.isArray(table)) return;
table.forEach((row, rowIndex) => {
const emptyFields = Object.entries(row)
.filter(([key, value]) => value === "" || value === null || value === undefined)
.map(([key]) => key);
if (emptyFields.length > 0) {
result.hasEmpty = true;
result.emptyItems.push({
tableIndex: tableIndex + 1,
rowIndex: rowIndex + 1,
channel: row.channel || "无渠道字段",
emptyFields,
});
}
});
});
return result;
}
const closeCode = () => {
runResultShow.value = false;
};
function mapData(
sourceArray,
fieldMapping = {
exposureData: "exposureData",
clickData: "clickData",
adCost: "adCost",
orderAmount: "orderAmount",
}
) {
return sourceArray.map((item) => {
const mappedItem = {};
for (const [targetKey, sourceKey] of Object.entries(fieldMapping)) {
mappedItem[targetKey] = item[sourceKey];
}
return mappedItem;
});
}
const buildTaskParams = () => ({
userId: userStore.userInfo.userId,
dataInfoList: mapData(tableData1.value),
radioInfoList: mapData(tableData2.value),
messageInfoList: mapData(tableData3.value),
eventInfoList: mapData(tableData4.value),
keywords: tableData5.value.map((item) => item.keywords).join(","),
scenePromotion: scenePromotion.value,
});
const saveTask = () => analysisAPI.releaseChannelSubmit(buildTaskParams()).then(() => {
proxy.$modal.msgSuccess("当前进度已保存");
});
const submitTask = () => {
const tablesToCheck = [tableData1.value, tableData2.value, tableData3.value, tableData4.value, tableData5.value];
const checkResult = checkEmptyValues(tablesToCheck);
if (checkResult.hasEmpty) {
proxy.$modal.msgWarning("请完善数据后提交");
return;
}
analysisAPI.releaseChannelSubmit(buildTaskParams()).then(() => {
proxy.$modal.msgSuccess("提交成功");
});
};
// 回显数据
const loadTaskInfo = () => {
analysisAPI.releaseChannelEcho({ userId: userStore.userInfo.userId }).then((res) => {
if (!res.data) return;
res.data?.dataInfoList.forEach((item, index) => {
tableData1.value[index].exposureData = item.exposureData;
tableData1.value[index].clickData = item.clickData;
tableData1.value[index].adCost = item.adCost;
tableData1.value[index].orderAmount = item.orderAmount;
});
res.data?.radioInfoList.forEach((item, index) => {
tableData2.value[index].exposureData = item.exposureData;
tableData2.value[index].clickData = item.clickData;
tableData2.value[index].adCost = item.adCost;
tableData2.value[index].orderAmount = item.orderAmount;
});
tableData3.value = res.data.messageInfoList;
tableData4.value = res.data.eventInfoList;
res.data?.keywords.split(",").forEach((item, index) => {
tableData5.value[index].keywords = item;
});
scenePromotion.value = res.data.scenePromotion;
});
};
// 重新实训
const resetTask = () => {
analysisAPI.productReleaseReTrain({ userId: userStore.userInfo.userId, module: "发布渠道评估" }).then((res) => {
proxy.$modal.msgSuccess("重新实训成功");
scenePromotion.value = "";
clearData([tableData1.value, tableData2.value, tableData3.value, tableData4.value, tableData5.value]);
});
};
// 清空数据函数
const clearData = (tables) => {
tables.forEach((table) => {
table.forEach((row) => {
Object.keys(row).forEach((key) => {
if (key !== "channel") {
row[key] = "";
}
});
});
});
};
const go = () => {
window.open("https://jrdsj.sztzjy.com:8000/user/zy84cf311/lab");
};
onMounted(() => {
loadTaskInfo();
});
const taskTitle = computed(() => taskConfig.value?.taskName || "产品发布渠道评估");
const stepTitle = computed(() => ["评估维度与权重设置", "渠道基础数据汇总", "Min-Max 归一化与加权评分", "渠道综合排名与核心渠道选择"][activeStep.value - 1] || "产品发布渠道评估");
const taskBackground = computed(() => taskConfig.value?.background || "结合新品定位与渠道特征,为产品发布选择更合适的投放渠道。");
const taskRequirement = computed(() => taskConfig.value?.requirements || "设置渠道评估维度与权重,并汇总渠道基础数据。");
const taskGoals = computed(() => parseTrainingArray(taskConfig.value?.objectives, ["理解渠道评估的核心维度", "能够按重要性配置评估权重", "汇总渠道数据并完成评估基础"]));
const weightTotal = computed(() => Number(weightRows.value.reduce((sum, row) => sum + (Number(row.weight) || 0), 0).toFixed(6)));
const displayWeightTotal = computed(() => Number.isInteger(weightTotal.value) ? String(weightTotal.value) : weightTotal.value.toFixed(2));
const progressItems = computed(() => parseTrainingArray(taskConfig.value?.steps).map((text, index) => ({ text, status: index + 1 < activeStep.value ? "done" : index + 1 === activeStep.value ? "doing" : "" })));
const weightPayload = () => ({ rows: weightRows.value.map(({ dimension, description, weight }) => ({ dimension, description, weight })) });
const channelPayload = () => ({ rows: channelRows.value.map(({ channel, dailySearchVolume, supplyDemandRatio, averageClickRate, averageCpc, averageConversionRate, existingVisitorCount, matchingScore }) => ({ channel, dailySearchVolume, supplyDemandRatio, averageClickRate, averageCpc, averageConversionRate, existingVisitorCount, matchingScore })) });
const scorePayload = () => ({ rows: scoreRows.value.map((row) => ({ channel: row.channel, weightedScore: row.weightedScore, dimensions: row.dimensions.map(({ dimension, score, weight }) => ({ dimension, score, weight })) })) });
const rankingPayload = () => ({ rows: rankingRows.value.map(({ channel, weightedScore }) => ({ channel, weightedScore })), coreChannelAnalysis: coreChannelAnalysis.value });
function addWeightRow() { weightRows.value.push(makeRow({ dimension: "", description: "", weight: undefined, suggestion: "" })); }
function addChannelRow() { channelRows.value.push(makeRow({ channel: "" })); }
async function saveAnswer(currentStep, fields) { await saveStudentTrainingAnswer(TASK_KEY, { saveAction: "SAVE", currentStep, ...fields }); }
async function saveWeightStep() { if (saving.value) return; saving.value = true; try { await saveAnswer(1, { step1Answer: JSON.stringify(weightPayload()) }); ElMessage.success("当前进度已保存"); } finally { saving.value = false; } }
async function goStepTwo() { if (validating.value) return; validating.value = true; try { await validateReleaseChannelEvaluationStepOne(weightPayload()); await saveAnswer(2, { step1Answer: JSON.stringify(weightPayload()) }); activeStep.value = 2; ElMessage.success("权重校验通过,已进入第二步"); } catch (error) { ElMessage.error(error?.message || "请检查评估维度和权重设置"); } finally { validating.value = false; } }
async function saveChannelStep() { if (saving.value) return; saving.value = true; try { await saveAnswer(2, { step2Answer: JSON.stringify(channelPayload()) }); ElMessage.success("当前进度已保存"); } finally { saving.value = false; } }
async function validateChannelStep() { if (validating.value) return; validating.value = true; try { await validateReleaseChannelEvaluationStepTwo(channelPayload()); await saveAnswer(3, { step2Answer: JSON.stringify(channelPayload()) }); syncScoreRows(); activeStep.value = 3; ElMessage.success("渠道数据校验通过,已进入第三步"); } catch (error) { ElMessage.error(error?.message || "请检查渠道数据填写情况"); } finally { validating.value = false; } }
function syncScoreRows(savedRows = []) { const savedByChannel = new Map(savedRows.map((row) => [row?.channel, row])); const weights = Object.fromEntries(weightRows.value.map((row) => [row.dimension, row.weight])); scoreRows.value = channelRows.value.map((channelRow) => { const saved = savedByChannel.get(channelRow.channel); const savedScores = new Map((saved?.dimensions || []).map((dimension) => [dimension?.dimension, dimension])); return makeRow({ channel: channelRow.channel, weightedScore: saved?.weightedScore, dimensions: scoringMetrics.map((metric) => makeRow({ key: metric.key, dimension: metric.dimension, weight: weights[metric.dimension], score: savedScores.get(metric.dimension)?.score })) }); }); }
function openStepThree() { syncScoreRows(scoreRows.value); activeStep.value = 3; }
async function saveScoreStep() { if (saving.value) return; saving.value = true; try { await saveAnswer(3, { step3Answer: JSON.stringify(scorePayload()) }); ElMessage.success("当前进度已保存"); } finally { saving.value = false; } }
async function validateScoreStep() { if (validating.value) return; validating.value = true; try { await validateReleaseChannelEvaluationStepThree(scorePayload()); await saveAnswer(4, { step3Answer: JSON.stringify(scorePayload()) }); syncRankingRows(); activeStep.value = 4; ElMessage.success("归一化得分与加权得分校验通过,已进入第四步"); } catch (error) { ElMessage.error(error?.message || "请检查归一化得分和加权得分"); } finally { validating.value = false; } }
function syncRankingRows(savedRows = []) { const savedByChannel = new Map(savedRows.map((row) => [row?.channel, row])); rankingRows.value = scoreRows.value.map((scoreRow) => { const saved = savedByChannel.get(scoreRow.channel); return makeRow({ channel: saved?.channel ?? "", weightedScore: saved?.weightedScore }); }); }
function openStepFour() { syncRankingRows(rankingRows.value); activeStep.value = 4; }
async function saveRankingStep() { if (saving.value) return; saving.value = true; try { await saveAnswer(4, { step4Answer: JSON.stringify(rankingPayload()) }); ElMessage.success("当前进度已保存"); } finally { saving.value = false; } }
async function validateRankingStep() { if (validating.value) return; validating.value = true; try { await validateReleaseChannelEvaluationStepFour(rankingPayload()); await saveStudentTrainingAnswer(TASK_KEY, { saveAction: "SUBMIT", currentStep: 4, step4Answer: JSON.stringify(rankingPayload()) }); ElMessage.success("渠道排名校验通过,任务已提交"); } catch (error) { ElMessage.error(error?.message || "请检查渠道排名和核心渠道运营建议"); } finally { validating.value = false; } }
function exportChannelRows() { const headers = ["渠道", "日均总搜索量", "供需比", "平均点击率(%)", "平均CPC(元)", "平均转化率(%)", "现有相关访客数", "匹配度(1-5)"]; const escape = (value) => `"${String(value ?? "").replaceAll('"', '""')}"`; const lines = [headers, ...channelRows.value.map((row) => [row.channel, row.dailySearchVolume, row.supplyDemandRatio, row.averageClickRate, row.averageCpc, row.averageConversionRate, row.existingVisitorCount, row.matchingScore])].map((row) => row.map(escape).join(",")); const url = URL.createObjectURL(new Blob([`\uFEFF${lines.join("\n")}`], { type: "text/csv;charset=utf-8" })); const link = document.createElement("a"); link.href = url; link.download = "产品发布渠道评估-渠道数据.csv"; link.click(); URL.revokeObjectURL(url); }
function restoreRows(raw, target, defaults, fields) { try { const saved = typeof raw === "string" ? JSON.parse(raw || "{}") : raw; if (!Array.isArray(saved?.rows)) return; target.value = saved.rows.map((row) => { const value = Object.fromEntries(fields.map((field) => [field, row?.[field]])); return makeRow(target === weightRows ? { ...value, suggestion: weightSuggestions[value.dimension] || "" } : value); }); } catch (error) { target.value = defaults(); } }
async function loadProgress() { try { const result = await getStudentTrainingAnswer(TASK_KEY); const answer = result?.data; restoreRows(answer?.step1Answer, weightRows, defaultWeightRows, ["dimension", "description", "weight"]); restoreRows(answer?.step2Answer, channelRows, defaultChannelRows, ["channel", "dailySearchVolume", "supplyDemandRatio", "averageClickRate", "averageCpc", "averageConversionRate", "existingVisitorCount", "matchingScore"]); let savedScores = []; try { const saved = typeof answer?.step3Answer === "string" ? JSON.parse(answer.step3Answer || "{}") : answer?.step3Answer; savedScores = Array.isArray(saved?.rows) ? saved.rows : []; } catch (error) { savedScores = []; } syncScoreRows(savedScores); let savedRanking = []; try { const saved = typeof answer?.step4Answer === "string" ? JSON.parse(answer.step4Answer || "{}") : answer?.step4Answer; savedRanking = Array.isArray(saved?.rows) ? saved.rows : []; coreChannelAnalysis.value = String(saved?.coreChannelAnalysis || ""); } catch (error) { savedRanking = []; coreChannelAnalysis.value = ""; } syncRankingRows(savedRanking); activeStep.value = Math.min(Math.max(Number(answer?.currentStep) || 1, 1), 4); } catch (error) { syncScoreRows(); syncRankingRows(); } }
onMounted(async () => { try { const result = await getTrainingTaskByKey(TASK_KEY); taskConfig.value = result?.data || null; } catch (error) { taskConfig.value = null; } await loadProgress(); });
</script>
<style lang="scss" scoped>
.test {
--test-panel: rgba(0, 25, 42, 0.64);
--test-panel-strong: rgba(1, 14, 26, 0.82);
--test-line: rgba(82, 174, 226, 0.42);
min-height: calc(100vh - 76px);
padding: 24px 24px 32px;
color: #eef5ff;
background: #06111d;
@media (max-width: 1100px) {
& {
grid-template-columns: 1fr;
}
}
:deep(.el-table) {
--el-table-border-color: rgba(77, 167, 220, 0.2);
--el-table-header-bg-color: rgba(8, 55, 82, 0.98);
--el-table-header-text-color: #eaffff;
--el-table-row-hover-bg-color: rgba(11, 84, 122, 0.45);
--el-table-bg-color: rgba(1, 14, 26, 0.72);
--el-table-tr-bg-color: rgba(1, 14, 26, 0.48);
--el-table-text-color: #d7f1ff;
overflow: hidden;
margin: 12px 0 20px;
border: 1px solid rgba(0, 180, 255, 0.22);
border-radius: 22px;
background: var(--test-panel-strong);
}
:deep(.el-table::before),
:deep(.el-table__inner-wrapper::before) {
background-color: transparent;
}
:deep(.el-table th.el-table__cell) {
color: #eaffff !important;
background: rgba(8, 55, 82, 0.98) !important;
font-size: 14px;
font-weight: 900;
}
:deep(.el-table tr),
:deep(.el-table td.el-table__cell) {
color: #d7f1ff !important;
background: rgba(1, 14, 26, 0.48) !important;
}
:deep(.el-table--enable-row-hover .el-table__body tr:hover > td) {
background: rgba(11, 84, 122, 0.45) !important;
}
:deep(.el-table .cell) {
line-height: 1.65;
}
:deep(.el-input__wrapper),
:deep(.el-textarea__inner) {
border: 1px solid rgba(45, 95, 126, 0.95);
border-radius: 14px;
color: #eef5ff;
box-shadow: none !important;
background: #0f1a24;
}
:deep(.el-input__wrapper) {
min-height: 38px;
}
:deep(.el-input__inner),
:deep(.el-textarea__inner) {
color: #eef5ff;
}
:deep(.el-input__inner::placeholder),
:deep(.el-textarea__inner::placeholder) {
color: #8299aa;
}
:deep(.el-input__wrapper.is-focus),
:deep(.el-textarea__inner:focus) {
border-color: rgba(92, 224, 255, 0.7);
box-shadow: 0 0 0 3px rgba(0, 174, 255, 0.13) !important;
}
:deep(.el-textarea) {
display: block;
max-width: 960px;
margin-top: 12px;
}
:deep(.el-textarea__inner) {
min-height: 110px !important;
padding: 12px 14px;
line-height: 1.65;
}
:deep(.el-button:not(.training-action)) {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 40px;
padding: 0 18px;
border: 1px solid rgba(99, 217, 255, 0.55) !important;
border-radius: 999px;
color: #e9fbff !important;
font-weight: 800;
background: rgba(17, 126, 178, 0.36) !important;
transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease;
}
:deep(.el-button:not(.training-action):hover:not(.is-disabled)) {
transform: translateY(-1px);
border-color: rgba(116, 235, 255, 0.85) !important;
background: rgba(18, 146, 204, 0.5) !important;
}
.release-phase {
display: grid;
gap: 14px;
}
.release-phase > p {
max-width: 1100px;
margin: 0;
color: #d7ecff;
font-size: 15px;
line-height: 1.8;
}
.release-phase .release-lead {
padding: 16px;
border: 1px solid rgba(0, 180, 255, 0.22);
border-radius: 16px;
background: rgba(1, 14, 26, 0.72);
}
.release-phase .caseBacktitle {
display: inline-flex;
align-items: center;
width: fit-content;
min-height: 28px;
padding: 4px 12px;
border: 1px solid rgba(106, 226, 255, 0.45);
border-radius: 999px;
color: #71dfff;
background: rgba(0, 127, 184, 0.25);
font-size: 12px;
font-weight: 800;
}
.release-phase :deep(.el-table) {
width: 100% !important;
max-width: 1100px;
margin: 0;
}
.release-phase :deep(.el-input__wrapper) {
min-height: 42px;
}
.release-phase :deep(.el-button) {
width: fit-content;
border-color: rgba(92, 224, 255, 0.7) !important;
color: #ffffff !important;
background: linear-gradient(95deg, #0b84bd, #096491) !important;
}
@media (max-width: 900px) {
& {
padding: 14px 14px 18px;
}
.release-phase :deep(.el-button) {
width: 100%;
}
}
}
<style scoped lang="scss">
.release-channel-page{display:grid;grid-template-columns:minmax(720px,1fr) 340px;gap:24px;min-height:calc(100vh - 76px);padding:24px;color:#eef5ff;background:#06111d}.training-core{min-width:0;padding:28px;border:1px solid rgba(0,180,255,.25);border-radius:30px;background:rgba(8,18,28,.6)}.task-header{margin-bottom:24px}.task-badge{display:inline-flex;gap:8px;align-items:center;padding:5px 14px;border:1px solid rgba(99,217,255,.55);border-radius:999px;color:#dff5ff;background:rgba(17,126,178,.36);font-size:12px;font-weight:700}.task-header h1{margin:12px 0 0;color:#fff;font-size:30px}.task-card{margin-top:28px;padding:24px;border:1px solid rgba(82,174,226,.42);border-radius:24px;background:rgba(0,25,42,.64)}.workbench-head{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;padding-bottom:18px;border-bottom:1px solid rgba(0,180,255,.2)}.section-eyebrow{margin:0;color:#71dfff;font-size:12px;font-weight:800}.workbench-head h2{margin:4px 0 0;color:#fff;font-size:24px}.step-tabs{display:flex;gap:10px;margin:20px 0}.step-tabs button{min-height:36px;padding:0 15px;border:1px solid rgba(99,183,222,.42);border-radius:8px;color:#a8cde0;background:rgba(3,27,43,.56);font:inherit;font-size:13px;font-weight:800;cursor:pointer}.step-tabs button.active{border-color:#48d5ff;color:#fff;background:rgba(9,125,177,.7)}.lead,.scoring-guide{margin:22px 0;color:#e2f3ff;font-size:16px;line-height:1.9}.scoring-guide{padding:16px 18px;border-left:3px solid #48d5ff;border-radius:0 12px 12px 0;background:rgba(16,85,121,.2)}.table-actions{display:flex;justify-content:flex-end;gap:12px;margin:0 0 16px}.table-actions :deep(.el-button){min-width:110px;height:42px;font-weight:800}.table-wrap{overflow-x:auto;border:1px solid rgba(99,183,222,.46);border-radius:14px}.table-wrap table{width:100%;border-collapse:collapse}.weight-table{min-width:900px}.channel-table{min-width:1320px}.scoring-table{min-width:1780px}.ranking-table{min-width:880px}.table-wrap th,.table-wrap td{padding:12px;border-right:1px solid rgba(99,183,222,.35);border-bottom:1px solid rgba(99,183,222,.35);vertical-align:middle}.table-wrap th{white-space:nowrap;color:#effaff;background:#0c9dd6;font-size:14px}.table-wrap td{background:rgba(2,21,34,.58)}.weight-table tbody td:nth-child(1){width:22%}.weight-table tbody td:nth-child(2){width:43%}.weight-table tbody td:nth-child(3){width:23%}.channel-table td{min-width:134px}.channel-table td:first-child{min-width:110px}.scoring-table th:first-child{min-width:108px}.scoring-table td{min-width:108px}.scoring-table thead tr:nth-child(2) th{background:rgba(7,116,161,.94)}.ranking-table td{min-width:170px}.ranking-table td:first-child{min-width:100px}.rank-cell,.core-cell{text-align:center;font-weight:900}.core-cell .core{display:inline-flex;align-items:center;justify-content:center;min-width:30px;height:26px;border:1px solid rgba(99,235,204,.8);border-radius:999px;color:#062f30;background:#68edce}.weight-cell{color:#a8d9eb;text-align:center;font-weight:800}.analysis-field{display:block;margin-top:28px}.analysis-field>span{display:block;margin-bottom:10px;color:#dff5ff;font-size:15px;font-weight:800}.analysis-field :deep(.el-textarea__inner){min-height:180px!important;border:1px solid rgba(111,190,225,.3);border-radius:12px;box-shadow:none;color:#fff;background:#102233;line-height:1.7}.table-wrap :deep(.el-input__wrapper),.table-wrap :deep(.el-input-number){width:100%;background:#102233}.table-wrap :deep(.el-input__wrapper){box-shadow:0 0 0 1px rgba(111,190,225,.3) inset}.table-wrap :deep(.el-input__inner){color:#fff}.table-wrap :deep(.el-input-number .el-input__wrapper){background:#102233}.table-wrap small{display:block;margin-top:7px;color:#8fa8b8;font-size:12px}.operation-column{width:100px;text-align:center}.empty-row{text-align:center;color:#99b5c7}.table-wrap tfoot th,.table-wrap tfoot td{border-bottom:0;background:rgba(7,49,72,.74)}.total-error{color:#ff8c8c;font-weight:900}.hint{margin:18px 0 0;color:#95b2c3;line-height:1.8}.footer-actions{display:flex;justify-content:center;gap:16px;margin-top:30px}.footer-actions :deep(.el-button){min-width:156px;height:44px;font-weight:800}.footer-actions :deep(.el-button .el-icon){margin-left:5px}@media (max-width:1100px){.release-channel-page{grid-template-columns:1fr}}@media (max-width:720px){.release-channel-page{padding:14px}.training-core{padding:18px}.task-card{padding:18px}.workbench-head{flex-direction:column}.table-actions,.footer-actions{align-items:stretch;flex-direction:column}.table-actions :deep(.el-button),.footer-actions :deep(.el-button){width:100%}}
</style>

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save