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

886 lines
43 KiB
Vue

<template>
<div class="release-channel-page student-training-shell">
<TrainingTaskToolbar />
<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>
4 months ago
</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";
import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue";
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: "匹配度得分" },
4 months ago
];
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(); });
4 months ago
</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;
}
4 months ago
</style>