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

884 lines
43 KiB
Vue

This file contains ambiguous Unicode characters!

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

<template>
<div class="release-channel-page student-training-shell">
<main class="release-channel-core">
<section class="task-header">
<div class="task-head-main">
<h1 class="task-title">{{ taskTitle }}</h1>
<p class="task-brief">
<span class="task-brief-text">{{ taskBackground }}</span>
<button type="button" class="task-brief-toggle" @click="briefOpen = !briefOpen">
{{ briefOpen ? "收起" : "展开目标与要求" }}
</button>
<span v-if="briefOpen" class="task-brief-more">
<b>实训目标</b>
<ul><li v-for="goal in taskGoals" :key="goal">{{ goal }}</li></ul>
<b>实训要求</b>
<p>{{ taskRequirement }}</p>
</span>
</p>
</div>
<TrainingMaterialButton
class="release-channel-toolbar"
:material-name="taskConfig?.materialName"
:material-url="taskConfig?.materialUrl"
:training-data-name="taskConfig?.trainingDataName"
:training-data-url="taskConfig?.trainingDataUrl"
:guide-manual-name="taskConfig?.guideManualName"
:guide-manual-url="taskConfig?.guideManualUrl"
:ai-manual-name="taskConfig?.aiManualName"
:ai-manual-url="taskConfig?.aiManualUrl"
/>
</section>
<section class="task-card">
<header class="workbench-head">
<div>
<p class="section-eyebrow">STEP {{ String(activeStep).padStart(2, "0") }}</p>
<h2>{{ stepTitle }}</h2>
</div>
</header>
<nav class="step-tabs" aria-label="产品发布渠道评估步骤">
<button type="button" :class="['step-tab', { active: activeStep === 1, complete: activeStep > 1 }]" :aria-current="activeStep === 1 ? 'step' : undefined" @click="activeStep = 1">
<span class="step-marker">01</span><span class="step-copy"><b>权重设置</b><small>{{ activeStep === 1 ? '当前步骤 →' : '点击切换 →' }}</small></span>
</button>
<span class="step-connector" aria-hidden="true" />
<button type="button" :class="['step-tab', { active: activeStep === 2, complete: activeStep > 2 }]" :aria-current="activeStep === 2 ? 'step' : undefined" @click="activeStep = 2">
<span class="step-marker">02</span><span class="step-copy"><b>渠道数据</b><small>{{ activeStep === 2 ? '当前步骤 →' : '点击切换 →' }}</small></span>
</button>
<span class="step-connector" aria-hidden="true" />
<button type="button" :class="['step-tab', { active: activeStep === 3, complete: activeStep > 3 }]" :aria-current="activeStep === 3 ? 'step' : undefined" @click="openStepThree">
<span class="step-marker">03</span><span class="step-copy"><b>归一化评分</b><small>{{ activeStep === 3 ? '当前步骤 →' : '点击切换 →' }}</small></span>
</button>
<span class="step-connector" aria-hidden="true" />
<button type="button" :class="['step-tab', { active: activeStep === 4 }]" :aria-current="activeStep === 4 ? 'step' : undefined" @click="openStepFour">
<span class="step-marker">04</span><span class="step-copy"><b>渠道排名</b><small>{{ activeStep === 4 ? '当前步骤 →' : '点击切换 →' }}</small></span>
</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-100)</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="100" :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 归一法将各维度原始数据转换为 0–10 分标准化得分。正向指标(搜索量、点击率、转化率、访客数、匹配度)按“渠道值 − 最小值”计算;逆向指标(供需比、CPC)按“最大值 − 渠道值”计算。请根据前两步数据手工填写得分与加权得分。</p>
<div class="table-actions"><el-button type="primary" plain @click="exportScoreRows"><el-icon><Download /></el-icon>导出</el-button></div>
<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>
<p class="step-three-notice">说明:渠道来自第二步,权重来自第一步;每个维度得分乘以权重后相加即为加权得分。计算结果不一致时仍可进入下一步,系统会记录检测结果,供 AI 助评扣分参考。</p>
<section class="step-three-materials" aria-label="归一化评分参考资料">
<p>参考方法及工具如下:下载案例数据表,使用 Excel 按 Min-Max 公式计算各渠道归一化得分,可参考《实训操作说明》完成。</p>
<el-button type="primary" class="step-three-materials__button" @click="downloadPracticeGuide"><el-icon><Download /></el-icon>下载实训操作说明</el-button>
</section>
<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>
</main>
<TrainingAiSidebar :study-tip="'先确定可解释的评估权重,再完整汇总各渠道近三个月数据。'" :progress-items="progressItems" />
</div>
</template>
<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 { 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 RELEASE_CHANNEL_GUIDE_URL = "/file/training-materials/release-channel-evaluation/21.实训操作说明.docx";
const activeStep = ref(1);
const briefOpen = ref(false);
const taskConfig = ref(null);
const saving = ref(false);
const validating = ref(false);
let nextId = 1;
const makeRow = (values = {}) => ({ id: nextId++, ...values });
const defaultWeightRows = () => [
["市场规模潜力", "对应关键词日均总搜索量,代表渠道用户需求大盘空间", "20~25"], ["竞争激烈程度", "供需比(在线商品数/日均搜索量),数值越低渠道竞争越宽松", "15~20"], ["流量点击效率", "品类平均点击率,代表渠道用户对该品类的点击意愿强弱", "12~18"], ["流量获取成本", "平均CPC,数值越低获取精准用户的推广成本越低", "15~20"], ["流量转化效率", "品类平均转化率,代表渠道用户的最终下单意愿", "12~18"], ["现有用户基础", "店铺现有该品类相关访客数,代表品牌存量用户承接能力", "5~10"], ["匹配度", "渠道属性与产品定位的适配度打分(1-10分)", "5~10"],
].map(([dimension, description, suggestion]) => makeRow({ dimension, description, weight: undefined, 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 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 scoreAnswerPayload = (calculationCheck = {}) => ({ ...scorePayload(), calculationCheck: {
calculationCorrect: calculationCheck.calculationCorrect === true,
totalItemCount: Number(calculationCheck.totalItemCount) || 0,
incorrectItemCount: Number(calculationCheck.incorrectItemCount) || 0,
incorrectItems: Array.isArray(calculationCheck.incorrectItems) ? calculationCheck.incorrectItems : [],
} });
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 { const result = await validateReleaseChannelEvaluationStepThree(scorePayload()); const calculationCheck = result?.data || {}; await saveAnswer(4, { step3Answer: JSON.stringify(scoreAnswerPayload(calculationCheck)) }); syncRankingRows(); activeStep.value = 4; ElMessage[calculationCheck.calculationCorrect ? "success" : "warning"](calculationCheck.message || "已保存并进入第四步"); } 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 downloadPracticeGuide() {
downloadBuiltInFile(RELEASE_CHANNEL_GUIDE_URL, "21.实训操作说明.docx");
}
function downloadBuiltInFile(url, fileName) {
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function downloadCsv(headers, rows, fileName) { const escape = (value) => `"${String(value ?? "").replaceAll('"', '""')}"`; const lines = [headers, ...rows].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 = fileName; link.click(); URL.revokeObjectURL(url); }
function exportChannelRows() { downloadCsv(["渠道", "日均总搜索量", "供需比", "平均点击率(%)", "平均CPC(元)", "平均转化率(%)", "现有相关访客数", "匹配度(1-100)"], channelRows.value.map((row) => [row.channel, row.dailySearchVolume, row.supplyDemandRatio, row.averageClickRate, row.averageCpc, row.averageConversionRate, row.existingVisitorCount, row.matchingScore]), "产品发布渠道评估-渠道数据.csv"); }
function exportScoreRows() { const headers = ["渠道", ...scoringMetrics.flatMap((metric) => [`${metric.dimension}得分`, `${metric.dimension}权重(%)`]), "加权得分"]; const rows = scoreRows.value.map((row) => [row.channel, ...row.dimensions.flatMap((dimension) => [dimension.score, dimension.weight]), row.weightedScore]); downloadCsv(headers, rows, "产品发布渠道评估-归一化评分.csv"); }
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 scoped lang="scss">
/* ===== 页面级令牌(墨黑极简主题) ===== */
.release-channel-page {
--student-accent: #2563eb;
--student-accent-deep: #1d4ed8;
--student-accent-light: #3b82f6;
--student-accent-soft: #eff6ff;
--student-danger: #ef4444;
--student-success: #2ba471;
--student-layer-1: #1d4ed8;
--student-layer-2: #3b82f6;
--student-layer-3: #93c5fd;
--student-panel-border: #e5e7eb;
--student-card-bg: #ffffff;
--student-panel-bg: #fafbfc;
--student-text: #1f2937;
--student-text-secondary: #6b7280;
--student-fs-display: 17px;
--student-fs-h1: 15px;
--student-fs-h2: 14px;
--student-fs-h3: 13px;
--student-fs-body: 13px;
--student-fs-body-sm: 12px;
--student-fs-caption: 11px;
--student-fs-button: 13px;
--student-fs-button-lg: 13px;
--student-fs-data: 14px;
min-width: 0;
color: var(--student-text);
background: #ffffff;
}
/* 主容器:只设 gap,无边框/背景/阴影/双列 grid */
.release-channel-core {
display: grid;
gap: 10px;
align-content: start;
min-width: 0;
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
}
/* ===== task-header ===== */
.task-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin: 0;
padding: 16px;
border: 1px solid #e7e9ed;
border-radius: 16px;
background: #ffffff;
}
.task-head-main {
display: grid;
justify-items: start;
gap: 8px;
min-width: 0;
flex: 1 1 auto;
}
.task-title {
margin: 0;
color: var(--student-text);
font-size: var(--student-fs-display);
font-weight: 700;
line-height: 1.35;
}
.task-brief {
margin: 0;
color: var(--student-text-secondary);
font-size: 12px;
line-height: 1.6;
}
.task-brief-text {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.task-brief-toggle {
padding: 0;
border: 0;
color: var(--student-accent-deep);
background: transparent;
font: inherit;
font-weight: 700;
cursor: pointer;
&:hover { text-decoration: underline; }
}
.task-brief-more {
display: grid;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--student-panel-border);
b { color: var(--student-text); font-size: 12px; }
ul { display: grid; gap: 4px; margin: 0; padding-left: 18px; }
p { margin: 0; }
}
/* ===== 工具栏:一行不换行 ===== */
.task-header :deep(.material-actions) {
flex: 0 0 auto;
display: flex;
flex-wrap: nowrap;
gap: 6px;
}
.task-header :deep(.material-button) {
flex: 0 0 auto;
white-space: nowrap;
}
/* ===== task-card:透明容器 ===== */
.task-card {
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
display: grid;
gap: 10px;
min-width: 0;
}
/* grid 子项默认 min-width:auto 会被宽表格(评分表 1780px)撑开,
导致 .table-wrap 的 overflow-x:auto 失效、整页横向溢出 */
.task-card > * {
min-width: 0;
}
.workbench-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 0 0 10px;
border: 0;
border-bottom: 1px solid var(--student-panel-border);
}
.section-eyebrow {
margin: 0;
color: var(--student-text-secondary);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.workbench-head h2 {
margin: 4px 0 0;
color: var(--student-text);
font-size: var(--student-fs-h1);
font-weight: 700;
line-height: 1.35;
}
/* ===== 步骤条:容器样式 ===== */
.step-tabs {
display: flex;
align-items: stretch;
gap: 10px;
margin: 4px 0 16px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
overflow-x: auto;
}
/* ===== 步骤说明 & 引导区 ===== */
.lead {
margin: 14px 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
line-height: 1.6;
}
.scoring-guide {
margin: 14px 0 12px;
padding: 12px 14px;
border-left: 3px solid var(--student-accent);
border-radius: 0 8px 8px 0;
background: var(--student-accent-soft);
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
line-height: 1.6;
}
.step-three-materials {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-top: 18px;
padding: 12px 14px;
border-left: 3px solid var(--student-accent);
border-radius: 0 8px 8px 0;
background: var(--student-accent-soft);
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
line-height: 1.6;
}
.step-three-materials p { margin: 0; }
.step-three-notice {
margin: 20px 0 0;
padding: 10px 14px;
border: 1px solid rgba(217, 85, 76, 0.28);
border-radius: 8px;
color: #9b3b34;
background: rgba(217, 85, 76, 0.06);
font-size: var(--student-fs-body-sm);
line-height: 1.6;
}
/* ===== 表格 ===== */
.table-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin: 0 0 16px;
}
.table-wrap {
overflow-x: auto;
min-width: 0;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
}
.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 var(--student-panel-border);
border-bottom: 1px solid var(--student-panel-border);
vertical-align: middle;
}
.table-wrap th {
white-space: nowrap;
color: var(--student-text);
background: #fafbfc;
font-size: var(--student-fs-body-sm);
font-weight: 800;
text-align: left;
}
.table-wrap td { background: #ffffff; }
.scoring-table thead tr:nth-child(2) th { background: #fafbfc; }
.channel-table td,
.scoring-table td { min-width: 108px; }
.channel-table td:first-child { min-width: 110px; }
.scoring-table th:first-child { min-width: 108px; }
.ranking-table td { min-width: 170px; }
.ranking-table td:first-child { min-width: 100px; }
.rank-cell,
.core-cell { text-align: center; font-weight: 800; }
.core-cell .core {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
height: 24px;
padding: 0 8px;
border-radius: 6px;
color: #ffffff;
background: var(--student-accent);
font-size: var(--student-fs-caption);
font-weight: 700;
}
.weight-cell {
color: var(--student-accent);
text-align: center;
font-weight: 700;
}
.table-wrap small {
display: block;
margin-top: 6px;
color: var(--student-text-secondary);
font-size: var(--student-fs-caption);
}
.operation-column { width: 100px; text-align: center; }
.empty-row { text-align: center; color: var(--student-text-secondary); }
.table-wrap tfoot th,
.table-wrap tfoot td {
border-bottom: 0;
background: var(--student-panel-bg);
}
.total-error { color: var(--student-danger); font-weight: 700; }
/* ===== 表单输入 ===== */
.analysis-field {
display: block;
margin-top: 14px;
}
.analysis-field > span {
display: block;
margin-bottom: 8px;
color: var(--student-text);
font-size: var(--student-fs-body);
font-weight: 600;
}
/* 表格内输入框:透明底 + outline focus */
.table-wrap :deep(.el-input__wrapper),
.table-wrap :deep(.el-input-number) {
width: 100%;
background: transparent;
box-shadow: none;
}
.table-wrap :deep(.el-input__inner) {
color: var(--student-text);
}
.table-wrap :deep(.el-textarea__inner) {
border: 1px solid transparent;
background: transparent;
box-shadow: none;
color: var(--student-text);
font-size: var(--student-fs-body);
line-height: 1.55;
}
.analysis-field :deep(.el-textarea__inner) {
min-height: 180px !important;
border: 1px solid var(--student-panel-border);
border-radius: 8px;
background: #ffffff;
box-shadow: none;
color: var(--student-text);
line-height: 1.55;
}
.table-wrap :deep(.el-textarea__inner:focus),
.analysis-field :deep(.el-textarea__inner:focus),
.table-wrap :deep(.el-input__wrapper.is-focus) {
outline: 2px solid rgba(37, 99, 235, 0.2);
outline-offset: -2px;
box-shadow: none;
}
/* ===== 底部说明 & 按钮 ===== */
.hint {
margin: 14px 0 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
line-height: 1.6;
}
.footer-actions {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 20px;
}
/* ===== 响应式 ===== */
@media (max-width: 720px) {
.task-header { flex-direction: column; align-items: flex-start; }
.task-header :deep(.material-actions) { width: 100%; }
.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>
<style lang="scss">
/* ===== 步骤条:分段连接胶囊(教育蓝极简) ===== */
#app .studentLayout section.app-main:has(.release-channel-page) .step-tabs {
display: flex !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: center !important;
gap: 0 !important;
width: 100% !important;
margin: 0 0 20px !important;
padding: 0 !important;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
.step-connector { display: none !important; }
.step-tab {
flex: 1 1 0 !important;
max-width: 240px !important;
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
min-width: 0 !important;
height: auto !important;
min-height: 40px !important;
margin: 0 !important;
padding: 8px 14px !important;
border: 1px solid #e5e7eb !important;
border-right: none !important;
border-radius: 0 !important;
background: #ffffff !important;
box-shadow: none !important;
color: #6b7280 !important;
font-size: 13px !important;
font-weight: 600 !important;
line-height: 1.3 !important;
letter-spacing: 0 !important;
white-space: normal !important;
text-align: center !important;
cursor: pointer;
transition: all 0.16s ease !important;
&:first-child {
border-radius: 8px 0 0 8px !important;
}
&:last-child {
border-radius: 0 8px 8px 0 !important;
border-right: 1px solid #e5e7eb !important;
}
.step-marker {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
flex: 0 0 auto !important;
min-width: 22px !important;
width: auto !important;
height: 22px !important;
padding: 0 5px !important;
border: none !important;
border-radius: 6px !important;
background: #f3f4f6 !important;
color: #6b7280 !important;
font-family: var(--student-font-data), ui-monospace, "SF Mono", Consolas, monospace !important;
font-size: 12px !important;
font-weight: 700 !important;
line-height: 1 !important;
transition: all 0.16s ease !important;
}
.step-copy {
display: flex !important;
flex-direction: column !important;
gap: 0 !important;
min-width: 0 !important;
margin-left: 0 !important;
}
.step-copy b {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: normal !important;
line-height: 1.3 !important;
color: inherit !important;
font-size: 13px !important;
font-weight: 600 !important;
}
.step-copy small { display: none !important; }
&:hover:not(.active):not(.complete) {
border-color: #2563eb !important;
background: #eff6ff !important;
color: #2563eb !important;
z-index: 1 !important;
.step-marker { background: #2563eb !important; color: #ffffff !important; }
}
&.complete:not(.active) {
border-color: #e5e7eb !important;
color: #374151 !important;
.step-marker { background: rgba(43, 164, 113, 0.12) !important; color: #16915f !important; }
&:hover {
border-color: #2ba471 !important;
background: #f0fdf6 !important;
z-index: 1 !important;
.step-marker { background: #2ba471 !important; color: #ffffff !important; }
}
}
&.active {
border-color: #2563eb !important;
background: #2563eb !important;
color: #ffffff !important;
font-weight: 700 !important;
box-shadow: none !important;
z-index: 2 !important;
.step-marker { background: #ffffff !important; color: #2563eb !important; }
&:hover {
background: #1d4ed8 !important;
border-color: #1d4ed8 !important;
}
}
}
}
/* ===== 窄屏横向滑动 ===== */
@media (max-width: 900px) {
#app .studentLayout section.app-main:has(.release-channel-page) .step-tabs {
justify-content: flex-start !important;
overflow-x: auto !important;
}
#app .studentLayout section.app-main:has(.release-channel-page) .step-tabs .step-tab {
flex: 0 0 140px !important;
max-width: none !important;
}
}
/* ============================================================
* 共用组件换肤:TrainingMaterialButton 资料按钮 → 教育蓝
* ============================================================ */
.release-channel-page .task-header .material-button.el-button {
height: 34px !important;
min-height: 34px !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
border-radius: 8px !important;
color: #2563eb !important;
background: #ffffff !important;
font-size: 13px !important;
font-weight: 600 !important;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease !important;
&:hover {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12) !important;
}
&.is-plain {
border-color: #e5e7eb !important;
color: #9ca3af !important;
&:hover {
border-color: #e5e7eb !important;
color: #9ca3af !important;
background: #ffffff !important;
box-shadow: none !important;
}
}
}
/* ===== 表格操作按钮换肤 ===== */
.release-channel-page .table-actions .el-button,
.release-channel-page .footer-actions .el-button {
border-radius: 8px !important;
font-size: 13px !important;
font-weight: 700 !important;
}
.release-channel-page .table-actions .el-button--primary,
.release-channel-page .footer-actions .el-button--primary {
border-color: #2563eb !important;
background: #2563eb !important;
color: #ffffff !important;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.28) !important;
}
.release-channel-page .table-actions .el-button--primary:hover,
.release-channel-page .footer-actions .el-button--primary:hover {
border-color: #1d4ed8 !important;
background: #1d4ed8 !important;
box-shadow: 0 5px 14px rgba(29, 78, 216, 0.32) !important;
}
.release-channel-page .footer-actions .el-button:not(.el-button--primary) {
border-color: rgba(37, 99, 235, 0.35) !important;
background: #ffffff !important;
color: #2563eb !important;
}
.release-channel-page .footer-actions .el-button:not(.el-button--primary):hover {
border-color: #2563eb !important;
background: #eff6ff !important;
}
</style>