|
|
<template>
|
|
|
<div class="release-channel-page student-training-shell">
|
|
|
<main class="release-channel-core">
|
|
|
<section class="task-header">
|
|
|
<div class="task-head-main">
|
|
|
<h1 class="task-title">{{ taskTitle }}</h1>
|
|
|
<p class="task-brief">
|
|
|
<span class="task-brief-text">{{ taskBackground }}</span>
|
|
|
<button type="button" class="task-brief-toggle" @click="briefOpen = !briefOpen">
|
|
|
{{ briefOpen ? "收起" : "展开目标与要求" }}
|
|
|
</button>
|
|
|
<span v-if="briefOpen" class="task-brief-more">
|
|
|
<b>实训目标</b>
|
|
|
<ul>
|
|
|
<li v-for="goal in taskGoals" :key="goal">{{ goal }}</li>
|
|
|
</ul>
|
|
|
<b>实训要求</b>
|
|
|
<p>{{ taskRequirement }}</p>
|
|
|
</span>
|
|
|
</p>
|
|
|
</div>
|
|
|
</section>
|
|
|
|
|
|
<section class="task-action-strip">
|
|
|
<TrainingTaskToolbar :task-key="TASK_KEY" :on-submit="submitTask" :on-reset="resetTraining"
|
|
|
:on-export="exportOutcome" />
|
|
|
</section>
|
|
|
|
|
|
<section class="task-card">
|
|
|
<nav class="step-tabs" aria-label="产品发布渠道评估步骤">
|
|
|
<template v-for="(step, index) in trainingSteps" :key="`${step}-${index}`">
|
|
|
<button type="button" :class="['step-tab', { active: activeStep === index + 1, complete: activeStep > index + 1 }]"
|
|
|
:aria-current="activeStep === index + 1 ? 'step' : undefined" @click="goToStepFromTab(index + 1)">
|
|
|
<span class="step-marker">{{ String(index + 1).padStart(2, "0") }}</span><span class="step-copy"><b>{{ step
|
|
|
}}</b><small>{{ activeStep === index + 1
|
|
|
? '当前步骤 →' : '点击切换 →' }}</small></span>
|
|
|
</button>
|
|
|
<span v-if="index < trainingSteps.length - 1" class="step-connector" aria-hidden="true" />
|
|
|
</template>
|
|
|
</nav>
|
|
|
|
|
|
<section v-if="activeStep === 1">
|
|
|
<header class="step-heading">
|
|
|
<p class="step-eyebrow">Step 01</p>
|
|
|
<h2>权重设置</h2>
|
|
|
</header>
|
|
|
<p class="lead">不同维度对新品渠道效果的影响不一样,请给以下核心评估维度设置权重,按照重要性分配权重,总权重合计 100%。</p>
|
|
|
<div class="table-actions">
|
|
|
<el-button type="primary" @click="addWeightRow"><el-icon>
|
|
|
<Plus />
|
|
|
</el-icon>增加一行</el-button>
|
|
|
<el-button @click="weightRows.pop()" :disabled="!weightRows.length"><el-icon>
|
|
|
<Minus />
|
|
|
</el-icon>删除一行</el-button>
|
|
|
</div>
|
|
|
<div class="table-wrap">
|
|
|
<table class="weight-table">
|
|
|
<thead>
|
|
|
<tr>
|
|
|
<th>评估维度</th>
|
|
|
<th>维度说明</th>
|
|
|
<th>权重占比(%)</th>
|
|
|
<th class="operation-column">操作</th>
|
|
|
</tr>
|
|
|
</thead>
|
|
|
<tbody>
|
|
|
<tr v-for="(row, index) in weightRows" :key="row.id">
|
|
|
<td>
|
|
|
<el-input v-if="!row.preset" v-model="row.dimension" maxlength="50"
|
|
|
placeholder="填写评估维度" />
|
|
|
<span v-else class="preset-text">{{ row.dimension }}</span>
|
|
|
</td>
|
|
|
<td>
|
|
|
<el-input v-if="!row.preset" v-model="row.description" maxlength="200"
|
|
|
placeholder="填写维度说明" />
|
|
|
<span v-else class="preset-text">{{ row.description }}</span>
|
|
|
</td>
|
|
|
<td><el-input-number v-model="row.weight" :min="0" :max="100" :precision="2"
|
|
|
:step="1" controls-position="right" /><small v-if="row.suggestion">建议权重:{{
|
|
|
row.suggestion }}</small></td>
|
|
|
<td class="operation-column"><el-button v-if="!row.preset" link type="danger"
|
|
|
@click="weightRows.splice(index, 1)">删除本行</el-button></td>
|
|
|
</tr>
|
|
|
<tr v-if="!weightRows.length">
|
|
|
<td colspan="4" class="empty-row">暂无评估维度,请增加一行后填写。</td>
|
|
|
</tr>
|
|
|
</tbody>
|
|
|
<tfoot>
|
|
|
<tr>
|
|
|
<th colspan="2">当前权重合计</th>
|
|
|
<td :class="{ 'total-error': weightTotal !== 100 }">{{ displayWeightTotal }}%</td>
|
|
|
<td></td>
|
|
|
</tr>
|
|
|
</tfoot>
|
|
|
</table>
|
|
|
</div>
|
|
|
<p class="hint">默认展示上述7个评估维度和建议权重范围,具体权重需由学生自行填写,可另外新增评估维度;点击下一步时系统校验所有权重之和必须等于 100%。</p>
|
|
|
</section>
|
|
|
|
|
|
<section v-else-if="activeStep === 2">
|
|
|
<header class="step-heading">
|
|
|
<p class="step-eyebrow">Step 02</p>
|
|
|
<h2>渠道数据</h2>
|
|
|
</header>
|
|
|
<p class="lead">根据案例资料提供的 5 大渠道近 3 个月数据,汇总填写下表。</p>
|
|
|
<div class="table-actions">
|
|
|
<el-button type="primary" @click="addChannelRow"><el-icon>
|
|
|
<Plus />
|
|
|
</el-icon>增加一行</el-button>
|
|
|
<el-button @click="channelRows.pop()" :disabled="!channelRows.length"><el-icon>
|
|
|
<Minus />
|
|
|
</el-icon>删除一行</el-button>
|
|
|
<el-button type="primary" plain @click="exportChannelRows"><el-icon>
|
|
|
<Download />
|
|
|
</el-icon>导出</el-button>
|
|
|
</div>
|
|
|
<div class="table-wrap">
|
|
|
<table class="channel-table">
|
|
|
<thead>
|
|
|
<tr>
|
|
|
<th>渠道</th>
|
|
|
<th>日均总搜索量</th>
|
|
|
<th>供需比</th>
|
|
|
<th>平均点击率(%)</th>
|
|
|
<th>平均CPC(元)</th>
|
|
|
<th>平均转化率(%)</th>
|
|
|
<th>现有相关访客数</th>
|
|
|
<th>匹配度(1-100)</th>
|
|
|
<th class="operation-column">操作</th>
|
|
|
</tr>
|
|
|
</thead>
|
|
|
<tbody>
|
|
|
<tr v-for="(row, index) in channelRows" :key="row.id">
|
|
|
<td><el-input v-model="row.channel" maxlength="30" placeholder="填写渠道" /></td>
|
|
|
<td><el-input-number v-model="row.dailySearchVolume" :min="0" :precision="0"
|
|
|
controls-position="right" /></td>
|
|
|
<td><el-input-number v-model="row.supplyDemandRatio" :min="0" :precision="4"
|
|
|
:step="0.1" controls-position="right" /></td>
|
|
|
<td><el-input-number v-model="row.averageClickRate" :min="0" :max="100"
|
|
|
:precision="2" controls-position="right" /></td>
|
|
|
<td><el-input-number v-model="row.averageCpc" :min="0" :precision="2"
|
|
|
controls-position="right" /></td>
|
|
|
<td><el-input-number v-model="row.averageConversionRate" :min="0" :max="100"
|
|
|
:precision="2" controls-position="right" /></td>
|
|
|
<td><el-input-number v-model="row.existingVisitorCount" :min="0" :precision="0"
|
|
|
controls-position="right" /></td>
|
|
|
<td><el-input-number v-model="row.matchingScore" :min="1" :max="100" :precision="0"
|
|
|
controls-position="right" /></td>
|
|
|
<td class="operation-column"><el-button v-if="!row.preset" link type="danger"
|
|
|
@click="channelRows.splice(index, 1)">删除本行</el-button></td>
|
|
|
</tr>
|
|
|
<tr v-if="!channelRows.length">
|
|
|
<td colspan="9" class="empty-row">暂无渠道数据,请增加一行后填写。</td>
|
|
|
</tr>
|
|
|
</tbody>
|
|
|
</table>
|
|
|
</div>
|
|
|
<p class="hint">默认展示上述5个预置渠道,不可删除;可另外新增其他渠道。保存不拦截,点击下一步时由系统校验全部指标。</p>
|
|
|
</section>
|
|
|
|
|
|
<section v-else-if="activeStep === 3">
|
|
|
<header class="step-heading">
|
|
|
<p class="step-eyebrow">Step 03</p>
|
|
|
<h2>归一化评分</h2>
|
|
|
</header>
|
|
|
<p class="scoring-guide">通过Min-Max归一法将各维度原始数据转换为0-10分标准化得分,计算规则如下:<br>
|
|
|
正向指标(越大越好,如搜索量、点击率、转化率、访客数、匹配度),得分=(该渠道值−最小值)/(最大值−最小值)× 10<br>
|
|
|
逆向指标(越小越好,如供需比、CPC):得分=(最大值−该渠道值)/(最大值−最小值)× 10</p>
|
|
|
<div class="table-actions"><el-button type="primary" plain @click="exportScoreRows"><el-icon>
|
|
|
<Download />
|
|
|
</el-icon>导出</el-button></div>
|
|
|
<div class="table-wrap scoring-table-wrap">
|
|
|
<table class="scoring-table">
|
|
|
<thead>
|
|
|
<tr>
|
|
|
<th rowspan="2">渠道</th>
|
|
|
<th v-for="metric in scoringMetrics" :key="metric.key" colspan="2">{{ metric.label
|
|
|
}}</th>
|
|
|
<th rowspan="2">加权得分</th>
|
|
|
</tr>
|
|
|
<tr>
|
|
|
<template v-for="metric in scoringMetrics" :key="`${metric.key}-sub`">
|
|
|
<th>得分</th>
|
|
|
<th>权重</th>
|
|
|
</template>
|
|
|
</tr>
|
|
|
</thead>
|
|
|
<tbody>
|
|
|
<tr v-for="row in scoreRows" :key="row.id">
|
|
|
<th scope="row">{{ row.channel }}</th>
|
|
|
<template v-for="metric in row.dimensions" :key="metric.key">
|
|
|
<td><el-input-number v-model="metric.score" :min="0" :max="10" :precision="1"
|
|
|
:step="0.1" controls-position="right" /></td>
|
|
|
<td class="weight-cell">{{ metric.weight ?? "—" }}%</td>
|
|
|
</template>
|
|
|
<td><el-input-number v-model="row.weightedScore" :min="0" :precision="2"
|
|
|
:step="0.01" controls-position="right" /></td>
|
|
|
</tr>
|
|
|
<tr v-if="!scoreRows.length">
|
|
|
<td :colspan="2 + scoringMetrics.length * 2" class="empty-row">请先完成并保存第二步渠道数据。</td>
|
|
|
</tr>
|
|
|
</tbody>
|
|
|
</table>
|
|
|
</div>
|
|
|
<p class="hint">渠道名称同步第二步,权重同步第一步。每项得分乘以对应权重(按百分比折算)并相加得到加权得分;系统会依据已保存的前两步数据检测计算结果。</p>
|
|
|
<p class="step-three-notice">说明:渠道来自第二步,权重来自第一步;每个维度得分乘以权重后相加即为加权得分。计算结果不一致时仍可进入下一步,系统会记录检测结果,供 AI
|
|
|
助评扣分参考。</p>
|
|
|
</section>
|
|
|
|
|
|
<section v-else>
|
|
|
<header class="step-heading">
|
|
|
<p class="step-eyebrow">Step 04</p>
|
|
|
<h2>渠道排名</h2>
|
|
|
</header>
|
|
|
<p class="lead">根据步骤一设置的权重和步骤三的归一化得分,填写各渠道加权综合得分并排序,明确前两位核心渠道。</p>
|
|
|
<div class="table-wrap ranking-table-wrap">
|
|
|
<table class="ranking-table">
|
|
|
<thead>
|
|
|
<tr>
|
|
|
<th>排名</th>
|
|
|
<th>渠道</th>
|
|
|
<th>加权综合得分</th>
|
|
|
<th>是否选为核心渠道</th>
|
|
|
</tr>
|
|
|
</thead>
|
|
|
<tbody>
|
|
|
<tr v-for="(row, index) in rankingRows" :key="row.id">
|
|
|
<td class="rank-cell">{{ index + 1 }}</td>
|
|
|
<td><el-input v-model="row.channel" maxlength="30" placeholder="填写渠道" /></td>
|
|
|
<td><el-input-number v-model="row.weightedScore" :min="0" :precision="2"
|
|
|
:step="0.01" controls-position="right" /></td>
|
|
|
<td class="core-cell"><span :class="{ core: index < 2 }">{{ index < 2 ? "是" : "否"
|
|
|
}}</span>
|
|
|
</td>
|
|
|
</tr>
|
|
|
<tr v-if="!rankingRows.length">
|
|
|
<td colspan="4" class="empty-row">请先完成并保存第三步归一化评分。</td>
|
|
|
</tr>
|
|
|
</tbody>
|
|
|
</table>
|
|
|
</div>
|
|
|
<label class="analysis-field"><span>请根据以上计算结果,结合案例文档内容,分析所选核心渠道的优势及运营建议:</span><el-input
|
|
|
v-model="coreChannelAnalysis" type="textarea" :autosize="{ minRows: 7, maxRows: 14 }"
|
|
|
placeholder="填写核心渠道优势、资源投入重点和运营建议" /></label>
|
|
|
<p class="hint">渠道数量同步第三步,排名自动从 1 往下排列。渠道与加权综合得分需由学生填写,并与第三步数据逐项一致;得分必须按从高到低排序,前两名自动显示为核心渠道。</p>
|
|
|
</section>
|
|
|
|
|
|
<div class="step-navigation">
|
|
|
<div class="step-nav-left">
|
|
|
<button v-if="activeStep > 1" type="button" class="btn-nav btn-ghost" :disabled="saving" @click="previousStep">
|
|
|
<el-icon><ArrowLeft /></el-icon>
|
|
|
上一步
|
|
|
</button>
|
|
|
<button type="button" class="btn-nav btn-save" :disabled="saving" @click="saveCurrentProgress">
|
|
|
<el-icon><CircleCheck /></el-icon>
|
|
|
保存
|
|
|
</button>
|
|
|
</div>
|
|
|
<div class="step-nav-right">
|
|
|
<span class="step-progress">Step {{ String(activeStep).padStart(2, "0") }} / {{ String(4).padStart(2, "0") }}</span>
|
|
|
<button v-if="activeStep < 4" type="button" class="btn-nav btn-primary" :disabled="validating" @click="nextStep">
|
|
|
下一步
|
|
|
<el-icon><ArrowRight /></el-icon>
|
|
|
</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</section>
|
|
|
</main>
|
|
|
<TrainingAiSidebar :study-tip="'先确定可解释的评估权重,再完整汇总各渠道近三个月数据。'" :progress-items="progressItems" />
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
|
import { computed, onMounted, ref } from "vue"
|
|
|
import { ArrowLeft, ArrowRight, CircleCheck, Download, Minus, Plus } from "@element-plus/icons-vue"
|
|
|
import { ElMessage } from "element-plus"
|
|
|
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"
|
|
|
import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue"
|
|
|
import { getTrainingTaskByKey } from "@/api/trainingTask"
|
|
|
import { deleteStudentTrainingAnswer, getStudentTrainingAnswer, saveStudentTrainingAnswer, validateReleaseChannelEvaluationStepOne, validateReleaseChannelEvaluationStepTwo, validateReleaseChannelEvaluationStepThree, validateReleaseChannelEvaluationStepFour } from "@/api/studentTrainingAnswer"
|
|
|
import { parseTrainingArray } from "@/views/training/taskKeyMap"
|
|
|
|
|
|
const TASK_KEY = "release-channel-evaluation"
|
|
|
const RELEASE_CHANNEL_GUIDE_URL = "/file/training-materials/release-channel-evaluation/21.实训操作说明.docx"
|
|
|
const activeStep = ref(1)
|
|
|
const briefOpen = ref(true)
|
|
|
const taskConfig = ref(null)
|
|
|
const saving = ref(false)
|
|
|
const validating = ref(false)
|
|
|
let nextId = 1
|
|
|
const makeRow = (values = {}) => ({ id: nextId++, ...values })
|
|
|
const defaultWeightRows = () => [
|
|
|
["市场规模潜力", "对应关键词日均总搜索量,代表渠道用户需求大盘空间", "20~25"], ["竞争激烈程度", "供需比(在线商品数/日均搜索量),数值越低渠道竞争越宽松", "15~20"], ["流量点击效率", "品类平均点击率,代表渠道用户对该品类的点击意愿强弱", "12~18"], ["流量获取成本", "平均CPC,数值越低获取精准用户的推广成本越低", "15~20"], ["流量转化效率", "品类平均转化率,代表渠道用户的最终下单意愿", "12~18"], ["现有用户基础", "店铺现有该品类相关访客数,代表品牌存量用户承接能力", "5~10"], ["匹配度", "渠道属性与产品定位的适配度打分(1-10分)", "5~10"],
|
|
|
].map(([dimension, description, suggestion]) => makeRow({ dimension, description, weight: undefined, suggestion, preset: true }))
|
|
|
const defaultChannelRows = () => ["天猫", "京东", "抖音", "拼多多", "唯品会"].map((channel) => makeRow({ channel, preset: true }))
|
|
|
const weightSuggestions = Object.fromEntries(defaultWeightRows().map((row) => [row.dimension, row.suggestion]))
|
|
|
const weightRows = ref(defaultWeightRows())
|
|
|
const channelRows = ref(defaultChannelRows())
|
|
|
const scoreRows = ref([])
|
|
|
const rankingRows = ref([])
|
|
|
const coreChannelAnalysis = ref("")
|
|
|
const scoringMetrics = [
|
|
|
{ key: "marketScale", dimension: "市场规模潜力", label: "市场规模得分" },
|
|
|
{ key: "competition", dimension: "竞争激烈程度", label: "竞争程度得分" },
|
|
|
{ key: "click", dimension: "流量点击效率", label: "点击效率得分" },
|
|
|
{ key: "cost", dimension: "流量获取成本", label: "获客成本得分" },
|
|
|
{ key: "conversion", dimension: "流量转化效率", label: "转化效率得分" },
|
|
|
{ key: "userBase", dimension: "现有用户基础", label: "用户基础得分" },
|
|
|
{ key: "match", dimension: "匹配度", label: "匹配度得分" },
|
|
|
]
|
|
|
const taskTitle = computed(() => taskConfig.value?.taskName || "产品发布渠道评估")
|
|
|
const taskBackground = computed(() => taskConfig.value?.background || "结合新品定位与渠道特征,为产品发布选择更合适的投放渠道。")
|
|
|
const taskRequirement = computed(() => taskConfig.value?.requirements || "设置渠道评估维度与权重,并汇总渠道基础数据。")
|
|
|
const taskGoals = computed(() => parseTrainingArray(taskConfig.value?.objectives, ["理解渠道评估的核心维度", "能够按重要性配置评估权重", "汇总渠道数据并完成评估基础"]))
|
|
|
const weightTotal = computed(() => Number(weightRows.value.reduce((sum, row) => sum + (Number(row.weight) || 0), 0).toFixed(6)))
|
|
|
const displayWeightTotal = computed(() => Number.isInteger(weightTotal.value) ? String(weightTotal.value) : weightTotal.value.toFixed(2))
|
|
|
const progressItems = computed(() => parseTrainingArray(taskConfig.value?.steps).map((text, index) => ({ text, status: index + 1 < activeStep.value ? "done" : index + 1 === activeStep.value ? "doing" : "" })))
|
|
|
const DEFAULT_STEPS = ["权重设置", "渠道数据", "归一化评分", "渠道排名"]
|
|
|
const trainingSteps = computed(() => {
|
|
|
const fromConfig = parseTrainingArray(taskConfig.value?.steps)
|
|
|
return (fromConfig.length ? fromConfig : DEFAULT_STEPS).slice(0, DEFAULT_STEPS.length)
|
|
|
})
|
|
|
function goToStepFromTab(step) {
|
|
|
if (step === 3) return openStepThree()
|
|
|
if (step === 4) return openStepFour()
|
|
|
activeStep.value = step
|
|
|
}
|
|
|
const weightPayload = () => ({ rows: weightRows.value.map(({ dimension, description, weight }) => ({ dimension, description, weight })) })
|
|
|
const channelPayload = () => ({ rows: channelRows.value.map(({ channel, dailySearchVolume, supplyDemandRatio, averageClickRate, averageCpc, averageConversionRate, existingVisitorCount, matchingScore }) => ({ channel, dailySearchVolume, supplyDemandRatio, averageClickRate, averageCpc, averageConversionRate, existingVisitorCount, matchingScore })) })
|
|
|
const scorePayload = () => ({ rows: scoreRows.value.map((row) => ({ channel: row.channel, weightedScore: row.weightedScore, dimensions: row.dimensions.map(({ dimension, score, weight }) => ({ dimension, score, weight })) })) })
|
|
|
const scoreAnswerPayload = (calculationCheck = {}) => ({
|
|
|
...scorePayload(), calculationCheck: {
|
|
|
calculationCorrect: calculationCheck.calculationCorrect === true,
|
|
|
totalItemCount: Number(calculationCheck.totalItemCount) || 0,
|
|
|
incorrectItemCount: Number(calculationCheck.incorrectItemCount) || 0,
|
|
|
incorrectItems: Array.isArray(calculationCheck.incorrectItems) ? calculationCheck.incorrectItems : [],
|
|
|
}
|
|
|
})
|
|
|
const rankingPayload = () => ({ rows: rankingRows.value.map(({ channel, weightedScore }) => ({ channel, weightedScore })), coreChannelAnalysis: coreChannelAnalysis.value })
|
|
|
function addWeightRow() { weightRows.value.push(makeRow({ dimension: "", description: "", weight: undefined, suggestion: "" })) }
|
|
|
function addChannelRow() { channelRows.value.push(makeRow({ channel: "" })) }
|
|
|
async function saveAnswer(currentStep, fields) { await saveStudentTrainingAnswer(TASK_KEY, { saveAction: "SAVE", currentStep, ...fields }) }
|
|
|
async function saveWeightStep() { if (saving.value) return; saving.value = true; try { await saveAnswer(1, { step1Answer: JSON.stringify(weightPayload()) }); ElMessage.success("当前进度已保存") } finally { saving.value = false } }
|
|
|
async function goStepTwo() { if (validating.value) return; validating.value = true; try { await validateReleaseChannelEvaluationStepOne(weightPayload()); await saveAnswer(2, { step1Answer: JSON.stringify(weightPayload()) }); activeStep.value = 2; ElMessage.success("权重校验通过,已进入第二步") } catch (error) { ElMessage.error(error?.message || "请检查评估维度和权重设置") } finally { validating.value = false } }
|
|
|
async function saveChannelStep() { if (saving.value) return; saving.value = true; try { await saveAnswer(2, { step2Answer: JSON.stringify(channelPayload()) }); ElMessage.success("当前进度已保存") } finally { saving.value = false } }
|
|
|
async function validateChannelStep() { if (validating.value) return; validating.value = true; try { await validateReleaseChannelEvaluationStepTwo(channelPayload()); await saveAnswer(3, { step2Answer: JSON.stringify(channelPayload()) }); syncScoreRows(); activeStep.value = 3; ElMessage.success("渠道数据校验通过,已进入第三步") } catch (error) { ElMessage.error(error?.message || "请检查渠道数据填写情况") } finally { validating.value = false } }
|
|
|
function syncScoreRows(savedRows = []) { const savedByChannel = new Map(savedRows.map((row) => [row?.channel, row])); const weights = Object.fromEntries(weightRows.value.map((row) => [row.dimension, row.weight])); scoreRows.value = channelRows.value.map((channelRow) => { const saved = savedByChannel.get(channelRow.channel); const savedScores = new Map((saved?.dimensions || []).map((dimension) => [dimension?.dimension, dimension])); return makeRow({ channel: channelRow.channel, weightedScore: saved?.weightedScore, dimensions: scoringMetrics.map((metric) => makeRow({ key: metric.key, dimension: metric.dimension, weight: weights[metric.dimension], score: savedScores.get(metric.dimension)?.score })) }) }) }
|
|
|
function openStepThree() { syncScoreRows(scoreRows.value); activeStep.value = 3 }
|
|
|
async function saveScoreStep() { if (saving.value) return; saving.value = true; try { await saveAnswer(3, { step3Answer: JSON.stringify(scorePayload()) }); ElMessage.success("当前进度已保存") } finally { saving.value = false } }
|
|
|
async function validateScoreStep() { if (validating.value) return; validating.value = true; try { const result = await validateReleaseChannelEvaluationStepThree(scorePayload()); const calculationCheck = result?.data || {}; await saveAnswer(4, { step3Answer: JSON.stringify(scoreAnswerPayload(calculationCheck)) }); syncRankingRows(); activeStep.value = 4; ElMessage[calculationCheck.calculationCorrect ? "success" : "warning"](calculationCheck.message || "已保存并进入第四步") } catch (error) { ElMessage.error(error?.message || "请完整填写归一化得分表") } finally { validating.value = false } }
|
|
|
function syncRankingRows(savedRows = []) { const savedByChannel = new Map(savedRows.map((row) => [row?.channel, row])); rankingRows.value = scoreRows.value.map((scoreRow) => { const saved = savedByChannel.get(scoreRow.channel); return makeRow({ channel: saved?.channel ?? "", weightedScore: saved?.weightedScore }) }) }
|
|
|
function openStepFour() { syncRankingRows(rankingRows.value); activeStep.value = 4 }
|
|
|
async function saveRankingStep() { if (saving.value) return; saving.value = true; try { await saveAnswer(4, { step4Answer: JSON.stringify(rankingPayload()) }); ElMessage.success("当前进度已保存") } finally { saving.value = false } }
|
|
|
async function saveCurrentProgress() { if (activeStep.value === 1) return saveWeightStep(); if (activeStep.value === 2) return saveChannelStep(); if (activeStep.value === 3) return saveScoreStep(); return saveRankingStep() }
|
|
|
function previousStep() { if (activeStep.value > 1) activeStep.value -= 1 }
|
|
|
function nextStep() { if (activeStep.value === 1) return goStepTwo(); if (activeStep.value === 2) return validateChannelStep(); if (activeStep.value === 3) return validateScoreStep() }
|
|
|
async function validateRankingStep() { if (validating.value) return; validating.value = true; try { await validateReleaseChannelEvaluationStepFour(rankingPayload()); await saveStudentTrainingAnswer(TASK_KEY, { saveAction: "SUBMIT", currentStep: 4, step4Answer: JSON.stringify(rankingPayload()) }); ElMessage.success("渠道排名校验通过,任务已提交") } catch (error) { ElMessage.error(error?.message || "请检查渠道排名和核心渠道运营建议") } finally { validating.value = false } }
|
|
|
async function submitTask() { return validateRankingStep() }
|
|
|
async function resetTraining() { if (saving.value || validating.value) return; try { await deleteStudentTrainingAnswer(TASK_KEY); weightRows.value = defaultWeightRows(); channelRows.value = defaultChannelRows(); scoreRows.value = []; rankingRows.value = []; coreChannelAnalysis.value = ""; activeStep.value = 1; ElMessage.success("已清空全部实训步骤,可以重新开始") } catch (error) { ElMessage.error("重新实训失败,请稍后重试") } }
|
|
|
function exportOutcome() { ElMessage.info("导出功能正在开发中") }
|
|
|
function downloadBuiltInFile(url, fileName) {
|
|
|
const link = document.createElement("a")
|
|
|
link.href = url
|
|
|
link.download = fileName
|
|
|
document.body.appendChild(link)
|
|
|
link.click()
|
|
|
document.body.removeChild(link)
|
|
|
}
|
|
|
function downloadCsv(headers, rows, fileName) { const escape = (value) => `"${String(value ?? "").replaceAll('"', '""')}"`; const lines = [headers, ...rows].map((row) => row.map(escape).join(",")); const url = URL.createObjectURL(new Blob([`\uFEFF${lines.join("\n")}`], { type: "text/csv;charset=utf-8" })); const link = document.createElement("a"); link.href = url; link.download = fileName; link.click(); URL.revokeObjectURL(url) }
|
|
|
function exportChannelRows() { downloadCsv(["渠道", "日均总搜索量", "供需比", "平均点击率(%)", "平均CPC(元)", "平均转化率(%)", "现有相关访客数", "匹配度(1-100)"], channelRows.value.map((row) => [row.channel, row.dailySearchVolume, row.supplyDemandRatio, row.averageClickRate, row.averageCpc, row.averageConversionRate, row.existingVisitorCount, row.matchingScore]), "产品发布渠道评估-渠道数据.csv") }
|
|
|
function exportScoreRows() { const headers = ["渠道", ...scoringMetrics.flatMap((metric) => [`${metric.dimension}得分`, `${metric.dimension}权重(%)`]), "加权得分"]; const rows = scoreRows.value.map((row) => [row.channel, ...row.dimensions.flatMap((dimension) => [dimension.score, dimension.weight]), row.weightedScore]); downloadCsv(headers, rows, "产品发布渠道评估-归一化评分.csv") }
|
|
|
function restoreRows(raw, target, defaults, fields) { try { const saved = typeof raw === "string" ? JSON.parse(raw || "{}") : raw; if (!Array.isArray(saved?.rows)) return; const keyField = fields[0]; const presetFlags = new Map(defaults().map((row) => [row[keyField], Boolean(row.preset)])); target.value = saved.rows.map((row) => { const value = Object.fromEntries(fields.map((field) => [field, row?.[field]])); if (target === weightRows) return makeRow({ ...value, suggestion: weightSuggestions[value.dimension] || "", preset: presetFlags.get(value[keyField]) || false }); if (target === channelRows) return makeRow({ ...value, preset: presetFlags.get(value[keyField]) || false }); return makeRow(value) }) } catch (error) { target.value = defaults() } }
|
|
|
async function loadProgress() { try { const result = await getStudentTrainingAnswer(TASK_KEY); const answer = result?.data; restoreRows(answer?.step1Answer, weightRows, defaultWeightRows, ["dimension", "description", "weight"]); restoreRows(answer?.step2Answer, channelRows, defaultChannelRows, ["channel", "dailySearchVolume", "supplyDemandRatio", "averageClickRate", "averageCpc", "averageConversionRate", "existingVisitorCount", "matchingScore"]); let savedScores = []; try { const saved = typeof answer?.step3Answer === "string" ? JSON.parse(answer.step3Answer || "{}") : answer?.step3Answer; savedScores = Array.isArray(saved?.rows) ? saved.rows : [] } catch (error) { savedScores = [] } syncScoreRows(savedScores); let savedRanking = []; try { const saved = typeof answer?.step4Answer === "string" ? JSON.parse(answer.step4Answer || "{}") : answer?.step4Answer; savedRanking = Array.isArray(saved?.rows) ? saved.rows : []; coreChannelAnalysis.value = String(saved?.coreChannelAnalysis || "") } catch (error) { savedRanking = []; coreChannelAnalysis.value = "" } syncRankingRows(savedRanking); activeStep.value = Math.min(Math.max(Number(answer?.currentStep) || 1, 1), 4) } catch (error) { syncScoreRows(); syncRankingRows() } }
|
|
|
onMounted(async () => { try { const result = await getTrainingTaskByKey(TASK_KEY); taskConfig.value = result?.data || null } catch (error) { taskConfig.value = null } await loadProgress() })
|
|
|
</script>
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
/* ===== 页面级令牌(墨黑极简主题) ===== */
|
|
|
.release-channel-page {
|
|
|
--student-accent: #2563eb;
|
|
|
--student-accent-deep: #1d4ed8;
|
|
|
--student-accent-light: #3b82f6;
|
|
|
--student-accent-soft: #eff6ff;
|
|
|
--student-danger: #ef4444;
|
|
|
--student-success: #2ba471;
|
|
|
--student-layer-1: #1d4ed8;
|
|
|
--student-layer-2: #3b82f6;
|
|
|
--student-layer-3: #93c5fd;
|
|
|
--student-panel-border: #e5e7eb;
|
|
|
--student-card-bg: #ffffff;
|
|
|
--student-panel-bg: #fafbfc;
|
|
|
--student-text: #1f2937;
|
|
|
--student-text-secondary: #6b7280;
|
|
|
/* 字号梯度统一继承 .student-training-shell,页面不再重复声明 */
|
|
|
|
|
|
min-width: 0;
|
|
|
color: var(--student-text);
|
|
|
background: #ffffff;
|
|
|
}
|
|
|
|
|
|
/* 主容器:只设 gap,无边框/背景/阴影/双列 grid */
|
|
|
.release-channel-core {
|
|
|
display: grid;
|
|
|
gap: 10px;
|
|
|
align-content: start;
|
|
|
min-width: 0;
|
|
|
padding: 0;
|
|
|
border: 0;
|
|
|
background: transparent;
|
|
|
box-shadow: none;
|
|
|
}
|
|
|
|
|
|
/* ===== task-header ===== */
|
|
|
.task-header {
|
|
|
display: flex;
|
|
|
align-items: flex-start;
|
|
|
justify-content: space-between;
|
|
|
gap: 12px;
|
|
|
margin: 0;
|
|
|
padding: 16px;
|
|
|
border: 1px solid #e7e9ed;
|
|
|
border-radius: 16px;
|
|
|
background: #ffffff;
|
|
|
}
|
|
|
|
|
|
.task-head-main {
|
|
|
display: grid;
|
|
|
justify-items: start;
|
|
|
gap: 8px;
|
|
|
min-width: 0;
|
|
|
flex: 1 1 auto;
|
|
|
}
|
|
|
|
|
|
|
|
|
.task-title {
|
|
|
margin: 0;
|
|
|
color: var(--student-text);
|
|
|
font-size: var(--student-fs-display);
|
|
|
font-weight: 700;
|
|
|
line-height: 1.35;
|
|
|
}
|
|
|
|
|
|
.task-brief {
|
|
|
margin: 0;
|
|
|
color: var(--student-text-secondary);
|
|
|
font-size: var(--student-fs-body-sm);
|
|
|
line-height: 1.6;
|
|
|
}
|
|
|
|
|
|
.task-brief-text {
|
|
|
display: block;
|
|
|
}
|
|
|
|
|
|
.task-brief-toggle {
|
|
|
padding: 0;
|
|
|
border: 0;
|
|
|
color: var(--student-accent-deep);
|
|
|
background: transparent;
|
|
|
font: inherit;
|
|
|
font-weight: 700;
|
|
|
cursor: pointer;
|
|
|
|
|
|
&:hover {
|
|
|
text-decoration: underline;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.task-brief-more {
|
|
|
display: grid;
|
|
|
gap: 6px;
|
|
|
margin-top: 8px;
|
|
|
padding-top: 8px;
|
|
|
border-top: 1px solid var(--student-panel-border);
|
|
|
|
|
|
b {
|
|
|
color: var(--student-text);
|
|
|
font-size: var(--student-fs-body-sm);
|
|
|
}
|
|
|
|
|
|
ul {
|
|
|
display: grid;
|
|
|
gap: 4px;
|
|
|
margin: 0;
|
|
|
padding-left: 18px;
|
|
|
}
|
|
|
|
|
|
p {
|
|
|
margin: 0;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/* ===== 工具栏:一行不换行 ===== */
|
|
|
/* ===== 工具栏:TrainingTaskToolbar 按钮右对齐 ===== */
|
|
|
.task-action-strip {
|
|
|
margin: 0;
|
|
|
padding: 10px 16px;
|
|
|
|
|
|
:deep(.training-task-toolbar) {
|
|
|
display: flex;
|
|
|
flex-wrap: wrap;
|
|
|
align-items: center;
|
|
|
justify-content: flex-end;
|
|
|
gap: 8px;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/* ===== task-card:透明容器 ===== */
|
|
|
.task-card {
|
|
|
padding: 0;
|
|
|
border: 0;
|
|
|
border-radius: 0;
|
|
|
background: transparent;
|
|
|
display: grid;
|
|
|
gap: 10px;
|
|
|
min-width: 0;
|
|
|
}
|
|
|
|
|
|
/* grid 子项默认 min-width:auto 会被宽表格(评分表 1780px)撑开,
|
|
|
导致 .table-wrap 的 overflow-x:auto 失效、整页横向溢出 */
|
|
|
.task-card>* {
|
|
|
min-width: 0;
|
|
|
}
|
|
|
|
|
|
/* ===== 步骤条:容器样式 ===== */
|
|
|
.step-tabs {
|
|
|
display: flex;
|
|
|
align-items: stretch;
|
|
|
gap: 10px;
|
|
|
margin: 4px 0 16px;
|
|
|
padding: 0;
|
|
|
border: 0;
|
|
|
border-radius: 0;
|
|
|
background: transparent;
|
|
|
box-shadow: none;
|
|
|
overflow-x: auto;
|
|
|
}
|
|
|
|
|
|
/* ===== 步骤说明 & 引导区 ===== */
|
|
|
.step-heading {
|
|
|
display: grid;
|
|
|
gap: 4px;
|
|
|
margin: 0 0 10px;
|
|
|
}
|
|
|
|
|
|
.step-heading .step-eyebrow {
|
|
|
margin: 0;
|
|
|
color: var(--student-accent, #2563eb);
|
|
|
font-size: var(--student-fs-caption, 12px);
|
|
|
font-weight: 800;
|
|
|
letter-spacing: 0.12em;
|
|
|
text-transform: uppercase;
|
|
|
}
|
|
|
|
|
|
.step-heading h2 {
|
|
|
margin: 0;
|
|
|
color: var(--student-text, #11243b);
|
|
|
font-size: var(--student-fs-h2, 18px);
|
|
|
font-weight: 700;
|
|
|
line-height: 1.4;
|
|
|
}
|
|
|
|
|
|
.lead {
|
|
|
margin: 0 0 14px;
|
|
|
color: var(--student-text-secondary);
|
|
|
font-size: var(--student-fs-body);
|
|
|
line-height: 1.6;
|
|
|
}
|
|
|
|
|
|
/* ===== 预置维度行:只读文本 ===== */
|
|
|
.preset-text {
|
|
|
display: block;
|
|
|
padding: 6px 2px;
|
|
|
color: var(--student-text);
|
|
|
font-size: var(--student-fs-body);
|
|
|
line-height: 1.5;
|
|
|
}
|
|
|
|
|
|
.scoring-guide {
|
|
|
margin: 14px 0 12px;
|
|
|
padding: 12px 14px;
|
|
|
border-left: 3px solid var(--student-accent);
|
|
|
border-radius: 0 8px 8px 0;
|
|
|
background: var(--student-accent-soft);
|
|
|
color: var(--student-text-secondary);
|
|
|
font-size: var(--student-fs-body);
|
|
|
line-height: 1.6;
|
|
|
}
|
|
|
|
|
|
|
|
|
.step-three-notice {
|
|
|
margin: 20px 0 0;
|
|
|
padding: 10px 14px;
|
|
|
border: 1px solid rgba(217, 85, 76, 0.28);
|
|
|
border-radius: 8px;
|
|
|
color: #9b3b34;
|
|
|
background: rgba(217, 85, 76, 0.06);
|
|
|
font-size: var(--student-fs-body-sm);
|
|
|
line-height: 1.6;
|
|
|
}
|
|
|
|
|
|
/* ===== 表格 ===== */
|
|
|
.table-actions {
|
|
|
display: flex;
|
|
|
justify-content: flex-end;
|
|
|
gap: 12px;
|
|
|
margin: 0 0 16px;
|
|
|
}
|
|
|
|
|
|
.table-wrap {
|
|
|
overflow-x: auto;
|
|
|
min-width: 0;
|
|
|
border: 1px solid var(--student-panel-border);
|
|
|
border-radius: 10px;
|
|
|
}
|
|
|
|
|
|
.table-wrap table {
|
|
|
width: 100%;
|
|
|
border-collapse: collapse;
|
|
|
}
|
|
|
|
|
|
.weight-table {
|
|
|
min-width: 900px;
|
|
|
}
|
|
|
|
|
|
.channel-table {
|
|
|
min-width: 1320px;
|
|
|
}
|
|
|
|
|
|
.scoring-table {
|
|
|
min-width: 1780px;
|
|
|
}
|
|
|
|
|
|
.ranking-table {
|
|
|
min-width: 880px;
|
|
|
}
|
|
|
|
|
|
.table-wrap th,
|
|
|
.table-wrap td {
|
|
|
padding: 12px;
|
|
|
border-right: 1px solid var(--student-panel-border);
|
|
|
border-bottom: 1px solid var(--student-panel-border);
|
|
|
vertical-align: middle;
|
|
|
}
|
|
|
|
|
|
.table-wrap th {
|
|
|
white-space: nowrap;
|
|
|
color: var(--student-text);
|
|
|
background: #fafbfc;
|
|
|
font-size: var(--student-fs-body-sm);
|
|
|
font-weight: 800;
|
|
|
text-align: left;
|
|
|
}
|
|
|
|
|
|
.table-wrap td {
|
|
|
background: #ffffff;
|
|
|
}
|
|
|
|
|
|
.scoring-table thead tr:nth-child(2) th {
|
|
|
background: #fafbfc;
|
|
|
}
|
|
|
|
|
|
.channel-table td,
|
|
|
.scoring-table td {
|
|
|
min-width: 108px;
|
|
|
}
|
|
|
|
|
|
.channel-table td:first-child {
|
|
|
min-width: 110px;
|
|
|
}
|
|
|
|
|
|
.scoring-table th:first-child {
|
|
|
min-width: 108px;
|
|
|
}
|
|
|
|
|
|
.ranking-table td {
|
|
|
min-width: 170px;
|
|
|
}
|
|
|
|
|
|
.ranking-table td:first-child {
|
|
|
min-width: 100px;
|
|
|
}
|
|
|
|
|
|
.rank-cell,
|
|
|
.core-cell {
|
|
|
text-align: center;
|
|
|
font-weight: 800;
|
|
|
}
|
|
|
|
|
|
.core-cell .core {
|
|
|
display: inline-flex;
|
|
|
align-items: center;
|
|
|
justify-content: center;
|
|
|
min-width: 36px;
|
|
|
height: 24px;
|
|
|
padding: 0 8px;
|
|
|
border-radius: 6px;
|
|
|
color: #ffffff;
|
|
|
background: var(--student-accent);
|
|
|
font-size: var(--student-fs-caption);
|
|
|
font-weight: 700;
|
|
|
}
|
|
|
|
|
|
.weight-cell {
|
|
|
color: var(--student-accent);
|
|
|
text-align: center;
|
|
|
font-weight: 700;
|
|
|
}
|
|
|
|
|
|
.table-wrap small {
|
|
|
display: block;
|
|
|
margin-top: 6px;
|
|
|
color: var(--student-text-secondary);
|
|
|
font-size: var(--student-fs-caption);
|
|
|
}
|
|
|
|
|
|
.operation-column {
|
|
|
width: 100px;
|
|
|
text-align: center;
|
|
|
}
|
|
|
|
|
|
.empty-row {
|
|
|
text-align: center;
|
|
|
color: var(--student-text-secondary);
|
|
|
}
|
|
|
|
|
|
.table-wrap tfoot th,
|
|
|
.table-wrap tfoot td {
|
|
|
border-bottom: 0;
|
|
|
background: var(--student-panel-bg);
|
|
|
}
|
|
|
|
|
|
.total-error {
|
|
|
color: var(--student-danger);
|
|
|
font-weight: 700;
|
|
|
}
|
|
|
|
|
|
/* ===== 表单输入 ===== */
|
|
|
.analysis-field {
|
|
|
display: block;
|
|
|
margin-top: 14px;
|
|
|
}
|
|
|
|
|
|
.analysis-field>span {
|
|
|
display: block;
|
|
|
margin-bottom: 8px;
|
|
|
color: var(--student-text);
|
|
|
font-size: var(--student-fs-body);
|
|
|
font-weight: 600;
|
|
|
}
|
|
|
|
|
|
/* 表格内输入框:透明底 + outline focus */
|
|
|
.table-wrap :deep(.el-input__wrapper),
|
|
|
.table-wrap :deep(.el-input-number) {
|
|
|
width: 100%;
|
|
|
background: transparent;
|
|
|
box-shadow: none;
|
|
|
}
|
|
|
|
|
|
.table-wrap :deep(.el-input__inner) {
|
|
|
color: var(--student-text);
|
|
|
}
|
|
|
|
|
|
.table-wrap :deep(.el-textarea__inner) {
|
|
|
border: 1px solid transparent;
|
|
|
background: transparent;
|
|
|
box-shadow: none;
|
|
|
color: var(--student-text);
|
|
|
font-size: var(--student-fs-body);
|
|
|
line-height: 1.55;
|
|
|
}
|
|
|
|
|
|
.analysis-field :deep(.el-textarea__inner) {
|
|
|
min-height: 180px !important;
|
|
|
border: 1px solid var(--student-panel-border);
|
|
|
border-radius: 8px;
|
|
|
background: #ffffff;
|
|
|
box-shadow: none;
|
|
|
color: var(--student-text);
|
|
|
line-height: 1.55;
|
|
|
}
|
|
|
|
|
|
.table-wrap :deep(.el-textarea__inner:focus),
|
|
|
.analysis-field :deep(.el-textarea__inner:focus),
|
|
|
.table-wrap :deep(.el-input__wrapper.is-focus) {
|
|
|
outline: 2px solid rgba(37, 99, 235, 0.2);
|
|
|
outline-offset: -2px;
|
|
|
box-shadow: none;
|
|
|
}
|
|
|
|
|
|
/* ===== 底部说明 ===== */
|
|
|
.hint {
|
|
|
margin: 14px 0 0;
|
|
|
color: var(--student-text-secondary);
|
|
|
font-size: var(--student-fs-body-sm);
|
|
|
line-height: 1.6;
|
|
|
}
|
|
|
|
|
|
/* ===== 底部步骤导航按钮 ===== */
|
|
|
.step-navigation {
|
|
|
position: relative;
|
|
|
display: flex;
|
|
|
flex-wrap: wrap;
|
|
|
align-items: center;
|
|
|
justify-content: space-between;
|
|
|
gap: 10px;
|
|
|
margin-top: 16px;
|
|
|
padding: 12px 16px;
|
|
|
border: 1px solid var(--student-panel-border);
|
|
|
border-radius: 10px;
|
|
|
background: #ffffff;
|
|
|
}
|
|
|
|
|
|
.step-nav-left,
|
|
|
.step-nav-right {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
gap: 8px;
|
|
|
flex-wrap: nowrap;
|
|
|
}
|
|
|
|
|
|
.step-nav-right {
|
|
|
margin-left: auto;
|
|
|
}
|
|
|
|
|
|
.btn-primary {
|
|
|
display: inline-flex;
|
|
|
align-items: center;
|
|
|
justify-content: center;
|
|
|
gap: 6px;
|
|
|
min-height: 36px;
|
|
|
height: 36px;
|
|
|
padding: 0 14px;
|
|
|
border: 1px solid var(--student-accent);
|
|
|
border-radius: 8px;
|
|
|
color: #ffffff;
|
|
|
background: var(--student-accent);
|
|
|
font-family: inherit;
|
|
|
font-size: var(--student-fs-button);
|
|
|
font-weight: 600;
|
|
|
box-shadow: 0 1px 2px rgba(37, 99, 235, 0.2), 0 2px 8px rgba(37, 99, 235, 0.15);
|
|
|
cursor: pointer;
|
|
|
transition: all 0.16s ease;
|
|
|
white-space: nowrap;
|
|
|
|
|
|
.el-icon {
|
|
|
font-size: 18px;
|
|
|
}
|
|
|
|
|
|
&:hover:not(:disabled) {
|
|
|
border-color: var(--student-accent-deep);
|
|
|
background: var(--student-accent-deep);
|
|
|
transform: translateY(-1px);
|
|
|
box-shadow: 0 2px 6px rgba(29, 78, 216, 0.3), 0 4px 12px rgba(29, 78, 216, 0.22);
|
|
|
}
|
|
|
|
|
|
&:active:not(:disabled) {
|
|
|
transform: translateY(0);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.btn-ghost {
|
|
|
display: inline-flex;
|
|
|
align-items: center;
|
|
|
justify-content: center;
|
|
|
gap: 6px;
|
|
|
min-height: 36px;
|
|
|
height: 36px;
|
|
|
padding: 0 14px;
|
|
|
border: 1px solid rgba(37, 99, 235, 0.35);
|
|
|
border-radius: 8px;
|
|
|
color: var(--student-accent);
|
|
|
background: #ffffff;
|
|
|
font-family: inherit;
|
|
|
font-size: var(--student-fs-button);
|
|
|
font-weight: 600;
|
|
|
cursor: pointer;
|
|
|
transition: all 0.16s ease;
|
|
|
white-space: nowrap;
|
|
|
|
|
|
.el-icon {
|
|
|
font-size: 18px;
|
|
|
}
|
|
|
|
|
|
&:hover:not(:disabled) {
|
|
|
border-color: var(--student-accent);
|
|
|
background: var(--student-accent-soft);
|
|
|
transform: translateY(-1px);
|
|
|
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.btn-save {
|
|
|
display: inline-flex;
|
|
|
align-items: center;
|
|
|
justify-content: center;
|
|
|
gap: 6px;
|
|
|
min-height: 36px;
|
|
|
height: 36px;
|
|
|
padding: 0 14px;
|
|
|
border: 1px solid #e5e7eb;
|
|
|
border-radius: 8px;
|
|
|
color: #4b5563;
|
|
|
background: #f9fafb;
|
|
|
font-family: inherit;
|
|
|
font-size: var(--student-fs-button);
|
|
|
font-weight: 600;
|
|
|
cursor: pointer;
|
|
|
transition: all 0.16s ease;
|
|
|
white-space: nowrap;
|
|
|
|
|
|
.el-icon {
|
|
|
font-size: 18px;
|
|
|
}
|
|
|
|
|
|
&:hover:not(:disabled) {
|
|
|
border-color: #d1d5db;
|
|
|
background: #f3f4f6;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.btn-primary:disabled,
|
|
|
.btn-ghost:disabled,
|
|
|
.btn-save:disabled,
|
|
|
.btn-nav:disabled {
|
|
|
cursor: not-allowed;
|
|
|
opacity: 0.5;
|
|
|
transform: none;
|
|
|
box-shadow: none;
|
|
|
}
|
|
|
|
|
|
.step-progress {
|
|
|
color: var(--student-text-secondary);
|
|
|
font-family: var(--student-font-data);
|
|
|
font-size: var(--student-fs-caption);
|
|
|
font-weight: 700;
|
|
|
letter-spacing: 0.05em;
|
|
|
white-space: nowrap;
|
|
|
}
|
|
|
|
|
|
/* ===== 响应式 ===== */
|
|
|
@media (max-width: 720px) {
|
|
|
.task-header {
|
|
|
flex-direction: column;
|
|
|
align-items: flex-start;
|
|
|
}
|
|
|
|
|
|
.task-action-strip {
|
|
|
:deep(.training-task-toolbar) {
|
|
|
justify-content: stretch;
|
|
|
}
|
|
|
|
|
|
:deep(.training-task-toolbar > *) {
|
|
|
flex: 1 1 auto;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
.table-actions {
|
|
|
align-items: stretch;
|
|
|
flex-direction: column;
|
|
|
}
|
|
|
|
|
|
.table-actions :deep(.el-button) {
|
|
|
width: 100%;
|
|
|
}
|
|
|
|
|
|
.step-navigation {
|
|
|
flex-direction: column;
|
|
|
align-items: stretch;
|
|
|
}
|
|
|
|
|
|
.step-nav-left,
|
|
|
.step-nav-right {
|
|
|
width: 100%;
|
|
|
}
|
|
|
|
|
|
.btn-nav {
|
|
|
width: 100%;
|
|
|
}
|
|
|
}
|
|
|
</style>
|
|
|
|
|
|
<style lang="scss">
|
|
|
/* ===== 步骤条:分段连接胶囊(教育蓝极简) ===== */
|
|
|
#app .studentLayout section.app-main:has(.release-channel-page) .step-tabs {
|
|
|
display: flex !important;
|
|
|
flex-wrap: nowrap !important;
|
|
|
align-items: stretch !important;
|
|
|
justify-content: center !important;
|
|
|
gap: 0 !important;
|
|
|
width: 100% !important;
|
|
|
margin: 0 0 20px !important;
|
|
|
padding: 0 !important;
|
|
|
border: none !important;
|
|
|
border-radius: 0 !important;
|
|
|
background: transparent !important;
|
|
|
box-shadow: none !important;
|
|
|
|
|
|
.step-connector {
|
|
|
display: none !important;
|
|
|
}
|
|
|
|
|
|
.step-tab {
|
|
|
|
|
|
|
|
|
display: flex !important;
|
|
|
flex-direction: row !important;
|
|
|
align-items: center !important;
|
|
|
justify-content: center !important;
|
|
|
gap: 8px !important;
|
|
|
min-width: 0 !important;
|
|
|
height: auto !important;
|
|
|
min-height: 40px !important;
|
|
|
margin: 0 !important;
|
|
|
padding: 8px 14px !important;
|
|
|
border: 1px solid #e5e7eb !important;
|
|
|
border-right: none !important;
|
|
|
border-radius: 0 !important;
|
|
|
background: #ffffff !important;
|
|
|
box-shadow: none !important;
|
|
|
color: #6b7280 !important;
|
|
|
font-size: var(--student-fs-body) !important;
|
|
|
font-weight: 600 !important;
|
|
|
line-height: 1.3 !important;
|
|
|
letter-spacing: 0 !important;
|
|
|
white-space: nowrap !important;
|
|
|
text-align: center !important;
|
|
|
cursor: pointer;
|
|
|
transition: all 0.16s ease !important;
|
|
|
|
|
|
&:first-child {
|
|
|
border-radius: 8px 0 0 8px !important;
|
|
|
}
|
|
|
|
|
|
&:last-child {
|
|
|
border-radius: 0 8px 8px 0 !important;
|
|
|
border-right: 1px solid #e5e7eb !important;
|
|
|
}
|
|
|
|
|
|
.step-marker {
|
|
|
display: inline-flex !important;
|
|
|
align-items: center !important;
|
|
|
justify-content: center !important;
|
|
|
flex: 0 0 auto !important;
|
|
|
min-width: 22px !important;
|
|
|
width: auto !important;
|
|
|
height: 22px !important;
|
|
|
padding: 0 5px !important;
|
|
|
border: none !important;
|
|
|
border-radius: 6px !important;
|
|
|
background: #f3f4f6 !important;
|
|
|
color: #6b7280 !important;
|
|
|
font-family: var(--student-font-data), ui-monospace, "SF Mono", Consolas, monospace !important;
|
|
|
font-size: var(--student-fs-body-sm) !important;
|
|
|
font-weight: 700 !important;
|
|
|
line-height: 1 !important;
|
|
|
transition: all 0.16s ease !important;
|
|
|
}
|
|
|
|
|
|
.step-copy {
|
|
|
display: flex !important;
|
|
|
flex-direction: column !important;
|
|
|
gap: 0 !important;
|
|
|
min-width: 0 !important;
|
|
|
margin-left: 0 !important;
|
|
|
}
|
|
|
|
|
|
.step-copy b {
|
|
|
overflow: hidden !important;
|
|
|
text-overflow: ellipsis !important;
|
|
|
white-space: nowrap !important;
|
|
|
line-height: 1.3 !important;
|
|
|
color: inherit !important;
|
|
|
font-size: var(--student-fs-body) !important;
|
|
|
font-weight: 600 !important;
|
|
|
}
|
|
|
|
|
|
.step-copy small {
|
|
|
display: none !important;
|
|
|
}
|
|
|
|
|
|
&:hover:not(.active):not(.complete) {
|
|
|
border-color: #2563eb !important;
|
|
|
background: #eff6ff !important;
|
|
|
color: #2563eb !important;
|
|
|
z-index: 1 !important;
|
|
|
|
|
|
.step-marker {
|
|
|
background: #2563eb !important;
|
|
|
color: #ffffff !important;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
&.complete:not(.active) {
|
|
|
border-color: #e5e7eb !important;
|
|
|
color: #374151 !important;
|
|
|
|
|
|
.step-marker {
|
|
|
background: rgba(43, 164, 113, 0.12) !important;
|
|
|
color: #16915f !important;
|
|
|
}
|
|
|
|
|
|
&:hover {
|
|
|
border-color: #2ba471 !important;
|
|
|
background: #f0fdf6 !important;
|
|
|
z-index: 1 !important;
|
|
|
|
|
|
.step-marker {
|
|
|
background: #2ba471 !important;
|
|
|
color: #ffffff !important;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
&.active {
|
|
|
border-color: #2563eb !important;
|
|
|
background: #2563eb !important;
|
|
|
color: #ffffff !important;
|
|
|
font-weight: 700 !important;
|
|
|
box-shadow: none !important;
|
|
|
z-index: 2 !important;
|
|
|
|
|
|
.step-marker {
|
|
|
background: #ffffff !important;
|
|
|
color: #2563eb !important;
|
|
|
}
|
|
|
|
|
|
&:hover {
|
|
|
background: #1d4ed8 !important;
|
|
|
border-color: #1d4ed8 !important;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/* ===== 窄屏横向滑动 ===== */
|
|
|
@media (max-width: 900px) {
|
|
|
#app .studentLayout section.app-main:has(.release-channel-page) .step-tabs {
|
|
|
justify-content: flex-start !important;
|
|
|
overflow-x: auto !important;
|
|
|
}
|
|
|
|
|
|
#app .studentLayout section.app-main:has(.release-channel-page) .step-tabs .step-tab {
|
|
|
flex: 0 0 140px !important;
|
|
|
max-width: none !important;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/* ============================================================
|
|
|
* 共用组件换肤:TrainingTaskToolbar 工具栏按钮 → 教育蓝
|
|
|
* ============================================================ */
|
|
|
.release-channel-page .task-action-strip .task-toolbar-button.el-button {
|
|
|
display: inline-flex;
|
|
|
align-items: center;
|
|
|
justify-content: center;
|
|
|
gap: 6px;
|
|
|
min-height: 34px;
|
|
|
height: 34px;
|
|
|
padding: 0 14px;
|
|
|
border: 1px solid rgba(37, 99, 235, 0.35);
|
|
|
border-radius: 8px;
|
|
|
color: #2563eb;
|
|
|
background: #ffffff;
|
|
|
font-size: var(--student-fs-body);
|
|
|
font-weight: 600;
|
|
|
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease;
|
|
|
}
|
|
|
|
|
|
.release-channel-page .task-action-strip .task-toolbar-button:hover,
|
|
|
.release-channel-page .task-action-strip .task-toolbar-button:focus {
|
|
|
border-color: #2563eb;
|
|
|
color: #1d4ed8;
|
|
|
background: #eff6ff;
|
|
|
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12);
|
|
|
}
|
|
|
|
|
|
.release-channel-page .task-action-strip .task-toolbar-button--submit.el-button {
|
|
|
border-color: #2563eb;
|
|
|
color: #ffffff;
|
|
|
background: #2563eb;
|
|
|
box-shadow: 0 1px 2px rgba(37, 99, 235, 0.35), 0 3px 10px rgba(37, 99, 235, 0.22);
|
|
|
}
|
|
|
|
|
|
.release-channel-page .task-action-strip .task-toolbar-button--submit:hover,
|
|
|
.release-channel-page .task-action-strip .task-toolbar-button--submit:focus {
|
|
|
border-color: #1d4ed8;
|
|
|
color: #ffffff;
|
|
|
background: #1d4ed8;
|
|
|
box-shadow: 0 2px 4px rgba(37, 99, 235, 0.4), 0 5px 14px rgba(29, 78, 216, 0.28);
|
|
|
}
|
|
|
|
|
|
/* ===== 表格操作按钮换肤 ===== */
|
|
|
.release-channel-page .table-actions .el-button {
|
|
|
border-radius: 8px !important;
|
|
|
font-size: var(--student-fs-body) !important;
|
|
|
font-weight: 700 !important;
|
|
|
}
|
|
|
|
|
|
.release-channel-page .table-actions .el-button--primary {
|
|
|
border-color: #2563eb !important;
|
|
|
background: #2563eb !important;
|
|
|
color: #ffffff !important;
|
|
|
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.28) !important;
|
|
|
}
|
|
|
|
|
|
.release-channel-page .table-actions .el-button--primary:hover {
|
|
|
border-color: #1d4ed8 !important;
|
|
|
background: #1d4ed8 !important;
|
|
|
box-shadow: 0 5px 14px rgba(29, 78, 216, 0.32) !important;
|
|
|
}
|
|
|
</style>
|