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

dev-QQq
chenyuan 2 weeks 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>支持 PNGJPGWEBP 格式图片不超过 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>支持 CSVTXTXLSXLSX文件不超过 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>支持 PNGJPGWEBP 格式图片不超过 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>支持 JPGPNGWEBP 等图片格式</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>ABC类和新品四行均需填写运营库存策略应结合案例经营数据资源配置优先级需明确选择未完整填写时不能提交任务</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) : [];
}
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; }
}
@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;
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>
</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>
<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>
<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">
<template #default="scope">
<el-input v-model="scope.row.ma12" placeholder=""></el-input>
</template>
</el-table-column>
</el-table>
<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>支持 PNGJPGJPEGWEBP 格式上传后可点击缩略图预览</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>计算季节指数先用上述各月份实际值除以移动平均值计算季节性不规则成分然后按月份分组求均值得出初版季节性指数最后再用各月份初版季节性指数除以初版季节性指数的均值得出标准化季节性指数请在下表填写标准化季节性指数结果保留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">
<template #default="scope">
<el-input v-model="scope.row.index" placeholder=""></el-input>
</template>
</el-table-column>
</el-table>
<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">
<span class="procurement-plan-readonly">{{ procurementPlanExpectedRows[scope.$index]?.demand ?? '请先完成第 1 步' }}</span>
</template>
</el-table-column>
<el-table-column label="安全库存(台)" align="center" min-width="165">
<template #default="scope">
<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.LINEARWPS 可使用 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>
下载案例资料
</el-button>
<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">
<p class="release-lead">
在电商产品投放测试中熵值法可以用于评估不同投放策略或广告内容的效果
<br />
<br />
通过收集多个反映投放效果的关键指标数据如点击率转化率订单成交量等利用熵值法计算各指标的权重从而确定哪些指标对投放效果的影响更大这有助于优化投放策略提高广告效果和投资回报率
</p>
<div class="caseBacktitle">
<span>实验实训</span>
</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>
</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>
<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>
<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>
<TrainingTaskBrief :background="taskBackground" :goals="taskGoals" :requirements="taskRequirement" :show-material="false" />
<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>
<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-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-else-if="activeStep === 3">
<p class="scoring-guide">通过 Min-Max 归一法将各维度原始数据转换为 010 分标准化得分正向指标搜索量点击率转化率访客数匹配度渠道值 最小值计算逆向指标供需比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-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>
</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>
</template>
</ProductStandardTrainingPage>
<pop-model :showModel="runResultShow" title="设置代码" @closePop="closeCode">
<template v-slot:content>
<div>
<codemirror :code="code"></codemirror>
</div>
</template>
</pop-model>
</main>
<TrainingAiSidebar :study-tip="'先确定可解释的评估权重,再完整汇总各渠道近三个月数据。'" :progress-items="progressItems" />
</div>
</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