|
|
<template>
|
|
|
<div class="promotion-page student-training-shell">
|
|
|
<main class="promotion-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-workbench">
|
|
|
<nav class="step-indicator" 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"><strong class="step-name">{{ step }}</strong><span class="step-action">{{
|
|
|
activeStep === index + 1 ? '当前步骤' : '点击切换' }} →</span></span>
|
|
|
</button>
|
|
|
<span v-if="index < trainingSteps.length - 1" class="step-connector" aria-hidden="true" />
|
|
|
</template>
|
|
|
</nav>
|
|
|
|
|
|
<section v-if="activeStep === 1" class="budget-form">
|
|
|
<header class="step-heading">
|
|
|
<p class="step-eyebrow">Step {{ String(activeStep).padStart(2, "0") }}</p>
|
|
|
<h2>{{ stepTitle }}</h2>
|
|
|
</header>
|
|
|
<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>
|
|
|
</section>
|
|
|
|
|
|
<section v-else-if="activeStep === 2" class="strategy-form">
|
|
|
<header class="step-heading">
|
|
|
<p class="step-eyebrow">Step {{ String(activeStep).padStart(2, "0") }}</p>
|
|
|
<h2>{{ stepTitle }}</h2>
|
|
|
</header>
|
|
|
<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}`">
|
|
|
<div class="strategy-input-stack">
|
|
|
<el-input v-model="strategyCell(phase, channel).strategies[0]"
|
|
|
maxlength="80" placeholder="填写策略一"
|
|
|
:aria-label="`${phase}-${channel} 策略一`" />
|
|
|
<el-input v-model="strategyCell(phase, channel).strategies[1]"
|
|
|
maxlength="80" placeholder="填写策略二"
|
|
|
:aria-label="`${phase}-${channel} 策略二`" />
|
|
|
</div>
|
|
|
<small>{{ filledStrategyCount(phase, channel) }}/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>
|
|
|
</section>
|
|
|
|
|
|
<section v-else-if="activeStep === 3" class="performance-form">
|
|
|
<header class="step-heading">
|
|
|
<p class="step-eyebrow">Step {{ String(activeStep).padStart(2, "0") }}</p>
|
|
|
<h2>{{ stepTitle }}</h2>
|
|
|
</header>
|
|
|
<p class="lead">产品投放结束后,企业从各渠道后台导出投放数据,核算每个渠道的点击率和ROI,判断是否达标。请从案例资料中获取各渠道的真实投放数据(曝光量、点击量、成交额、推广花费),填入下表并核算各渠道的点击率与ROI(投产比)。</p>
|
|
|
<section class="roi-baseline">
|
|
|
<div class="section-heading"><span>01</span>
|
|
|
<div>
|
|
|
<h3>计算保本 ROI</h3>
|
|
|
<p>根据案例资料填写产品毛利率,计算保本 ROI。</p>
|
|
|
</div>
|
|
|
</div>
|
|
|
<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="section-heading"><span>02</span>
|
|
|
<div>
|
|
|
<h3>完善投放数据</h3>
|
|
|
<p>根据案例资料填写下表,并计算点击率和实际 ROI。</p>
|
|
|
</div>
|
|
|
</div>
|
|
|
<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>
|
|
|
</section>
|
|
|
|
|
|
<section v-else class="evaluation-form">
|
|
|
<header class="step-heading">
|
|
|
<p class="step-eyebrow">Step {{ String(activeStep).padStart(2, "0") }}</p>
|
|
|
<h2>{{ stepTitle }}</h2>
|
|
|
</header>
|
|
|
<section class="evaluation-intro">
|
|
|
<div class="section-heading"><span>01</span>
|
|
|
<div>
|
|
|
<h3>投放效果评估</h3>
|
|
|
<p>企业投放目标为整体ROI不低于保本ROI。请依据步骤三的核算结果,判断各渠道投放是否达标;对不达标渠道,根据案例资料从选项池中选择最可能的问题诊断与优化动作。</p>
|
|
|
</div>
|
|
|
</div>
|
|
|
<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="section-heading"><span>02</span>
|
|
|
<div>
|
|
|
<h3>优化决策</h3>
|
|
|
<p>结合整体ROI与各渠道达标情况,总结本方案是否需要调整及调整方向,包括,整体ROI是否达标?哪些渠道拖累整体效果?优先调整哪个渠道?</p>
|
|
|
</div>
|
|
|
</div>
|
|
|
<el-input v-model="optimizationDecision" type="textarea" :autosize="{ minRows: 7, maxRows: 14 }"
|
|
|
placeholder="填写整体 ROI 是否达标、拖累整体效果的渠道及优先优化方向" />
|
|
|
</section>
|
|
|
</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>
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
|
import { computed, onMounted, ref } from "vue"
|
|
|
import { ArrowLeft, ArrowRight, CircleCheck, 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, 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(true)
|
|
|
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 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) }
|
|
|
while (row.strategies.length < 2) row.strategies.push("")
|
|
|
return row
|
|
|
}
|
|
|
function filledStrategyCount(phase, channel) { return strategyCell(phase, channel).strategies.filter((item) => String(item || "").trim()).length }
|
|
|
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) => {
|
|
|
const savedStrategies = Array.isArray(saved.get(`${phase}\u0000${channel}`)?.strategies) ? [...saved.get(`${phase}\u0000${channel}`).strategies] : []
|
|
|
while (savedStrategies.length < 2) savedStrategies.push("")
|
|
|
return makeStrategyRow({ phase, channel, strategies: savedStrategies })
|
|
|
}))
|
|
|
}
|
|
|
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) }))
|
|
|
}
|
|
|
const DEFAULT_STEPS = ["投放预算分配", "分渠道投放策略", "投放数据与 ROI", "投放效果评估与优化"]
|
|
|
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 === 2) return openStepTwo()
|
|
|
if (step === 3) return openStepThree()
|
|
|
if (step === 4) return openStepFour()
|
|
|
activeStep.value = 1
|
|
|
}
|
|
|
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) }))
|
|
|
}
|
|
|
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 saveCurrentProgress() { if (activeStep.value === 1) return saveBudgetDraft(); if (activeStep.value === 2) return saveStrategyDraft(); if (activeStep.value === 3) return savePerformanceDraft(); return saveEvaluationDraft() }
|
|
|
function previousStep() { if (activeStep.value > 1) activeStep.value -= 1 }
|
|
|
function nextStep() { if (activeStep.value === 1) return validateBudgetAndContinue(); if (activeStep.value === 2) return validateStrategies(); if (activeStep.value === 3) return validatePerformance() }
|
|
|
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 } }
|
|
|
async function submitTask() { return submitEvaluation() }
|
|
|
async function resetTraining() { if (saving.value || validating.value) return; try { await deleteStudentTrainingAnswer(TASK_KEY); restoreBudget(); syncStrategyRows(); restorePerformance(); restoreEvaluation(); ElMessage.success("已清空全部实训步骤,可以重新开始") } catch (error) { ElMessage.error("重新实训失败,请稍后重试") } }
|
|
|
function exportOutcome() { ElMessage.info("导出功能正在开发中") }
|
|
|
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()]) })
|
|
|
</script>
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
/* ===== 页面级令牌(墨黑极简主题) ===== */
|
|
|
.promotion-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;
|
|
|
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-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;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/* task-workbench 透明容器 */
|
|
|
.task-workbench {
|
|
|
display: grid;
|
|
|
gap: 10px;
|
|
|
align-content: start;
|
|
|
}
|
|
|
|
|
|
/* 操作工具栏横条:内联布局,去掉悬浮和边框 */
|
|
|
.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;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.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: var(--student-fs-h1);
|
|
|
font-weight: 700;
|
|
|
}
|
|
|
|
|
|
.total-budget-field :deep(.el-input-number) {
|
|
|
width: 250px;
|
|
|
}
|
|
|
|
|
|
.total-budget-field em {
|
|
|
font-style: normal;
|
|
|
}
|
|
|
|
|
|
.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 12px;
|
|
|
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: var(--student-fs-h1);
|
|
|
}
|
|
|
|
|
|
/* 预算表 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: var(--student-fs-body-sm);
|
|
|
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-input-stack {
|
|
|
display: grid;
|
|
|
gap: 8px;
|
|
|
}
|
|
|
|
|
|
.strategy-input-stack .el-input {
|
|
|
width: 100%;
|
|
|
}
|
|
|
|
|
|
.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);
|
|
|
}
|
|
|
|
|
|
.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: flex-start;
|
|
|
gap: 26px;
|
|
|
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: var(--student-fs-body);
|
|
|
}
|
|
|
|
|
|
.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;
|
|
|
}
|
|
|
|
|
|
/* ===== 底部步骤导航按钮 ===== */
|
|
|
.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: 900px) {
|
|
|
.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;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.promotion-page {
|
|
|
padding: 14px;
|
|
|
}
|
|
|
|
|
|
.total-budget-field,
|
|
|
.roi-inputs,
|
|
|
.table-actions {
|
|
|
align-items: stretch;
|
|
|
flex-direction: column;
|
|
|
}
|
|
|
|
|
|
.total-budget-field :deep(.el-input-number),
|
|
|
.roi-inputs :deep(.el-input-number),
|
|
|
.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%;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@media (max-width: 720px) {
|
|
|
.overall-roi {
|
|
|
align-items: stretch;
|
|
|
flex-direction: column;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/* ===== 小节标题(蓝底序号,与 diagnosis 页统一) ===== */
|
|
|
.section-heading {
|
|
|
display: flex;
|
|
|
align-items: flex-start;
|
|
|
gap: 12px;
|
|
|
margin-bottom: 10px;
|
|
|
|
|
|
>span {
|
|
|
display: grid;
|
|
|
flex: 0 0 28px;
|
|
|
width: 28px;
|
|
|
height: 28px;
|
|
|
place-items: center;
|
|
|
border-radius: 6px;
|
|
|
color: #ffffff;
|
|
|
background: var(--student-accent, #2563eb);
|
|
|
font-size: var(--student-fs-body, 14px);
|
|
|
font-weight: 700;
|
|
|
}
|
|
|
|
|
|
h3 {
|
|
|
margin: 0;
|
|
|
color: var(--student-text, #1f2937);
|
|
|
font-size: var(--student-fs-h1, 18px);
|
|
|
font-weight: 700;
|
|
|
}
|
|
|
|
|
|
p {
|
|
|
margin: 4px 0 0;
|
|
|
color: var(--student-text-secondary, #6b7280);
|
|
|
font-size: var(--student-fs-body, 14px);
|
|
|
line-height: 1.6;
|
|
|
}
|
|
|
}
|
|
|
</style>
|
|
|
|
|
|
<style lang="scss">
|
|
|
/* ===== 步骤导航:分段连接胶囊(教育蓝) ===== */
|
|
|
#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: 0 !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 {
|
|
|
|
|
|
min-width: 0 !important;
|
|
|
|
|
|
display: flex !important;
|
|
|
flex-direction: row !important;
|
|
|
align-items: center !important;
|
|
|
justify-content: center !important;
|
|
|
gap: 8px !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;
|
|
|
|
|
|
&:first-child {
|
|
|
border-radius: 8px 0 0 8px !important;
|
|
|
}
|
|
|
|
|
|
&:last-child {
|
|
|
border-radius: 0 8px 8px 0 !important;
|
|
|
border-right: 1px solid #e5e7eb !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: 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: var(--student-fs-body-sm) !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: nowrap !important;
|
|
|
line-height: 1.3 !important;
|
|
|
color: inherit !important;
|
|
|
}
|
|
|
|
|
|
.step-action {
|
|
|
display: none !important;
|
|
|
}
|
|
|
|
|
|
/* 未到达项 hover:教育蓝边浅底 */
|
|
|
&: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;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/* ===== 窄屏:900px 横向滑动 ===== */
|
|
|
@media (max-width: 900px) {
|
|
|
#app .studentLayout section.app-main:has(.promotion-page) .step-indicator {
|
|
|
justify-content: flex-start !important;
|
|
|
overflow-x: auto !important;
|
|
|
}
|
|
|
|
|
|
#app .studentLayout section.app-main:has(.promotion-page) .step-indicator .step-tab {
|
|
|
flex: 0 0 140px !important;
|
|
|
max-width: none !important;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/* ===== 顶部任务工具栏换肤 ===== */
|
|
|
.promotion-page .training-task-toolbar {
|
|
|
gap: 8px;
|
|
|
}
|
|
|
|
|
|
.promotion-page .task-toolbar-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 rgba(37, 99, 235, 0.35);
|
|
|
border-radius: 8px;
|
|
|
color: #2563eb;
|
|
|
background: #ffffff;
|
|
|
box-shadow: none;
|
|
|
text-shadow: none;
|
|
|
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;
|
|
|
}
|
|
|
|
|
|
.promotion-page .task-toolbar-button.el-button .el-icon {
|
|
|
font-size: var(--student-fs-h2);
|
|
|
color: inherit;
|
|
|
}
|
|
|
|
|
|
.promotion-page .task-toolbar-button.el-button:hover,
|
|
|
.promotion-page .task-toolbar-button.el-button:focus {
|
|
|
border-color: #2563eb;
|
|
|
color: #2563eb;
|
|
|
background: #eff6ff;
|
|
|
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12);
|
|
|
}
|
|
|
|
|
|
.promotion-page .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);
|
|
|
}
|
|
|
|
|
|
.promotion-page .task-toolbar-button--submit.el-button:hover,
|
|
|
.promotion-page .task-toolbar-button--submit.el-button: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);
|
|
|
}
|
|
|
|
|
|
.promotion-page .task-toolbar-button.el-button.is-loading {
|
|
|
border-color: rgba(37, 99, 235, 0.35);
|
|
|
color: #2563eb;
|
|
|
background: #ffffff;
|
|
|
}
|
|
|
</style>
|