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/promotion.vue

914 lines
46 KiB
Vue

<template>
<div class="promotion-page student-training-shell">
<main class="promotion-core">
<section class="task-header">
<div class="task-head-main">
<span class="task-badge"><el-icon><Document /></el-icon> 核心实训 · 产品上线与运营推广</span>
<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>
4 months ago
<section class="task-card promotion-phase">
<header class="workbench-head">
<div>
<p class="section-eyebrow">STEP {{ String(activeStep).padStart(2, "0") }}</p>
<h2>{{ stepTitle }}</h2>
</div>
<TrainingTaskToolbar class="promotion-toolbar" :task-key="TASK_KEY" />
</header>
<nav class="step-indicator" 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"><strong class="step-name">预算分配</strong><span class="step-action">{{ activeStep === 1 ? '当前步骤' : '点击切换' }} →</span></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="openStepTwo">
<span class="step-marker">02</span>
<span class="step-copy"><strong class="step-name">投放策略</strong><span class="step-action">{{ activeStep === 2 ? '当前步骤' : '点击切换' }} →</span></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"><strong class="step-name">投放数据与 ROI</strong><span class="step-action">{{ activeStep === 3 ? '当前步骤' : '点击切换' }} →</span></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"><strong class="step-name">效果评估与优化</strong><span class="step-action">{{ activeStep === 4 ? '当前步骤' : '点击切换' }} →</span></span>
</button>
</nav>
<section v-if="activeStep === 1" class="budget-form">
<label class="total-budget-field">
<span>请设置新产品投放试错预算</span>
<el-input-number v-model="totalBudget" :min="0" :precision="2" :step="100" controls-position="right" placeholder="请输入预算" />
<em>元</em>
</label>
<p class="lead">投放周期分为冷启动期、增长期、稳定期三个阶段。请将预算分配到渠道和三个时间段中。</p>
<div class="table-actions">
<el-button type="primary" :disabled="budgetRows.length >= 5" @click="addBudgetRow"><el-icon><Plus /></el-icon>增加一行</el-button>
<el-button :disabled="budgetRows.length <= 1" @click="removeBudgetRow"><el-icon><Minus /></el-icon>删除一行</el-button>
</div>
<div class="table-wrap">
<table class="budget-table">
<thead><tr><th>渠道</th><th>渠道定位</th><th>预算占比(%)</th><th>预算金额(元)</th><th>冷启动期投放预算(元)</th><th>增长期投放预算(元)</th><th>稳定期投放预算(元)</th></tr></thead>
<tbody>
<tr v-for="(row, index) in budgetRows" :key="row.id">
<td><el-select v-model="row.channel" placeholder="请选择渠道" filterable><el-option v-for="channel in availableChannels" :key="channel" :label="channel" :value="channel" :disabled="isChannelUnavailable(channel, index)" /></el-select></td>
<td><el-select v-model="row.channelPositioning" placeholder="请选择"><el-option v-for="positioning in channelPositionings" :key="positioning" :label="positioning" :value="positioning" /></el-select></td>
<td><el-input-number v-model="row.budgetPercentage" :min="0" :max="100" :precision="2" :step="1" controls-position="right" /></td>
<td class="computed-money">{{ displayMoney(rowBudgetAmount(row)) }}</td>
<td><el-input-number v-model="row.coldStartBudget" :min="0" :precision="2" :step="100" controls-position="right" /></td>
<td><el-input-number v-model="row.growthBudget" :min="0" :precision="2" :step="100" controls-position="right" /></td>
<td><el-input-number v-model="row.stableBudget" :min="0" :precision="2" :step="100" controls-position="right" /></td>
</tr>
</tbody>
<tfoot><tr><th colspan="2">当前渠道预算占比合计</th><td :class="{ 'total-error': budgetPercentageTotal !== 100 }">{{ displayMoney(budgetPercentageTotal) }}%</td><td :class="{ 'total-error': rowBudgetTotal !== normalizedTotalBudget }">{{ displayMoney(rowBudgetTotal) }}</td><td colspan="3" class="table-note">预算金额由总预算 × 预算占比自动计算</td></tr></tfoot>
</table>
</div>
<p v-if="!availableChannels.length" class="source-warning">尚未读取到产品发布渠道评估的渠道数据。请先完成并保存该模块的渠道数据。</p>
<footer class="footer-actions"><el-button :loading="saving" @click="saveBudgetDraft">保存当前进度</el-button><el-button type="primary" :loading="validating" @click="validateBudgetAndContinue">下一步 <el-icon><ArrowRight /></el-icon></el-button></footer>
</section>
<section v-else-if="activeStep === 2" class="strategy-form">
<p class="lead">新品投放的 6 周内,不同阶段、不同渠道需要执行不同的投放策略。请从策略选项池参考并为每个渠道在每个阶段选择或填写两项投放策略。</p>
<div class="strategy-table-wrap">
<table class="strategy-table">
<thead><tr><th>阶段</th><th v-for="channel in stepOneChannels" :key="channel">{{ channel }}</th></tr></thead>
<tbody>
<tr v-for="phase in phases" :key="phase">
<th>{{ phase }}</th>
<td v-for="channel in stepOneChannels" :key="`${phase}-${channel}`">
<el-select v-model="strategyCell(phase, channel).strategies" multiple filterable allow-create default-first-option :multiple-limit="2" collapse-tags collapse-tags-tooltip placeholder="选择或填写两项策略">
<el-option v-for="strategy in strategyOptions(phase)" :key="strategy" :label="strategy" :value="strategy" />
</el-select>
<small>{{ strategyCell(phase, channel).strategies.length }}/2 项策略</small>
</td>
</tr>
<tr v-if="!stepOneChannels.length"><td colspan="2" class="empty-row">请先完成第一步并填写至少两个渠道。</td></tr>
</tbody>
</table>
</div>
<el-collapse class="strategy-reference">
<el-collapse-item title="查看策略选项池参考" name="strategy-pool">
<div class="reference-table-wrap">
<table class="reference-table"><thead><tr><th>策略描述</th><th>适用阶段</th><th>适用渠道</th></tr></thead><tbody><tr v-for="item in strategyPool" :key="item.description"><td>{{ item.description }}</td><td>{{ item.phase }}</td><td>{{ item.channel }}</td></tr></tbody></table>
</div>
</el-collapse-item>
</el-collapse>
<footer class="footer-actions"><el-button @click="activeStep = 1">上一步</el-button><el-button :loading="saving" @click="saveStrategyDraft">保存当前进度</el-button><el-button type="primary" :loading="validating" @click="validateStrategies">下一步 <el-icon><ArrowRight /></el-icon></el-button></footer>
</section>
<section v-else-if="activeStep === 3" class="performance-form">
<p class="lead">产品投放结束后,请根据案例资料填写各渠道的曝光量、点击量、成交额和推广花费,并核算点击率与实际 ROI(投产比)。</p>
<section class="roi-baseline">
<div class="subsection-title">(一)计算保本 ROI</div>
<p>根据案例资料填写产品毛利率,计算保本 ROI。</p>
<div class="roi-inputs">
<label>产品毛利率 <el-input-number v-model="grossMargin" :min="0" :max="100" :precision="2" :step="1" controls-position="right" /> <span>%</span></label>
<label>保本 ROI <el-input-number v-model="breakEvenRoi" :min="0" :precision="2" :step="0.01" controls-position="right" /> <span>计算结果保留两位小数</span></label>
</div>
<p class="formula-note">保本 ROI = 1 ÷ 毛利率;当投产比达到该数值时,推广花费刚好等于产品的毛利额,实现投放盈亏平衡。</p>
</section>
<section class="performance-data">
<div class="subsection-title">(二)完善投放数据</div>
<p>根据案例资料填写下表,并计算点击率和实际 ROI。</p>
<div class="performance-table-wrap">
<table class="performance-table">
<thead><tr><th>渠道</th><th>曝光量</th><th>点击量</th><th>点击率(%)</th><th>成交额(元)</th><th>推广花费(元)</th><th>实际 ROI</th></tr></thead>
<tbody>
<tr v-for="row in performanceRows" :key="row.id" :class="{ 'total-row': row.channel === '合计' }">
<th>{{ row.channel }}</th>
<td><el-input-number v-model="row.impressions" :min="0" :precision="0" controls-position="right" /></td>
<td><el-input-number v-model="row.clicks" :min="0" :precision="0" controls-position="right" /></td>
<td><el-input-number v-model="row.clickRate" :min="0" :precision="2" :step="0.01" controls-position="right" /></td>
<td><el-input-number v-model="row.turnover" :min="0" :precision="2" :step="100" controls-position="right" /></td>
<td><el-input-number v-model="row.promotionCost" :min="0" :precision="2" :step="100" controls-position="right" /></td>
<td><el-input-number v-model="row.actualRoi" :min="0" :precision="2" :step="0.01" controls-position="right" /></td>
</tr>
<tr v-if="!performanceRows.length"><td colspan="7" class="empty-row">请先完成第一步并填写至少两个渠道。</td></tr>
</tbody>
</table>
</div>
</section>
<footer class="footer-actions"><el-button @click="activeStep = 2">上一步</el-button><el-button :loading="saving" @click="savePerformanceDraft">保存当前进度</el-button><el-button type="primary" :loading="validating" @click="validatePerformance">下一步 <el-icon><ArrowRight /></el-icon></el-button></footer>
</section>
<section v-else class="evaluation-form">
<section class="evaluation-intro">
<div class="subsection-title">(一)投放效果评估</div>
<p>企业投放目标为整体 ROI 不低于保本 ROI。请根据第三步核算结果,判断各渠道投放是否达标;对不达标渠道填写最可能的问题诊断与优化动作。</p>
<div class="overall-roi"><span>保本 ROI:{{ displayFixed(breakEvenRoi) }}</span><span>整体 ROI:{{ displayFixed(overallRoi) }}</span><span :class="{ achieved: overallAchieved, underperforming: !overallAchieved }">整体{{ overallAchieved ? '达标' : '不达标' }}</span></div>
</section>
<div class="evaluation-table-wrap">
<table class="evaluation-table">
<thead><tr><th>渠道</th><th>ROI</th><th>是否达标</th><th>问题诊断</th><th>优化动作</th></tr></thead>
<tbody>
<tr v-for="row in evaluationRows" :key="row.id">
<th>{{ row.channel }}</th>
<td class="synced-roi">{{ displayFixed(row.roi) }}</td>
<td><el-select v-model="row.achievement" placeholder="请选择"><el-option label="达标" value="达标" /><el-option label="不达标" value="不达标" /></el-select></td>
<td><el-input v-model="row.problemDiagnosis" type="textarea" :autosize="{ minRows: 2, maxRows: 5 }" placeholder="未达标渠道必填" /></td>
<td><el-input v-model="row.optimizationAction" type="textarea" :autosize="{ minRows: 2, maxRows: 5 }" placeholder="未达标渠道必填" /></td>
</tr>
<tr v-if="!evaluationRows.length"><td colspan="5" class="empty-row">请先完成并保存第三步投放数据。</td></tr>
</tbody>
</table>
</div>
<section class="decision-section">
<div class="subsection-title">(二)优化决策</div>
<p>结合整体 ROI 与各渠道达标情况,总结本方案是否需要调整、调整方向,以及优先调整的渠道。</p>
<el-input v-model="optimizationDecision" type="textarea" :autosize="{ minRows: 7, maxRows: 14 }" placeholder="填写整体 ROI 是否达标、拖累整体效果的渠道及优先优化方向" />
</section>
<footer class="footer-actions"><el-button @click="activeStep = 3">上一步</el-button><el-button type="primary" :loading="saving" @click="saveEvaluationDraft">保存当前进度</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, 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 { getStudentTrainingAnswer, saveStudentTrainingAnswer, validatePromotionEffectOptimizationStepOne, validatePromotionEffectOptimizationStepTwo, validatePromotionEffectOptimizationStepThree, validatePromotionEffectOptimizationStepFour } from "@/api/studentTrainingAnswer";
import { parseTrainingArray } from "@/views/training/taskKeyMap";
const TASK_KEY = "promotion-effect-optimization";
const RELEASE_CHANNEL_TASK_KEY = "release-channel-evaluation";
const phases = ["冷启动期", "增长期", "稳定期"];
const channelPositionings = ["品牌曝光", "种草引流", "新品转化"];
const strategyPool = [
["投放泛流量关键词+测试3-5组不同内容素材,观察点击率确定最优方向", "冷启动期", "抖音"], ["优化商品标题和主图,承接自然搜索流量,暂不开启大规模付费推广", "冷启动期", "天猫"], ["设置低价引流款,报名“新品秒杀”活动,获取初始销量和评价", "冷启动期", "拼多多"],
["将冷启动期跑出的TOP2内容素材加大投放,定向场景化标签(如,白领、科技爱好者)", "增长期", "抖音"], ["开启直通车,主推核心大词,截取竞品搜索流量", "增长期", "天猫"], ["参与平台大促活动(如百亿补贴),设置满减券,快速拉升销量", "增长期", "拼多多"],
["稳定更新日常内容,维持直播间日播,以“老客复购+会员日”为主", "稳定期", "抖音"], ["稳定类目排名,优化评价管理,维持搜索位,减少付费投放比例", "稳定期", "天猫"], ["持续参与秒杀活动,维持价格力标签,以自然流量为主、付费为辅", "稳定期", "拼多多"],
].map(([description, phase, channel]) => ({ description, phase, channel }));
const activeStep = ref(1);
const taskConfig = ref(null);
const briefOpen = ref(false);
const totalBudget = ref(undefined);
const budgetRows = ref([]);
const availableChannels = ref([]);
const strategyRows = ref([]);
const grossMargin = ref(undefined);
const breakEvenRoi = ref(undefined);
const performanceRows = ref([]);
const evaluationRows = ref([]);
const optimizationDecision = ref("");
const saving = ref(false);
const validating = ref(false);
let nextId = 1;
const makeRow = (values = {}) => ({ id: nextId++, channel: "", channelPositioning: "", budgetPercentage: undefined, coldStartBudget: undefined, growthBudget: undefined, stableBudget: undefined, ...values });
const makeStrategyRow = (values = {}) => ({ id: nextId++, phase: "", channel: "", strategies: [], ...values });
const makePerformanceRow = (values = {}) => ({ id: nextId++, channel: "", impressions: undefined, clicks: undefined, clickRate: undefined, turnover: undefined, promotionCost: undefined, actualRoi: undefined, ...values });
const makeEvaluationRow = (values = {}) => ({ id: nextId++, channel: "", roi: undefined, achievement: "", problemDiagnosis: "", optimizationAction: "", ...values });
const taskTitle = computed(() => taskConfig.value?.taskName || "投放效果评估与优化");
const stepTitle = computed(() => ["投放试错预算与阶段分配", "各阶段渠道投放策略", "保本 ROI 与投放数据核算", "投放效果评估与优化决策"][activeStep.value - 1]);
const taskBackground = computed(() => taskConfig.value?.background || "基于渠道评估结果制定新产品投放试错预算,并按投放阶段安排资源。");
const taskRequirement = computed(() => taskConfig.value?.requirements || "设置总预算,完成渠道预算占比与投放阶段策略分配。");
const taskGoals = computed(() => parseTrainingArray(taskConfig.value?.objectives, ["理解投放试错预算的作用", "能够将预算分配到渠道与投放阶段", "能够根据投放阶段选择策略"]));
const progressItems = computed(() => {
const steps = parseTrainingArray(taskConfig.value?.steps);
const items = steps.length ? steps : ["投放预算分配", "分阶段投放策略", "投放数据与 ROI", "效果评估与优化"];
return items.map((text, index) => ({ text, status: index + 1 < activeStep.value ? "done" : index + 1 === activeStep.value ? "doing" : "" }));
});
const normalizedTotalBudget = computed(() => toMoney(totalBudget.value));
const budgetPercentageTotal = computed(() => Number(budgetRows.value.reduce((sum, row) => sum + (Number(row.budgetPercentage) || 0), 0).toFixed(6)));
const rowBudgetTotal = computed(() => Number(budgetRows.value.reduce((sum, row) => sum + rowBudgetAmount(row), 0).toFixed(2)));
const stepOneChannels = computed(() => [...new Set(budgetRows.value.map((row) => String(row.channel || "").trim()).filter(Boolean))]);
const overallRoi = computed(() => Number(performanceRows.value.find((row) => row.channel === "合计")?.actualRoi));
const overallAchieved = computed(() => Number.isFinite(overallRoi.value) && Number.isFinite(Number(breakEvenRoi.value)) && overallRoi.value >= Number(breakEvenRoi.value));
function toMoney(value) { const number = Number(value); return Number.isFinite(number) ? Number(number.toFixed(2)) : 0; }
function rowBudgetAmount(row) { return toMoney(Number(totalBudget.value) * (Number(row?.budgetPercentage) || 0) / 100); }
function displayMoney(value) { return Number(value || 0).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); }
function isChannelUnavailable(channel, rowIndex) { return budgetRows.value.some((row, index) => index !== rowIndex && row.channel === channel); }
function addBudgetRow() { if (budgetRows.value.length < 5) budgetRows.value.push(makeRow()); }
function removeBudgetRow() { if (budgetRows.value.length > 1) budgetRows.value.pop(); }
function budgetPayload() { return { totalBudget: totalBudget.value, rows: budgetRows.value.map((row) => ({ channel: row.channel, channelPositioning: row.channelPositioning, budgetPercentage: row.budgetPercentage, budgetAmount: rowBudgetAmount(row), coldStartBudget: row.coldStartBudget, growthBudget: row.growthBudget, stableBudget: row.stableBudget })) }; }
function strategyPayload() { return { rows: strategyRows.value.map((row) => ({ phase: row.phase, channel: row.channel, strategies: [...row.strategies] })) }; }
function strategyOptions(phase) { return strategyPool.filter((item) => item.phase === phase).map((item) => item.description); }
function strategyCell(phase, channel) {
let row = strategyRows.value.find((item) => item.phase === phase && item.channel === channel);
if (!row) { row = makeStrategyRow({ phase, channel, strategies: [] }); strategyRows.value.push(row); }
return row;
4 months ago
}
function syncStrategyRows(savedRows = []) {
const saved = new Map(savedRows.map((row) => [`${row?.phase}\u0000${row?.channel}`, row]));
strategyRows.value = phases.flatMap((phase) => stepOneChannels.value.map((channel) => makeStrategyRow({ phase, channel, strategies: Array.isArray(saved.get(`${phase}\u0000${channel}`)?.strategies) ? [...saved.get(`${phase}\u0000${channel}`).strategies] : [] })));
4 months ago
}
function performancePayload() { return { grossMargin: grossMargin.value, breakEvenRoi: breakEvenRoi.value, rows: performanceRows.value.map((row) => ({ channel: row.channel, impressions: row.impressions, clicks: row.clicks, clickRate: row.clickRate, turnover: row.turnover, promotionCost: row.promotionCost, actualRoi: row.actualRoi })) }; }
function syncPerformanceRows(savedRows = []) {
const saved = new Map(savedRows.map((row) => [row?.channel, row]));
performanceRows.value = [...stepOneChannels.value, "合计"].map((channel) => makePerformanceRow({ channel, ...saved.get(channel) }));
4 months ago
}
function openStepThree() { syncPerformanceRows(performanceRows.value); activeStep.value = 3; }
function displayFixed(value) { return Number.isFinite(Number(value)) ? Number(value).toFixed(2) : "—"; }
function evaluationPayload() { return { rows: evaluationRows.value.map((row) => ({ channel: row.channel, roi: row.roi, achievement: row.achievement, problemDiagnosis: row.problemDiagnosis, optimizationAction: row.optimizationAction })), optimizationDecision: optimizationDecision.value }; }
function syncEvaluationRows(savedRows = []) {
const saved = new Map(savedRows.map((row) => [row?.channel, row]));
evaluationRows.value = performanceRows.value.filter((row) => row.channel && row.channel !== "合计").map((row) => makeEvaluationRow({ channel: row.channel, roi: row.actualRoi, ...saved.get(row.channel) }));
4 months ago
}
function openStepFour() { syncEvaluationRows(evaluationRows.value); activeStep.value = 4; }
async function saveAnswer(currentStep, fields) { await saveStudentTrainingAnswer(TASK_KEY, { saveAction: "SAVE", currentStep, ...fields }); }
async function saveBudgetDraft() { if (saving.value) return; saving.value = true; try { await saveAnswer(1, { step1Answer: JSON.stringify(budgetPayload()) }); ElMessage.success("当前进度已保存"); } finally { saving.value = false; } }
async function validateBudgetAndContinue() { if (validating.value) return; validating.value = true; try { await validatePromotionEffectOptimizationStepOne(budgetPayload()); await saveAnswer(2, { step1Answer: JSON.stringify(budgetPayload()) }); syncStrategyRows(strategyRows.value); activeStep.value = 2; ElMessage.success("第一步预算分配校验通过,已进入第二步"); } catch (error) { ElMessage.error(error?.message || "请检查投放试错预算、渠道预算占比和阶段投放预算"); } finally { validating.value = false; } }
function openStepTwo() { syncStrategyRows(strategyRows.value); activeStep.value = 2; }
async function saveStrategyDraft() { if (saving.value) return; saving.value = true; try { await saveAnswer(2, { step2Answer: JSON.stringify(strategyPayload()) }); ElMessage.success("当前进度已保存"); } finally { saving.value = false; } }
async function validateStrategies() { if (validating.value) return; validating.value = true; try { await validatePromotionEffectOptimizationStepTwo(strategyPayload()); await saveAnswer(3, { step2Answer: JSON.stringify(strategyPayload()) }); syncPerformanceRows(performanceRows.value); activeStep.value = 3; ElMessage.success("第二步投放策略校验通过,已进入第三步"); } catch (error) { ElMessage.error(error?.message || "请为每个阶段和渠道填写两项不重复的投放策略"); } finally { validating.value = false; } }
async function savePerformanceDraft() { if (saving.value) return; saving.value = true; try { await saveAnswer(3, { step3Answer: JSON.stringify(performancePayload()) }); ElMessage.success("当前进度已保存"); } finally { saving.value = false; } }
async function validatePerformance() { if (validating.value) return; validating.value = true; try { await validatePromotionEffectOptimizationStepThree(performancePayload()); await saveAnswer(4, { step3Answer: JSON.stringify(performancePayload()) }); syncEvaluationRows(evaluationRows.value); activeStep.value = 4; ElMessage.success("第三步保本 ROI 与投放数据校验通过,已进入第四步"); } catch (error) { ElMessage.error(error?.message || "请检查毛利率、保本 ROI 及各渠道投放数据计算"); } finally { validating.value = false; } }
function restorePerformance(raw) { try { const saved = typeof raw === "string" ? JSON.parse(raw || "{}") : raw || {}; grossMargin.value = saved.grossMargin; breakEvenRoi.value = saved.breakEvenRoi; syncPerformanceRows(Array.isArray(saved.rows) ? saved.rows : []); } catch (error) { grossMargin.value = undefined; breakEvenRoi.value = undefined; syncPerformanceRows(); } }
async function saveEvaluationDraft() { if (saving.value) return; saving.value = true; try { await saveAnswer(4, { step4Answer: JSON.stringify(evaluationPayload()) }); ElMessage.success("当前进度已保存"); } finally { saving.value = false; } }
async function submitEvaluation() { if (validating.value) return; validating.value = true; try { await validatePromotionEffectOptimizationStepFour(evaluationPayload()); await saveStudentTrainingAnswer(TASK_KEY, { saveAction: "SUBMIT", currentStep: 4, step4Answer: JSON.stringify(evaluationPayload()) }); ElMessage.success("投放效果评估与优化任务已提交"); } catch (error) { ElMessage.error(error?.message || "请检查各渠道达标判断、未达标渠道的诊断与优化动作"); } finally { validating.value = false; } }
function restoreEvaluation(raw) { try { const saved = typeof raw === "string" ? JSON.parse(raw || "{}") : raw || {}; optimizationDecision.value = String(saved.optimizationDecision || ""); syncEvaluationRows(Array.isArray(saved.rows) ? saved.rows : []); } catch (error) { optimizationDecision.value = ""; syncEvaluationRows(); } }
function restoreBudget(raw) { try { const saved = typeof raw === "string" ? JSON.parse(raw || "{}") : raw || {}; totalBudget.value = saved.totalBudget; if (Array.isArray(saved.rows) && saved.rows.length) budgetRows.value = saved.rows.slice(0, 5).map((row) => makeRow({ channel: row?.channel || "", channelPositioning: row?.channelPositioning || "", budgetPercentage: row?.budgetPercentage, coldStartBudget: row?.coldStartBudget, growthBudget: row?.growthBudget, stableBudget: row?.stableBudget })); } catch (error) { budgetRows.value = []; } if (!budgetRows.value.length) budgetRows.value = [makeRow()]; }
function readRows(raw) { try { const saved = typeof raw === "string" ? JSON.parse(raw || "{}") : raw || {}; return Array.isArray(saved.rows) ? saved.rows : []; } catch (error) { return []; } }
async function loadReleaseChannels() { try { const result = await getStudentTrainingAnswer(RELEASE_CHANNEL_TASK_KEY); const saved = typeof result?.data?.step2Answer === "string" ? JSON.parse(result.data.step2Answer || "{}") : result?.data?.step2Answer || {}; availableChannels.value = [...new Set((Array.isArray(saved?.rows) ? saved.rows : []).map((row) => String(row?.channel || "").trim()).filter(Boolean))]; } catch (error) { availableChannels.value = []; } }
async function loadProgress() { try { const result = await getStudentTrainingAnswer(TASK_KEY); const answer = result?.data || {}; restoreBudget(answer.step1Answer); syncStrategyRows(readRows(answer.step2Answer)); restorePerformance(answer.step3Answer); restoreEvaluation(answer.step4Answer); activeStep.value = Math.min(Math.max(Number(answer.currentStep) || 1, 1), 4); } catch (error) { restoreBudget(); syncStrategyRows(); restorePerformance(); restoreEvaluation(); } }
onMounted(async () => { try { const result = await getTrainingTaskByKey(TASK_KEY); taskConfig.value = result?.data || null; } catch (error) { taskConfig.value = null; } await Promise.all([loadReleaseChannels(), loadProgress()]); });
4 months ago
</script>
<style scoped lang="scss">
/* ===== 页面级令牌(墨黑极简主题) ===== */
.promotion-page {
--student-accent: #1f2937;
--student-accent-deep: #111827;
--student-accent-light: #4b5563;
--student-accent-soft: #f3f4f6;
--student-danger: #d9554c;
--student-success: #4a9d6e;
--student-layer-1: #374151;
--student-layer-2: #43a294;
--student-layer-3: #8b6fc0;
--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;
padding: 24px;
color: var(--student-text);
background: #ffffff;
}
.promotion-core {
display: grid;
gap: 10px;
align-content: start;
min-width: 0;
border: 0;
background: transparent;
box-shadow: none;
padding: 0;
}
/* 任务头:独立卡片 */
.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-badge {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 4px 12px;
border: 1px solid var(--student-panel-border);
border-radius: 999px;
color: var(--student-accent-deep);
background: var(--student-accent-soft);
font-size: 12px;
font-weight: 700;
}
.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-card 透明化 */
.task-card.promotion-phase {
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
display: grid;
gap: 10px;
margin: 0;
}
.workbench-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--student-panel-border);
}
.promotion-toolbar {
flex: 0 0 auto;
align-self: center;
justify-content: flex-end;
max-width: 100%;
}
.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-indicator 基础 reset(由非 scoped 块接管完整样式) */
.step-indicator { margin: 0; padding: 0; border: 0; background: transparent; }
.budget-form, .strategy-form, .performance-form, .evaluation-form { padding-top: 4px; }
.total-budget-field, .roi-inputs {
display: flex;
align-items: center;
gap: 12px;
color: var(--student-text);
font-size: 15px;
font-weight: 700;
}
.total-budget-field :deep(.el-input-number) { width: 250px; }
.total-budget-field em { font-style: normal; }
.lead {
margin: 12px 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
line-height: 1.55;
}
.table-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin: 0 0 10px;
}
/* ===== 表格容器透明化 + 新表格样式 ===== */
.table-wrap, .strategy-table-wrap, .performance-table-wrap, .evaluation-table-wrap, .reference-table-wrap {
overflow-x: auto;
margin-bottom: 8px;
}
.budget-table, .strategy-table, .reference-table, .performance-table, .evaluation-table {
width: 100%;
border-collapse: collapse;
}
.budget-table { min-width: 1300px; }
.strategy-table { min-width: 860px; }
.performance-table { min-width: 1080px; }
.evaluation-table { min-width: 1050px; }
.reference-table { min-width: 780px; }
.budget-table th, .budget-table td,
.strategy-table th, .strategy-table td,
.reference-table th, .reference-table td,
.performance-table th, .performance-table td,
.evaluation-table th, .evaluation-table td {
padding: 10px 12px;
border: 1px solid var(--student-panel-border);
vertical-align: middle;
}
/* thead 改新样式 */
.budget-table thead th, .strategy-table thead th, .reference-table thead th,
.performance-table thead th, .evaluation-table thead th {
white-space: nowrap;
color: var(--student-text);
background: #fafbfc;
font-size: var(--student-fs-body-sm);
font-weight: 800;
text-align: left;
}
/* tbody 透明 */
.budget-table td, .strategy-table td, .reference-table td,
.performance-table td, .evaluation-table td {
min-width: 170px;
background: transparent;
}
.budget-table td:first-child, .budget-table td:nth-child(2) { min-width: 155px; }
.performance-table th:first-child, .performance-table td:first-child { min-width: 110px; text-align: center; }
.strategy-table th:first-child { min-width: 116px; }
.strategy-table td { min-width: 240px; }
.reference-table td:first-child { min-width: 520px; line-height: 1.65; }
.evaluation-table th:first-child, .evaluation-table td:first-child { min-width: 110px; }
.evaluation-table td:nth-child(2) { min-width: 100px; text-align: center; }
.evaluation-table td:nth-child(3) { min-width: 145px; text-align: center; }
.evaluation-table td:nth-child(4), .evaluation-table td:nth-child(5) { min-width: 250px; }
/* 表格内 select / input / textarea 透明底 */
.budget-table :deep(.el-select), .budget-table :deep(.el-input-number),
.strategy-table :deep(.el-select), .performance-table :deep(.el-input-number),
.evaluation-table :deep(.el-select) { width: 100%; }
.budget-table :deep(.el-input__wrapper),
.strategy-table :deep(.el-input__wrapper),
.performance-table :deep(.el-input__wrapper),
.evaluation-table :deep(.el-input__wrapper),
.evaluation-table :deep(.el-textarea__inner),
.decision-section :deep(.el-textarea__inner) {
box-shadow: 0 0 0 1px var(--student-panel-border) inset !important;
background: #ffffff !important;
}
.budget-table :deep(.el-input__inner),
.strategy-table :deep(.el-input__inner),
.performance-table :deep(.el-input__inner),
.evaluation-table :deep(.el-input__inner) { color: var(--student-text); }
/* 计算数字墨黑 */
.computed-money, .synced-roi {
color: var(--student-accent-deep);
text-align: right;
font-variant-numeric: tabular-nums;
font-weight: 800;
}
.synced-roi { text-align: center; font-size: 15px; }
/* 预算表 tfoot */
.budget-table tfoot th, .budget-table tfoot td {
border-bottom: 1px solid var(--student-panel-border);
background: var(--student-panel-bg);
}
.budget-table tfoot td {
color: var(--student-success);
text-align: center;
font-weight: 700;
}
.budget-table .total-error { color: var(--student-danger); }
.table-note { font-size: var(--student-fs-caption); font-weight: 400; }
.strategy-table small {
display: block;
margin-top: 6px;
color: var(--student-text-secondary);
font-size: 12px;
text-align: right;
}
.empty-row { text-align: center; color: var(--student-text-secondary); }
.source-warning { margin: 14px 0 0; color: var(--student-danger); }
/* strategy-reference:改浅灰 */
.strategy-reference {
margin-top: 14px;
padding: 0 14px;
border-radius: 8px;
border: 1px solid var(--student-panel-border);
background: var(--student-panel-bg);
}
.strategy-reference :deep(.el-collapse) { border: 0; }
.strategy-reference :deep(.el-collapse-item__header) {
height: 44px;
border: 0;
color: var(--student-text);
background: transparent;
font-weight: 600;
font-size: var(--student-fs-body);
}
.strategy-reference :deep(.el-collapse-item__wrap) { border: 0; background: transparent; }
.strategy-reference :deep(.el-collapse-item__content) { color: var(--student-text-secondary); }
.strategy-reference .reference-table td { color: var(--student-text-secondary); font-size: var(--student-fs-body); font-weight: 400; }
.strategy-reference .reference-table td:nth-child(2),
.strategy-reference .reference-table td:nth-child(3) { color: var(--student-accent-light); font-weight: 600; }
/* 引导区(research-guide 同款:左墨黑边 + accent-soft 底) */
.roi-baseline, .evaluation-intro {
margin-top: 14px;
padding: 14px 16px;
border: 1px solid var(--student-panel-border);
border-radius: 0 8px 8px 0;
border-left: 3px solid var(--student-accent);
background: var(--student-accent-soft);
}
.subsection-title {
color: var(--student-text);
font-size: var(--student-fs-h2);
font-weight: 700;
}
.roi-baseline p, .performance-data > p,
.evaluation-intro p, .decision-section > p {
color: var(--student-text-secondary);
line-height: 1.55;
margin: 4px 0 0;
}
.roi-inputs { flex-wrap: wrap; justify-content: space-between; padding: 10px 0 4px; }
.roi-inputs label { display: flex; align-items: center; gap: 10px; }
.roi-inputs :deep(.el-input-number) { width: 150px; }
.roi-inputs label > span { color: var(--student-text-secondary); font-size: 13px; }
.formula-note { margin-bottom: 0; }
.performance-data { margin-top: 14px; }
.performance-table .total-row th,
.performance-table .total-row td {
background: var(--student-panel-bg);
font-weight: 700;
}
/* 整体 ROI */
.overall-roi {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
}
.overall-roi span {
padding: 5px 12px;
border: 1px solid var(--student-panel-border);
border-radius: 6px;
color: var(--student-text);
background: #ffffff;
font-weight: 600;
font-size: var(--student-fs-body);
}
.overall-roi .achieved { border-color: var(--student-success); color: var(--student-success); }
.overall-roi .underperforming { border-color: var(--student-danger); color: var(--student-danger); }
/* decision-section textarea */
.decision-section { margin-top: 14px; }
.decision-section :deep(.el-textarea__inner) {
min-height: 170px !important;
padding: 12px 14px;
line-height: 1.55;
}
/* footer 按钮间距 */
.footer-actions {
display: flex;
justify-content: center;
gap: 14px;
margin-top: 18px;
}
/* ===== 响应式 ===== */
@media (max-width: 900px) {
.task-header { flex-direction: column; align-items: flex-start; }
.promotion-toolbar { width: 100%; justify-content: flex-start; }
.promotion-page { padding: 14px; }
.workbench-head, .total-budget-field, .roi-inputs, .table-actions, .footer-actions {
align-items: stretch;
flex-direction: column;
}
.promotion-toolbar { width: 100%; justify-content: flex-start; }
.total-budget-field :deep(.el-input-number),
.roi-inputs :deep(.el-input-number),
.table-actions :deep(.el-button),
.footer-actions :deep(.el-button) { width: 100%; }
.footer-actions { flex-direction: column; }
}
@media (max-width: 720px) {
.overall-roi { align-items: stretch; flex-direction: column; }
}
</style>
<style lang="scss">
/* ===== 画布 + 行高 ===== */
#app .studentLayout .app-main:has(.promotion-page) {
background: #ffffff !important;
}
#app .studentLayout .app-main:has(.promotion-page) .promotion-page {
line-height: 1.55 !important;
}
/* ===== 步骤导航:按钮切换风 ===== */
#app .studentLayout section.app-main:has(.promotion-page) .step-indicator {
display: flex !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: center !important;
gap: 10px !important;
width: 100% !important;
margin: 4px 0 16px !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: 0 0 auto !important;
min-width: auto !important;
max-width: none !important;
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
height: auto !important;
min-height: 42px !important;
margin: 0 !important;
padding: 8px 14px !important;
border: 1px solid #e5e7eb !important;
border-radius: 8px !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: nowrap !important;
text-align: center !important;
cursor: pointer;
transition: background-color 0.16s ease, color 0.16s ease, border-color 0.16s ease !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: ui-monospace, "SF Mono", Consolas, monospace !important;
font-size: 12px !important;
font-weight: 700 !important;
line-height: 1 !important;
transition: background-color 0.16s ease, color 0.16s ease !important;
}
.step-copy {
display: flex !important;
flex-direction: column !important;
gap: 0 !important;
min-width: 0 !important;
}
.step-name {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: normal !important;
line-height: 1.3 !important;
color: inherit !important;
}
.step-action { display: none !important; }
/* 未到达项 hover */
&:hover:not(.active):not(.complete) {
border-color: #1f2937 !important;
background: #f9fafb !important;
color: #1f2937 !important;
.step-marker { background: #1f2937 !important; color: #ffffff !important; }
}
/* 已完成 */
&.complete:not(.active) {
border-color: #dce9e2 !important;
color: #374151 !important;
.step-marker { background: rgba(74, 157, 110, 0.12) !important; color: #3f8a5f !important; }
&:hover {
border-color: #4a9d6e !important;
background: #f9fafb !important;
.step-marker { background: #4a9d6e !important; color: #ffffff !important; }
}
}
/* 当前步骤 */
&.active {
border-color: #1f2937 !important;
background: #1f2937 !important;
color: #ffffff !important;
font-weight: 700 !important;
.step-marker { background: #ffffff !important; color: #1f2937 !important; }
&:hover {
background: #111827 !important;
border-color: #111827 !important;
}
}
}
}
/* ===== 窄屏:900px 横向滑动 ===== */
@media (max-width: 900px) {
#app .studentLayout section.app-main:has(.promotion-page) .step-indicator {
justify-content: flex-start !important;
gap: 8px !important;
overflow-x: auto !important;
}
#app .studentLayout section.app-main:has(.promotion-page) .step-indicator .step-tab {
flex: 0 0 auto !important;
max-width: none !important;
}
}
/* ===== 顶部任务工具栏换肤 ===== */
.promotion-page .training-task-toolbar { gap: 8px; }
.promotion-page .task-toolbar-button.el-button,
.promotion-page .material-button.el-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
color: #374151;
background: #ffffff;
box-shadow: none;
text-shadow: none;
font-size: 13px;
font-weight: 600;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease;
}
.promotion-page .task-toolbar-button.el-button .el-icon,
.promotion-page .material-button.el-button .el-icon {
font-size: 14px;
color: inherit;
}
.promotion-page .task-toolbar-button.el-button:hover,
.promotion-page .task-toolbar-button.el-button:focus,
.promotion-page .material-button.el-button:hover,
.promotion-page .material-button.el-button:focus {
border-color: #1f2937;
color: #1f2937;
background: #f9fafb;
box-shadow: 0 2px 6px rgba(17, 24, 39, 0.1);
}
.promotion-page .task-toolbar-button--submit.el-button {
border-color: #1f2937;
color: #ffffff;
background: #1f2937;
box-shadow: 0 2px 8px rgba(17, 24, 39, 0.22);
}
.promotion-page .task-toolbar-button--submit.el-button:hover,
.promotion-page .task-toolbar-button--submit.el-button:focus {
border-color: #111827;
color: #ffffff;
background: #111827;
box-shadow: 0 4px 12px rgba(17, 24, 39, 0.28);
}
.promotion-page .task-toolbar-button.el-button.is-loading {
border-color: #d1d5db;
color: #374151;
background: #ffffff;
}
</style>