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

1211 lines
52 KiB
Vue

<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>
</section>
<section class="task-action-strip">
<TrainingTaskToolbar :task-key="TASK_KEY" :on-submit="submitTask" :on-reset="resetTraining"
:on-export="exportOutcome" />
</section>
<section class="task-card">
<nav class="step-tabs" aria-label="产品发布渠道评估步骤">
<template v-for="(step, index) in trainingSteps" :key="`${step}-${index}`">
<button type="button" :class="['step-tab', { active: activeStep === index + 1, complete: activeStep > index + 1 }]"
:aria-current="activeStep === index + 1 ? 'step' : undefined" @click="goToStepFromTab(index + 1)">
<span class="step-marker">{{ String(index + 1).padStart(2, "0") }}</span><span class="step-copy"><b>{{ step
}}</b><small>{{ activeStep === index + 1
? '当前步骤 →' : '点击切换 →' }}</small></span>
</button>
<span v-if="index < trainingSteps.length - 1" class="step-connector" aria-hidden="true" />
</template>
</nav>
<section v-if="activeStep === 1">
<header class="step-heading">
<p class="step-eyebrow">Step 01</p>
<h2>权重设置</h2>
</header>
<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-if="!row.preset" v-model="row.dimension" maxlength="50"
placeholder="填写评估维度" />
<span v-else class="preset-text">{{ row.dimension }}</span>
</td>
<td>
<el-input v-if="!row.preset" v-model="row.description" maxlength="200"
placeholder="填写维度说明" />
<span v-else class="preset-text">{{ row.description }}</span>
</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 v-if="!row.preset" 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">默认展示上述7个评估维度和建议权重范围,具体权重需由学生自行填写,可另外新增评估维度;点击下一步时系统校验所有权重之和必须等于 100%。</p>
</section>
<section v-else-if="activeStep === 2">
<header class="step-heading">
<p class="step-eyebrow">Step 02</p>
<h2>渠道数据</h2>
</header>
<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 v-if="!row.preset" 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>
</section>
<section v-else-if="activeStep === 3">
<header class="step-heading">
<p class="step-eyebrow">Step 03</p>
<h2>归一化评分</h2>
</header>
<p class="scoring-guide">通过Min-Max归一法将各维度原始数据转换为0-10分标准化得分,计算规则如下:<br>
正向指标(越大越好,如搜索量、点击率、转化率、访客数、匹配度),得分=(该渠道值−最小值)/(最大值−最小值)× 10<br>
逆向指标(越小越好,如供需比、CPC):得分=(最大值−该渠道值)/(最大值−最小值)× 10</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>
<section v-else>
<header class="step-heading">
<p class="step-eyebrow">Step 04</p>
<h2>渠道排名</h2>
</header>
<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>
</section>
<div class="step-navigation">
<div class="step-nav-left">
<button v-if="activeStep > 1" type="button" class="btn-nav btn-ghost" :disabled="saving" @click="previousStep">
<el-icon><ArrowLeft /></el-icon>
上一步
</button>
<button type="button" class="btn-nav btn-save" :disabled="saving" @click="saveCurrentProgress">
<el-icon><CircleCheck /></el-icon>
保存
</button>
</div>
<div class="step-nav-right">
<span class="step-progress">Step {{ String(activeStep).padStart(2, "0") }} / {{ String(4).padStart(2, "0") }}</span>
<button v-if="activeStep < 4" type="button" class="btn-nav btn-primary" :disabled="validating" @click="nextStep">
下一步
<el-icon><ArrowRight /></el-icon>
</button>
</div>
</div>
</section>
</main>
<TrainingAiSidebar :study-tip="'先确定可解释的评估权重,再完整汇总各渠道近三个月数据。'" :progress-items="progressItems" />
</div>
4 months ago
</template>
<script setup>
import { computed, onMounted, ref } from "vue"
import { ArrowLeft, ArrowRight, CircleCheck, Download, Minus, Plus } from "@element-plus/icons-vue"
import { ElMessage } from "element-plus"
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"
import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue"
import { getTrainingTaskByKey } from "@/api/trainingTask"
import { deleteStudentTrainingAnswer, 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(true)
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, preset: true }))
const defaultChannelRows = () => ["天猫", "京东", "抖音", "拼多多", "唯品会"].map((channel) => makeRow({ channel, preset: true }))
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 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 DEFAULT_STEPS = ["权重设置", "渠道数据", "归一化评分", "渠道排名"]
const trainingSteps = computed(() => {
const fromConfig = parseTrainingArray(taskConfig.value?.steps)
return (fromConfig.length ? fromConfig : DEFAULT_STEPS).slice(0, DEFAULT_STEPS.length)
})
function goToStepFromTab(step) {
if (step === 3) return openStepThree()
if (step === 4) return openStepFour()
activeStep.value = step
}
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 saveCurrentProgress() { if (activeStep.value === 1) return saveWeightStep(); if (activeStep.value === 2) return saveChannelStep(); if (activeStep.value === 3) return saveScoreStep(); return saveRankingStep() }
function previousStep() { if (activeStep.value > 1) activeStep.value -= 1 }
function nextStep() { if (activeStep.value === 1) return goStepTwo(); if (activeStep.value === 2) return validateChannelStep(); if (activeStep.value === 3) return validateScoreStep() }
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 } }
async function submitTask() { return validateRankingStep() }
async function resetTraining() { if (saving.value || validating.value) return; try { await deleteStudentTrainingAnswer(TASK_KEY); weightRows.value = defaultWeightRows(); channelRows.value = defaultChannelRows(); scoreRows.value = []; rankingRows.value = []; coreChannelAnalysis.value = ""; activeStep.value = 1; ElMessage.success("已清空全部实训步骤,可以重新开始") } catch (error) { ElMessage.error("重新实训失败,请稍后重试") } }
function exportOutcome() { ElMessage.info("导出功能正在开发中") }
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; const keyField = fields[0]; const presetFlags = new Map(defaults().map((row) => [row[keyField], Boolean(row.preset)])); target.value = saved.rows.map((row) => { const value = Object.fromEntries(fields.map((field) => [field, row?.[field]])); if (target === weightRows) return makeRow({ ...value, suggestion: weightSuggestions[value.dimension] || "", preset: presetFlags.get(value[keyField]) || false }); if (target === channelRows) return makeRow({ ...value, preset: presetFlags.get(value[keyField]) || false }); return makeRow(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-training-shell,页面不再重复声明 */
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: var(--student-fs-body-sm);
line-height: 1.6;
}
.task-brief-text {
display: block;
}
.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: var(--student-fs-body-sm);
}
ul {
display: grid;
gap: 4px;
margin: 0;
padding-left: 18px;
}
p {
margin: 0;
}
}
/* ===== 工具栏:一行不换行 ===== */
/* ===== 工具栏:TrainingTaskToolbar 按钮右对齐 ===== */
.task-action-strip {
margin: 0;
padding: 10px 16px;
:deep(.training-task-toolbar) {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
}
/* ===== 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;
}
/* ===== 步骤条:容器样式 ===== */
.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;
}
/* ===== 步骤说明 & 引导区 ===== */
.step-heading {
display: grid;
gap: 4px;
margin: 0 0 10px;
}
.step-heading .step-eyebrow {
margin: 0;
color: var(--student-accent, #2563eb);
font-size: var(--student-fs-caption, 12px);
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.step-heading h2 {
margin: 0;
color: var(--student-text, #11243b);
font-size: var(--student-fs-h2, 18px);
font-weight: 700;
line-height: 1.4;
}
.lead {
margin: 0 0 14px;
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
line-height: 1.6;
}
/* ===== 预置维度行:只读文本 ===== */
.preset-text {
display: block;
padding: 6px 2px;
color: var(--student-text);
font-size: var(--student-fs-body);
line-height: 1.5;
}
.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-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;
}
/* ===== 底部步骤导航按钮 ===== */
.step-navigation {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 16px;
padding: 12px 16px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: #ffffff;
}
.step-nav-left,
.step-nav-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
}
.step-nav-right {
margin-left: auto;
}
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid var(--student-accent);
border-radius: 8px;
color: #ffffff;
background: var(--student-accent);
font-family: inherit;
font-size: var(--student-fs-button);
font-weight: 600;
box-shadow: 0 1px 2px rgba(37, 99, 235, 0.2), 0 2px 8px rgba(37, 99, 235, 0.15);
cursor: pointer;
transition: all 0.16s ease;
white-space: nowrap;
.el-icon {
font-size: 18px;
}
&:hover:not(:disabled) {
border-color: var(--student-accent-deep);
background: var(--student-accent-deep);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(29, 78, 216, 0.3), 0 4px 12px rgba(29, 78, 216, 0.22);
}
&:active:not(:disabled) {
transform: translateY(0);
}
}
.btn-ghost {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid rgba(37, 99, 235, 0.35);
border-radius: 8px;
color: var(--student-accent);
background: #ffffff;
font-family: inherit;
font-size: var(--student-fs-button);
font-weight: 600;
cursor: pointer;
transition: all 0.16s ease;
white-space: nowrap;
.el-icon {
font-size: 18px;
}
&:hover:not(:disabled) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12);
}
}
.btn-save {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid #e5e7eb;
border-radius: 8px;
color: #4b5563;
background: #f9fafb;
font-family: inherit;
font-size: var(--student-fs-button);
font-weight: 600;
cursor: pointer;
transition: all 0.16s ease;
white-space: nowrap;
.el-icon {
font-size: 18px;
}
&:hover:not(:disabled) {
border-color: #d1d5db;
background: #f3f4f6;
}
}
.btn-primary:disabled,
.btn-ghost:disabled,
.btn-save:disabled,
.btn-nav:disabled {
cursor: not-allowed;
opacity: 0.5;
transform: none;
box-shadow: none;
}
.step-progress {
color: var(--student-text-secondary);
font-family: var(--student-font-data);
font-size: var(--student-fs-caption);
font-weight: 700;
letter-spacing: 0.05em;
white-space: nowrap;
}
/* ===== 响应式 ===== */
@media (max-width: 720px) {
.task-header {
flex-direction: column;
align-items: flex-start;
}
.task-action-strip {
:deep(.training-task-toolbar) {
justify-content: stretch;
}
:deep(.training-task-toolbar > *) {
flex: 1 1 auto;
}
}
.table-actions {
align-items: stretch;
flex-direction: column;
}
.table-actions :deep(.el-button) {
width: 100%;
}
.step-navigation {
flex-direction: column;
align-items: stretch;
}
.step-nav-left,
.step-nav-right {
width: 100%;
}
.btn-nav {
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 {
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: var(--student-fs-body) !important;
font-weight: 600 !important;
line-height: 1.3 !important;
letter-spacing: 0 !important;
white-space: nowrap !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: var(--student-fs-body-sm) !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: nowrap !important;
line-height: 1.3 !important;
color: inherit !important;
font-size: var(--student-fs-body) !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;
}
}
/* ============================================================
* 共用组件换肤:TrainingTaskToolbar 工具栏按钮 → 教育蓝
* ============================================================ */
.release-channel-page .task-action-strip .task-toolbar-button.el-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 34px;
height: 34px;
padding: 0 14px;
border: 1px solid rgba(37, 99, 235, 0.35);
border-radius: 8px;
color: #2563eb;
background: #ffffff;
font-size: var(--student-fs-body);
font-weight: 600;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease;
}
.release-channel-page .task-action-strip .task-toolbar-button:hover,
.release-channel-page .task-action-strip .task-toolbar-button:focus {
border-color: #2563eb;
color: #1d4ed8;
background: #eff6ff;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12);
}
.release-channel-page .task-action-strip .task-toolbar-button--submit.el-button {
border-color: #2563eb;
color: #ffffff;
background: #2563eb;
box-shadow: 0 1px 2px rgba(37, 99, 235, 0.35), 0 3px 10px rgba(37, 99, 235, 0.22);
}
.release-channel-page .task-action-strip .task-toolbar-button--submit:hover,
.release-channel-page .task-action-strip .task-toolbar-button--submit:focus {
border-color: #1d4ed8;
color: #ffffff;
background: #1d4ed8;
box-shadow: 0 2px 4px rgba(37, 99, 235, 0.4), 0 5px 14px rgba(29, 78, 216, 0.28);
}
/* ===== 表格操作按钮换肤 ===== */
.release-channel-page .table-actions .el-button {
border-radius: 8px !important;
font-size: var(--student-fs-body) !important;
font-weight: 700 !important;
}
.release-channel-page .table-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 {
border-color: #1d4ed8 !important;
background: #1d4ed8 !important;
box-shadow: 0 5px 14px rgba(29, 78, 216, 0.32) !important;
}
4 months ago
</style>