You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
dianshang-qianduan/src/views/demand/feature-conversion.vue

1899 lines
67 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div class="feature-conversion-page student-training-shell">
<main class="feature-conversion-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 v-if="flowSteps.length" class="step-indicator" aria-label="需求转化为功能步骤">
<template v-for="(step, index) in flowSteps" :key="`${step}-${index}`">
<button type="button"
:class="['step-tab', { active: form.activeStep === index + 1, complete: form.activeStep > index + 1 }]"
:aria-current="form.activeStep === index + 1 ? 'step' : undefined"
:aria-label="`第${index + 1}步:${step}`" @click="setCurrentStep(index + 1)">
<span class="step-marker">{{ String(index + 1).padStart(2, '0') }}</span>
<span class="step-copy">
<strong class="step-name" :title="step">{{ step }}</strong>
<span class="step-action">{{ form.activeStep === index + 1 ? "当前步骤" : "点击切换" }} <span
aria-hidden="true">→</span></span>
</span>
</button>
<span v-if="index < flowSteps.length - 1" class="step-connector" aria-hidden="true"></span>
</template>
</nav>
<section v-show="isSectionVisible(1)" class="step-card conversion-content">
<div v-if="activeStepName" class="step-title-row">
<div>
<p class="section-eyebrow">Step {{ String(form.activeStep).padStart(2, '0') }}</p>
<h3>{{ activeStepName }}</h3>
</div>
</div>
<div class="conversion-intro">
<p>用户反馈通常混杂重复描述、情绪化表达、无关信息。请阅读案例资料中原始用户反馈,逐条进行场景洞察拆解,分析用户在什么时间、什么情境下产生该需求,背后的深层动机是什么,然后将用户语言转化为可执行的产品功能定义,填写以下需求转化表。
</p>
<div class="conversion-row-actions">
<el-button type="primary" :disabled="form.rows.length >= MAX_CONVERSION_ROWS"
@click="addConversionRow">增加一行</el-button>
<el-button type="primary" plain :disabled="form.rows.length <= MIN_CONVERSION_ROWS"
@click="removeConversionRow">删除一行</el-button>
</div>
</div>
<div class="conversion-table-wrap">
<table class="conversion-table">
<thead>
<tr>
<th>序号</th>
<th>用户原声</th>
<th>场景洞察</th>
<th>转化后的功能定义</th>
<th>功能描述</th>
<th>技术指标/验收标准</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in form.rows" :key="row.id">
<td>{{ index + 1 }}</td>
<td><textarea v-model="row.voice"
:placeholder="index === 0 ? '示例:起夜需要温和照明,避免强光刺激' : '填写用户原声'"></textarea>
</td>
<td><textarea v-model="row.insight"
:placeholder="index === 0 ? '示例:凌晨2-5点用户从深度睡眠中被尿意唤醒,处于半梦半醒状态,瞳孔对光线极其敏感' : '填写场景洞察'"></textarea>
</td>
<td><textarea v-model="row.definition"
:placeholder="index === 0 ? '示例:起夜微光模式' : '填写转化后的功能定义'"></textarea></td>
<td><textarea v-model="row.description"
:placeholder="index === 0 ? '示例:感应到用户起身时,自动开启亮度≤10%的暖黄地脚光,延迟3秒渐亮' : '填写功能描述'"></textarea>
</td>
<td><textarea v-model="row.acceptance"
:placeholder="index === 0 ? '示例:亮度5-15流明可调,色温2700K,启动延时≤0.5秒,光通量渐升时间3±0.5秒' : '填写技术指标/验收标准'"></textarea>
</td>
</tr>
</tbody>
</table>
</div>
<p class="conversion-limit-tip">至少 {{ MIN_CONVERSION_ROWS }} 行,最多 {{ MAX_CONVERSION_ROWS }} 行</p>
<p v-if="conversionValidationMessage" class="conversion-validation-message" role="status">{{
conversionValidationMessage }}</p>
</section>
<section v-show="isSectionVisible(2)" class="step-card conversion-content priority-content">
<div v-if="activeStepName" class="step-title-row">
<div>
<p class="section-eyebrow">Step {{ String(form.activeStep).padStart(2, '0') }}</p>
<h3>{{ activeStepName }}</h3>
</div>
</div>
<div class="rice-intro">
<p>将步骤一的功能纳入 RICE 评估模型,进行量化打分。RICE 模型包含覆盖用户数、影响程度、置信度、开发成本四个维度。</p>
<p>请根据上一步骤需求转化后的功能定义,针对具体需求进行量化打分,填写以下打分表。</p>
</div>
<div class="conversion-table-wrap">
<table class="priority-score-table">
<thead>
<tr>
<th>需求ID</th>
<th>功能名称</th>
<th>KANO属性</th>
<th>Reach(覆盖范围)</th>
<th>Impact(影响程度)</th>
<th>Confidence(置信度)</th>
<th>Effort(人/月)</th>
<th>RICE得分</th>
</tr>
</thead>
<tbody>
<tr v-for="row in form.scoreRows" :key="row.uid">
<td class="auto-score-cell">{{ row.requirementId }}</td>
<td class="auto-score-cell auto-score-cell--name">{{ row.name || "请先在步骤一填写功能定义" }}
</td>
<td>
<select v-model="row.kano" :aria-label="`${row.requirementId} KANO属性`">
<option value="">请选择</option>
<option v-for="type in kanoTypes" :key="type" :value="type">{{ type }}
</option>
</select>
</td>
<td><input v-model.number="row.reach" type="number" min="1" max="5" step="1"
aria-label="Reach覆盖范围" /></td>
<td><input v-model.number="row.impact" type="number" min="1" max="5" step="1"
aria-label="Impact影响程度" /></td>
<td>
<div class="metric-input"><input v-model.number="row.confidence" type="number"
min="1" max="100" step="1" aria-label="Confidence置信度" /><span>%</span>
</div>
</td>
<td><input v-model.number="row.effort" type="number" min="0.1" step="0.1"
aria-label="Effort人月" />
</td>
<td class="rice-score-cell">{{ calculateRiceScore(row) }}</td>
</tr>
</tbody>
</table>
</div>
<p class="rice-formula">计算公式:(Reach × Impact × Confidence) / Effort,结果保留两位小数。需求 ID 与功能名称由步骤一自动同步。
</p>
<p v-if="scoreValidationMessage" class="conversion-validation-message" role="status">{{
scoreValidationMessage }}
</p>
</section>
<section v-show="isSectionVisible(3)" class="step-card conversion-content decision-content">
<div v-if="activeStepName" class="step-title-row">
<div>
<p class="section-eyebrow">Step {{ String(form.activeStep).padStart(2, '0') }}</p>
<h3>{{ activeStepName }}</h3>
</div>
</div>
<p class="decision-intro">基于步骤二的 RICE 评分结果,输出 P0-P3 四级功能清单,并说明每一等级的划分理由。</p>
<div class="conversion-table-wrap">
<table class="priority-decision-table">
<thead>
<tr>
<th>功能名称</th>
<th>优先级</th>
<th>理由</th>
</tr>
</thead>
<tbody>
<tr v-for="row in form.priorityRows" :key="row.uid">
<td class="auto-score-cell auto-score-cell--name">{{ row.name || "请先完成步骤二 RICE 评分"
}}</td>
<td>
<select v-model="row.priority" :aria-label="`${row.name || row.uid} 优先级`">
<option value="">请选择</option>
<option v-for="level in priorityLevels" :key="level.value"
:value="level.value">{{
level.value }}</option>
</select>
</td>
<td><textarea v-model="row.reason" :aria-label="`${row.name || row.uid} 划分理由`"
placeholder="结合 RICE 评分、KANO 属性和开发投入说明划分理由"></textarea></td>
</tr>
</tbody>
</table>
</div>
<div class="priority-legend" aria-label="优先级说明">
<p v-for="level in priorityLevels" :key="level.value"><strong>{{ level.value }}</strong>{{
level.description }}
</p>
</div>
<p v-if="priorityValidationMessage" class="conversion-validation-message" role="status">{{
priorityValidationMessage
}}</p>
</section>
<section v-show="isSectionVisible(4)" class="step-card conversion-content completion-content">
<div v-if="activeStepName" class="step-title-row">
<div>
<p class="section-eyebrow">Step {{ String(form.activeStep).padStart(2, '0') }}</p>
<h3>{{ activeStepName }}</h3>
</div>
</div>
<p>请检查需求转化、优先级评分和排序决策是否填写完整,再保存或提交本次实训成果。</p>
</section>
<div v-if="hasConfiguredSteps" class="step-navigation">
<div class="step-nav-left">
<button v-if="form.activeStep > 1" type="button" class="btn-nav btn-ghost"
@click="goPreviousStep">
<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(form.activeStep).padStart(2, '0') }} / {{
String(flowSteps.length).padStart(2, '0') }}</span>
<button v-if="form.activeStep < flowSteps.length" type="button"
class="btn-nav btn-primary" @click="goNextStep">
下一步
<el-icon>
<ArrowRight />
</el-icon>
</button>
</div>
</div>
</section>
</main>
<TrainingAiSidebar study-tip="当前任务建议:先把用户原声还原成具体场景,再提炼痛点,最后写成可开发、可验收的功能定义。" reference-title="需求转化参考"
reference-text="· 用户语言:愿望、抱怨、现象<br />· 场景洞察:谁在什么情况下遇到什么阻碍<br />· 工程语言:功能名称、触发条件、处理逻辑、验收指标"
assist-text="可以让 AI 帮你把每条用户原声拆成“场景洞察 + 功能定义 + 验收标准”,再人工检查是否可执行。" :progress-items="progressItems" />
</div>
</template>
<script setup>
import { computed, getCurrentInstance, onMounted, ref } from "vue"
import { ArrowLeft, ArrowRight, CircleCheck } from "@element-plus/icons-vue"
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"
import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue"
import { getTrainingTaskByKey } from "@/api/trainingTask"
import { parseTrainingArray } from "@/views/training/taskKeyMap"
import { checkFeatureConversionStepOne, checkFeatureConversionStepTwo, checkFeatureConversionStepThree, getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer"
const TASK_KEY = "feature-conversion"
const MIN_CONVERSION_ROWS = 8
const MAX_CONVERSION_ROWS = 12
const kanoTypes = ["基本型", "期望型", "兴奋型", "无差异型", "反向型"]
const priorityLevels = [
{ value: "P0", description: "(首期必做,生存线):不做会导致产品无法上市或差评退货的基本功能。" },
{ value: "P1", description: "(首期核心,竞争力):在成本允许下最大化期望属性,是产品区别于竞品的核心竞争力。" },
{ value: "P2", description: "(二期迭代,差异化):能显著提升用户体验,但成本高或属于不允许的兴奋型功能。" },
{ value: "P3", description: "(暂缓/砍掉):伪需求或偏离核心场景的功能建议。" },
]
const { proxy } = getCurrentInstance()
const defaultRequirement =
"需求转化不是照搬用户原话,而是把模糊痛点翻译成可设计、可开发、可验收的功能。过程包括需求清洗、场景还原、功能定义、验收标准和优先级排序。"
const defaultBackground =
"假如你是某款智能产品的产品经理,在前期问卷调研和需求分析后,需要把收集到的用户需求转化为功能设计。你要成为需求的翻译者和决策者,而不是用户原话的传声筒。"
const defaultGoals = [
"学会梳理用户需求,剔除重复或低价值需求。",
"将用户语言转化为功能设计和工程语言。",
"能够对功能优先级进行排序。",
]
const priorityTemplateRows = [
{ field: "需求ID", description: "唯一编号,便于追踪", example: "REQ-2026-003", source: "内部系统生成" },
{ field: "需求名称", description: "清晰、无歧义的功能描述", example: "“儿童位置异常自动推送家长APP”", source: "用户访谈 / 客服反馈" },
{ field: "需求来源", description: "提出方(用户/销售/合规/研发)", example: "家长调研组 / 法务部", source: "-" },
{ field: "KANO属性", description: "分类:必备 / 期望 / 魅力", example: "必备属性", source: "基于KANO模型调研" },
{ field: "Reach(覆盖范围)", description: "本周期内受影响的用户数(估算)", example: "85,000(占目标用户群72%)", source: "市场数据 / DAU统计" },
{ field: "Impact(影响程度)", description: "单用户价值评分(1–5分)<br>5=显著提升留存/转化;1=无感知", example: "5(解决核心安全焦虑)", source: "专家评估 + 用户调研" },
{ field: "Confidence(置信度)", description: "对Reach/Impact预测的信心(0%–100%)", example: "90%(基于1,200份问卷)", source: "市场研究 / A/B测试" },
{ field: "Effort(投入成本)", description: "实现所需总人天(开发+测试+认证)", example: "45人天(含嵌入式+云端+认证)", source: "工程团队评估" },
{ field: "RICE得分", description: "计算公式:(Reach × Impact × Confidence)/ Effort<br>结果保留两位小数", example: "(85,000 × 5 × 0.9) / 45 = 8,500.00", source: "自动计算列" },
{
field: "合规性检查项",
description: "必须通过的强制项(任一不通过则直接降为P3或砍掉)",
example: "符合GB 46859-2025(儿童手表隐私权限)<br>□ 通过SRRC认证(无线模块)<br>□ 无未经加密的儿童位置上传<br>□ 内容池通过AI过滤(无不良信息)",
source: "法务/认证团队确认",
},
{ field: "BOM成本影响", description: "是否增加核心元器件(如GPS模组、安全芯片)", example: "是(+¥18/台)", source: "采购部评估" },
{ field: "DFM影响", description: "是否导致模具复杂度↑、良率↓", example: "否", source: "结构工程师确认" },
{ field: "供应链风险", description: "是否依赖长交期/单一供应商物料", example: "否(GPS模组有备选)", source: "供应链团队确认" },
{
field: "优先级等级",
description: "根据RICE得分与合规性综合判定:<br>P0:≥5,000 且合规全通过<br>P1:2,000–4,999 且合规通过<br>P2:<2,000 或部分合规待优化",
example: "P0",
source: "综合判定",
},
{ field: "建议动作", description: "是否纳入首期开发?是否需替代方案?", example: "纳入首期,同步启动SRRC认证预检", source: "——" },
]
const parentFeedback = [
"我家孩子总爱抢手机玩游戏,能不能让机器人把手机里的学习内容导进去,替代手机当学习工具?",
"孩子学拼音总是平翘舌不分,机器人能不能像老师那样纠正发音,还能出练习题测试?",
"机器人能不能记录孩子白天的日常,比如玩了什么游戏、学了什么儿歌,我直接就能看?",
"老人带孩子不会教英语,机器人能不能设定固定时间,比如每天晚上7点,主动跟孩子说英语、玩英语小游戏?",
"孩子害怕一个人睡,机器人能不能在床边播放轻柔的故事,还能感应孩子有没有踢被子,踢了就提醒盖被子?",
"孩子在家总爱乱扔玩具,机器人能不能当“小管家”,提醒孩子收拾玩具,还能跟孩子比赛谁收拾得快?",
"有时候加班晚回家,孩子会闹情绪,机器人能不能跟视频电话结合,我在手机上就能通过机器人跟孩子互动,还能远程控制机器人给孩子放动画片?",
"孩子总爱问“为什么”,我很多问题答不上来,机器人能不能像百科全书一样,不管天文地理都能给孩子讲明白,还要用孩子能听懂的话?",
"我家孩子有点内向,不爱跟别人说话,机器人能不能主动找孩子聊天,引导孩子表达自己的想法,比如“你今天最开心的事是什么呀?”",
"孩子写作业姿势不对,总是趴在桌子上,机器人能不能提醒孩子坐端正,要是不听还能告诉家长?",
"现在网上内容太杂,机器人能不能自动过滤掉暴力、恐怖的内容,只给孩子推送适合3-6岁看的内容?",
]
const childFeedback = [
"机器人能不能跟我一起玩躲猫猫,它来找我,我藏起来它能找到我!",
"我想要机器人会变魔术,比如能把我画的画变成动画片里的小人动起来!",
"机器人的声音能不能像佩奇一样,我还能让它变成奥特曼的声音跟我说话!",
"我要是不开心了,机器人能不能给我跳舞,还能唱我喜欢的《孤勇者》!",
"机器人能不能陪我搭积木,它要是看到我搭错了,就告诉我怎么搭才能搭得更高!",
"我想要机器人有好多好多表情,开心的时候笑,难过的时候哭,就像小朋友一样!",
"机器人能不能跟我一起养小宠物,比如养一只虚拟的小猫,我们一起喂它吃饭、给它洗澡!",
"我要是想爷爷奶奶了,机器人能不能直接帮我打电话给他们,不用我找妈妈拿手机!",
]
const taskConfig = ref(null)
const saving = ref(false)
const checkingConversion = ref(false)
const checkingScore = ref(false)
const checkingPriority = ref(false)
const conversionValidationMessage = ref("")
const scoreValidationMessage = ref("")
const priorityValidationMessage = ref("")
function createRows(count = MIN_CONVERSION_ROWS) {
return Array.from({ length: count }, (_, index) => ({
id: index + 1,
voice: "",
insight: "",
definition: "",
description: "",
acceptance: "",
}))
}
function createScoreRows(sourceRows = createRows()) {
return sourceRows.map((sourceRow, index) => ({
uid: index + 1,
requirementId: formatRequirementId(index),
name: sourceRow.definition || "",
kano: "",
reach: "",
impact: "",
confidence: "",
effort: "",
}))
}
function createPriorityRows(sourceRows = createScoreRows()) {
return sourceRows.map((sourceRow, index) => ({
uid: index + 1,
name: sourceRow.name || "",
priority: "",
reason: "",
}))
}
function createPriorityDecision() {
return {
p0: "",
p1: "",
p2: "",
p3: "",
}
}
function createForm() {
const rows = createRows()
return {
activeStep: 1,
productName: "",
rows,
scoreRows: createScoreRows(rows),
priorityRows: createPriorityRows(createScoreRows(rows)),
priorityDecision: createPriorityDecision(),
}
}
const form = ref(createForm())
const taskTitle = computed(() => taskConfig.value?.taskName || "需求转化为功能")
const briefOpen = ref(true)
const taskBackground = computed(() => taskConfig.value?.background || defaultBackground)
const taskGoals = computed(() => parseArray(taskConfig.value?.objectives, defaultGoals))
const taskRequirement = computed(() => taskConfig.value?.requirements || defaultRequirement)
const flowSteps = computed(() => parseTrainingArray(taskConfig.value?.steps))
const hasConfiguredSteps = computed(() => flowSteps.value.length > 0)
const activeStepName = computed(() => flowSteps.value[form.value.activeStep - 1] || "")
const progressItems = computed(() => [
{ text: hasTableContent.value ? "进行中:需求转化表" : "待完成:需求转化表", status: hasTableContent.value ? "doing" : "" },
{ text: hasScoreContent.value ? "进行中:优先级打分表" : "待完成:优先级打分表", status: hasScoreContent.value ? "doing" : "" },
{ text: hasDecisionContent.value ? "进行中:优先级排序决策" : "待完成:优先级排序决策", status: hasDecisionContent.value ? "doing" : "" },
{ text: "待完成:提交实训成果", status: "" },
])
const hasTableContent = computed(() =>
form.value.rows.some((row) =>
["voice", "insight", "definition", "description", "acceptance"].some((key) => String(row[key] || "").trim())
)
)
const hasScoreContent = computed(() =>
form.value.scoreRows.some((row) =>
["kano", "reach", "impact", "confidence", "effort"].some((key) =>
String(row[key] || "").trim()
)
)
)
const hasDecisionContent = computed(() =>
form.value.priorityRows.some((row) => String(row.priority || "").trim() || String(row.reason || "").trim())
)
onMounted(async () => {
await loadTaskConfig()
await loadSavedAnswer()
})
async function loadTaskConfig() {
try {
const res = await getTrainingTaskByKey(TASK_KEY)
taskConfig.value = res?.data || null
form.value.activeStep = normalizeActiveStep(form.value.activeStep)
} catch (error) {
taskConfig.value = null
form.value.activeStep = 1
}
}
async function loadSavedAnswer() {
try {
const res = await getStudentTrainingAnswer(TASK_KEY)
const answer = res?.data
if (answer?.step1Answer) {
applySavedAnswer(answer.step1Answer)
}
} catch (error) {
// Keep local defaults when history cannot be loaded.
}
}
function parseArray(value, fallback = []) {
if (Array.isArray(value)) return value.filter(Boolean)
if (!value) return fallback
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) && parsed.length ? parsed : fallback
} catch (error) {
const list = String(value)
.split(/[;;||\n]/)
.map((item) => item.trim())
.filter(Boolean)
return list.length ? list : fallback
}
}
function normalizeActiveStep(value) {
if (!flowSteps.value.length) return 1
const parsed = Number(value) || 1
return Math.min(Math.max(parsed, 1), flowSteps.value.length)
}
async function setCurrentStep(step) {
const targetStep = normalizeActiveStep(step)
if (targetStep > form.value.activeStep && [1, 2, 3].includes(form.value.activeStep) && !(await persist("IN_PROGRESS"))) return
if (form.value.activeStep === 1 && targetStep > 1) syncScoreRows()
if (targetStep > 2 && form.value.activeStep <= 2) syncPriorityRows()
form.value.activeStep = targetStep
}
function isSectionVisible(section) {
return !hasConfiguredSteps.value || form.value.activeStep === section
}
async function goPreviousStep() {
await setCurrentStep(form.value.activeStep - 1)
}
async function goNextStep() {
await setCurrentStep(form.value.activeStep + 1)
}
function applySavedAnswer(value) {
try {
const parsed = JSON.parse(value)
if (!parsed || typeof parsed !== "object") return
const rows = normalizeRows(parsed.rows)
form.value = {
...createForm(),
...parsed,
rows,
scoreRows: normalizeScoreRows(parsed.scoreRows, rows),
priorityRows: normalizePriorityRows(parsed.priorityRows, normalizeScoreRows(parsed.scoreRows, rows)),
priorityDecision: normalizePriorityDecision(parsed.priorityDecision),
activeStep: normalizeActiveStep(parsed.activeStep),
}
} catch (error) {
// Ignore malformed historical answers.
}
}
function normalizeRows(rows = []) {
const savedRows = Array.isArray(rows) ? rows : []
const normalizedCount = Math.min(Math.max(savedRows.length, MIN_CONVERSION_ROWS), MAX_CONVERSION_ROWS)
return createRows(normalizedCount).map((row, index) => ({ ...row, ...(savedRows[index] || {}), id: row.id }))
}
function addConversionRow() {
if (form.value.rows.length >= MAX_CONVERSION_ROWS) {
proxy?.$modal?.msgWarning(`最多只能填写 ${MAX_CONVERSION_ROWS} 行`)
return
}
const nextId = Math.max(0, ...form.value.rows.map((row) => Number(row.id) || 0)) + 1
form.value.rows.push({ id: nextId, voice: "", insight: "", definition: "", description: "", acceptance: "" })
syncScoreRows()
}
function removeConversionRow() {
if (form.value.rows.length <= MIN_CONVERSION_ROWS) {
proxy?.$modal?.msgWarning(`至少需要填写 ${MIN_CONVERSION_ROWS} 行`)
return
}
form.value.rows.pop()
syncScoreRows()
}
async function ensureConversionValid() {
if (checkingConversion.value) return false
checkingConversion.value = true
try {
const response = await checkFeatureConversionStepOne({ rows: form.value.rows })
const result = response?.data || {}
conversionValidationMessage.value = result.message || (result.valid ? "需求转化表填写完整。" : `请完整填写 ${MIN_CONVERSION_ROWS}-${MAX_CONVERSION_ROWS} 行需求转化信息。`)
return result.valid === true
} catch (error) {
conversionValidationMessage.value = error?.message || "需求转化表校验失败,请稍后重试。"
return false
} finally {
checkingConversion.value = false
}
}
function formatRequirementId(index) {
return `F${String(index + 1).padStart(2, "0")}`
}
function normalizeScoreRows(rows = [], sourceRows = form.value.rows) {
const savedRows = Array.isArray(rows) ? rows : []
return createScoreRows(sourceRows).map((row, index) => ({
...row,
...(savedRows[index] || {}),
uid: row.uid,
requirementId: row.requirementId,
name: row.name,
}))
}
function syncScoreRows() {
form.value.scoreRows = normalizeScoreRows(form.value.scoreRows, form.value.rows)
}
function normalizePriorityRows(rows = [], sourceRows = form.value.scoreRows) {
const savedRows = Array.isArray(rows) ? rows : []
return createPriorityRows(sourceRows).map((row, index) => ({
...row,
...(savedRows[index] || {}),
uid: row.uid,
name: row.name,
}))
}
function syncPriorityRows() {
form.value.priorityRows = normalizePriorityRows(form.value.priorityRows, form.value.scoreRows)
}
function calculateRiceScore(row) {
const reach = Number(row.reach)
const impact = Number(row.impact)
const confidence = Number(row.confidence)
const effort = Number(row.effort)
if (![reach, impact, confidence, effort].every(Number.isFinite) || effort <= 0) return "--"
return ((reach * impact * (confidence / 100)) / effort).toFixed(2)
}
async function ensureScoreValid() {
if (checkingScore.value) return false
checkingScore.value = true
try {
const response = await checkFeatureConversionStepTwo({
sourceDefinitions: form.value.rows.map((row) => row.definition),
rows: form.value.scoreRows.map((row) => ({ ...row, riceScore: calculateRiceScore(row) })),
})
const result = response?.data || {}
scoreValidationMessage.value = result.message || (result.valid ? "RICE 评分填写完整。" : "请完整填写 RICE 评分表。")
return result.valid === true
} catch (error) {
scoreValidationMessage.value = error?.message || "RICE 评分校验失败,请稍后重试。"
return false
} finally {
checkingScore.value = false
}
}
async function ensurePriorityValid() {
if (checkingPriority.value) return false
checkingPriority.value = true
try {
const response = await checkFeatureConversionStepThree({
scoreRows: form.value.scoreRows.map((row) => ({ ...row, riceScore: calculateRiceScore(row) })),
rows: form.value.priorityRows,
})
const result = response?.data || {}
priorityValidationMessage.value = result.message || (result.valid ? "功能优先级清单填写完整。" : "请完成每项功能的优先级和划分理由。")
return result.valid === true
} catch (error) {
priorityValidationMessage.value = error?.message || "功能优先级清单校验失败,请稍后重试。"
return false
} finally {
checkingPriority.value = false
}
}
function normalizePriorityDecision(value = {}) {
return {
...createPriorityDecision(),
...(value && typeof value === "object" ? value : {}),
}
}
function buildOutcome() {
return {
taskKey: TASK_KEY,
taskId: taskConfig.value?.id || "",
taskName: taskTitle.value,
flowSteps: flowSteps.value,
productName: form.value.productName,
activeStep: form.value.activeStep,
rows: form.value.rows,
scoreRows: form.value.scoreRows,
priorityRows: form.value.priorityRows,
priorityDecision: form.value.priorityDecision,
updatedAt: new Date().toISOString(),
}
}
function buildAnswerPayload(status) {
const saveAction = status === "SUBMITTED" ? "SUBMIT" : status === "RESET" ? "RESET" : "SAVE"
return {
saveAction,
currentStep: form.value.activeStep,
step1Answer: JSON.stringify(buildOutcome()),
}
}
async function persist(status = "IN_PROGRESS") {
if (saving.value) return
if (status !== "RESET" && form.value.activeStep === 1 && !(await ensureConversionValid())) return false
if (status !== "RESET" && form.value.activeStep === 2 && !(await ensureScoreValid())) return false
if (status !== "RESET" && form.value.activeStep === 3 && !(await ensurePriorityValid())) return false
saving.value = true
try {
await saveStudentTrainingAnswer(TASK_KEY, buildAnswerPayload(status))
proxy?.$modal?.msgSuccess(status === "SUBMITTED" ? "提交成功" : "保存成功")
return true
} finally {
saving.value = false
}
}
function saveCurrentProgress() {
return persist("IN_PROGRESS")
}
function submitTask() {
return persist("SUBMITTED")
}
async function resetTraining() {
form.value = createForm()
await persist("RESET")
}
function exportOutcome() {
const blob = new Blob([JSON.stringify(buildOutcome(), null, 2)], { type: "application/json;charset=utf-8" })
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = `${taskTitle.value}-实训结果.json`
link.click()
URL.revokeObjectURL(url)
}
</script>
<style scoped lang="scss">
/* ===== 页面级令牌(教育蓝极简主题) ===== */
.feature-conversion-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;
--el-color-primary: #2563eb;
--el-color-primary-light-3: #60a5fa;
--el-color-primary-light-5: #93c5fd;
--el-color-primary-light-7: #bfdbfe;
--el-color-primary-light-8: #dbeafe;
--el-color-primary-light-9: #eff6ff;
--el-color-primary-dark-2: #1d4ed8;
--el-input-hover-border-color: #3b82f6;
--el-input-focus-border-color: #2563eb;
--el-input-focus-ring-color: rgba(59, 130, 246, 0.15);
--el-textarea-hover-border-color: #3b82f6;
--el-textarea-focus-border-color: #2563eb;
/* 字号梯度统一继承 .student-training-shell,页面不再重复声明 */
position: relative;
z-index: 2;
display: grid;
grid-template-columns: minmax(720px, 1fr) 340px;
gap: 24px;
width: 100%;
max-width: 100%;
min-height: calc(100vh - 106px);
margin: 0;
padding: 24px 24px 32px;
color: var(--student-text);
background: #ffffff;
}
/* 对齐 foundation 的 .training-core:只设 gap,无边框/背景/阴影 */
.feature-conversion-core {
display: grid;
gap: 10px;
align-content: start;
min-width: 0;
border: 0;
background: transparent;
box-shadow: none;
}
/* 任务头:独立卡片 + 左侧教育蓝竖条 */
.task-header {
position: relative;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin: 0;
padding: 18px 20px 18px 24px;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: var(--student-card-bg);
overflow: hidden;
&::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 4px;
background: var(--student-accent);
}
}
.task-action-strip {
margin: 0;
padding: 10px 16px;
}
.task-action-strip :deep(.training-task-toolbar) {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.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-card / step-card 层:透明容器 */
.task-card,
.step-card {
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
display: grid;
gap: 10px;
}
/* ===== 顶部任务工具栏 ===== */
.workbench-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.workbench-head--actions-only {
justify-content: flex-end;
margin-bottom: 18px;
padding-bottom: 0;
border-bottom: 0;
}
.workbench-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
margin-left: auto;
gap: 10px;
flex-shrink: 0;
}
.outline-action {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
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-size: var(--student-fs-body);
font-weight: 700;
cursor: pointer;
text-shadow: none;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease;
}
.outline-action:hover,
.outline-action:focus-visible {
border-color: var(--student-accent);
background: var(--student-accent-soft);
color: var(--student-accent);
}
/* ===== step-title-row + section-eyebrow ===== */
.step-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.step-title-row h3 {
margin: 4px 0 0;
color: var(--student-text);
font-size: var(--student-fs-h1);
font-weight: 700;
line-height: 1.35;
}
.section-eyebrow {
margin: 0 0 6px;
color: var(--student-text-secondary);
font-weight: 700;
font-size: var(--student-fs-caption);
letter-spacing: 0.12em;
text-transform: uppercase;
}
/* ===== 引导区:research-guide 同款 ===== */
.conversion-intro,
.rice-intro,
.decision-intro {
padding: 12px 14px;
border-left: 3px solid var(--student-accent);
background: var(--student-accent-soft);
border-radius: 0 8px 8px 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
line-height: 1.6;
}
.conversion-intro {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
>p {
max-width: 840px;
margin: 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
line-height: 1.6;
}
}
.rice-intro p {
margin: 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
line-height: 1.6;
}
.rice-intro p+p {
margin-top: 5px;
}
.decision-intro {
margin: 0;
color: var(--student-text-secondary);
}
.conversion-row-actions {
display: flex;
flex: 0 0 auto;
gap: 10px;
}
/* ===== 表格 ===== */
.conversion-table-wrap {
overflow-x: auto;
}
.conversion-table,
.priority-score-table,
.priority-decision-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.conversion-table {
min-width: 1120px;
}
.priority-score-table {
min-width: 1100px;
}
.priority-decision-table {
min-width: 860px;
}
.conversion-table th,
.conversion-table td,
.priority-score-table th,
.priority-score-table td,
.priority-decision-table th,
.priority-decision-table td {
border: 1px solid var(--student-panel-border);
}
.conversion-table th,
.priority-score-table th,
.priority-decision-table th {
padding: 10px 12px;
color: var(--student-text);
background: #fafbfc;
font-size: var(--student-fs-body-sm);
font-weight: 800;
text-align: left;
}
.conversion-table td,
.priority-score-table td,
.priority-decision-table td {
padding: 10px;
background: transparent;
vertical-align: top;
color: var(--student-text);
}
.conversion-table th:first-child,
.conversion-table td:first-child {
width: 72px;
text-align: center;
}
.conversion-table textarea {
display: block;
width: 100%;
height: 78px;
padding: 9px 10px;
border: 0;
outline: none;
resize: vertical;
color: var(--student-text);
background: transparent;
font-family: inherit;
font-size: var(--student-fs-body-sm);
line-height: 1.55;
box-sizing: border-box;
}
.conversion-table textarea::placeholder {
color: var(--student-text-secondary);
}
.conversion-table textarea:focus,
.priority-score-table textarea:focus,
.priority-decision-table textarea:focus {
outline: 2px solid rgba(37, 99, 235, 0.15);
outline-offset: -2px;
}
/* ===== priority-score-table ===== */
.priority-score-table th,
.priority-score-table td {
text-align: center;
vertical-align: middle;
}
.priority-score-table td {
height: 66px;
padding: 8px;
}
.priority-score-table .auto-score-cell {
color: var(--student-text-secondary);
background: transparent;
font-weight: 700;
}
.priority-score-table .auto-score-cell--name {
min-width: 180px;
color: var(--student-text-secondary);
font-weight: 700;
line-height: 1.55;
word-break: break-word;
text-align: left;
}
.priority-score-table select,
.priority-score-table input {
width: 100%;
min-height: 36px;
padding: 0 9px;
border: 1px solid var(--student-panel-border);
border-radius: 8px;
outline: none;
color: var(--student-text);
background: #ffffff;
font: inherit;
font-size: var(--student-fs-body-sm);
box-sizing: border-box;
}
.priority-score-table select:focus,
.priority-score-table input:focus {
outline: 2px solid rgba(37, 99, 235, 0.15);
outline-offset: -2px;
border-color: var(--student-accent);
}
.priority-score-table .metric-input {
position: relative;
input {
padding-right: 24px;
}
span {
position: absolute;
top: 50%;
right: 9px;
color: var(--student-text-secondary);
font-size: var(--student-fs-caption);
transform: translateY(-50%);
pointer-events: none;
}
}
.priority-score-table .rice-score-cell {
color: var(--student-accent);
font-size: var(--student-fs-data);
font-weight: 700;
background: transparent;
}
/* ===== priority-decision-table ===== */
.priority-decision-table th,
.priority-decision-table td {
text-align: center;
vertical-align: middle;
}
.priority-decision-table td {
height: 76px;
padding: 10px;
color: var(--student-text);
background: transparent;
}
.priority-decision-table th:nth-child(1),
.priority-decision-table td:nth-child(1) {
width: 22%;
}
.priority-decision-table th:nth-child(2),
.priority-decision-table td:nth-child(2) {
width: 18%;
}
.priority-decision-table .auto-score-cell--name {
color: var(--student-text-secondary);
font-weight: 700;
line-height: 1.55;
word-break: break-word;
text-align: left;
}
.priority-decision-table select {
width: min(100%, 130px);
min-height: 36px;
padding: 0 9px;
border: 1px solid var(--student-panel-border);
border-radius: 8px;
outline: none;
color: var(--student-text);
background: #ffffff;
font: inherit;
}
.priority-decision-table select:focus {
outline: 2px solid rgba(37, 99, 235, 0.15);
outline-offset: -2px;
border-color: var(--student-accent);
}
.priority-decision-table textarea {
display: block;
width: 100%;
height: 72px;
min-height: 46px;
padding: 10px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
outline: none;
color: var(--student-text);
background: transparent;
font: inherit;
font-size: var(--student-fs-body-sm);
line-height: 1.55;
resize: vertical;
box-sizing: border-box;
}
.priority-decision-table textarea:focus {
outline: 2px solid rgba(37, 99, 235, 0.15);
outline-offset: -2px;
border-color: var(--student-accent);
}
.priority-legend {
display: grid;
gap: 6px;
margin-top: 14px;
padding: 12px 14px;
border: 1px solid var(--student-panel-border);
border-radius: 8px;
background: var(--student-accent-soft);
p {
margin: 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
line-height: 1.6;
}
strong {
display: inline-block;
min-width: 29px;
margin-right: 8px;
color: var(--student-accent);
font-weight: 700;
}
}
/* ===== 消息提示 ===== */
.conversion-validation-message {
margin: 10px 0 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
font-weight: 600;
}
.conversion-limit-tip,
.rice-formula {
margin: 8px 0 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
font-weight: 600;
}
/* ===== completion-content ===== */
.completion-content p {
margin: 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
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-nav {
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);
}
&:disabled {
cursor: not-allowed;
opacity: 0.45;
transform: none;
box-shadow: none;
}
}
.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;
}
/* training-actions 容器:保留,el-button 由全局样式接管 */
.training-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 14px;
margin-top: 20px;
}
/* ===== 响应式 ===== */
@media (max-width: 900px) {
.workbench-head,
.workbench-actions {
align-items: stretch;
flex-direction: column;
}
.outline-action,
.step-navigation,
.btn-nav,
.btn-primary,
.btn-ghost,
.btn-save {
width: 100%;
}
.step-navigation {
flex-direction: column;
align-items: stretch;
}
.step-nav-left,
.step-nav-right {
justify-content: center;
}
.conversion-intro,
.conversion-row-actions {
align-items: stretch;
flex-direction: column;
}
.conversion-row-actions .el-button {
width: 100%;
}
}
</style>
<style lang="scss">
/* ============================================================
* 步骤导航:分段连接胶囊(教育蓝极简)
* ============================================================ */
#app .studentLayout section.app-main:has(.feature-conversion-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: 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;
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;
min-width: 22px !important;
height: 22px !important;
padding: 0 5px !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;
}
.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: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;
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(.feature-conversion-page) .step-indicator {
justify-content: flex-start !important;
overflow-x: auto !important;
}
#app .studentLayout section.app-main:has(.feature-conversion-page) .step-indicator .step-tab {
flex: 0 0 140px !important;
max-width: none !important;
}
}
/* ============================================================
* 共用组件换肤:TrainingMaterialButton 资料按钮 → 教育蓝
* ============================================================ */
.feature-conversion-page .workbench-actions .material-button.el-button {
height: 34px !important;
min-height: 34px !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
border-radius: 8px !important;
color: #2563eb !important;
background: #ffffff !important;
font-size: var(--student-fs-body) !important;
font-weight: 600 !important;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease !important;
&:hover {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12) !important;
}
&.is-plain {
border-color: #e5e7eb !important;
color: #9ca3af !important;
&:hover {
border-color: #e5e7eb !important;
color: #9ca3af !important;
background: #ffffff !important;
box-shadow: none !important;
}
}
}
/* ============================================================
* 顶部导出按钮(原生 <button.outline-action>)
* ============================================================ */
#app .studentLayout section.app-main:has(.feature-conversion-page) .workbench-actions>button.outline-action {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 6px !important;
height: 34px !important;
min-height: 34px !important;
padding: 0 14px !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
border-radius: 8px !important;
color: #2563eb !important;
background: #ffffff !important;
font-size: var(--student-fs-body) !important;
font-weight: 600 !important;
line-height: 1 !important;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease !important;
}
#app .studentLayout section.app-main:has(.feature-conversion-page) .workbench-actions>button.outline-action:hover {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12) !important;
transform: none !important;
}
/* ============================================================
* 底部步骤按钮(40px 标准):task-actions > button
* ============================================================ */
#app .studentLayout section.app-main:has(.feature-conversion-page) .task-actions {
gap: 14px !important;
margin-top: 20px !important;
}
#app .studentLayout section.app-main:has(.feature-conversion-page) .task-actions>button {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
height: 34px !important;
min-height: 34px !important;
padding: 0 14px !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
border-radius: 8px !important;
color: #2563eb !important;
background: #ffffff !important;
font-size: var(--student-fs-body) !important;
font-weight: 700 !important;
line-height: 1 !important;
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease !important;
}
#app .studentLayout section.app-main:has(.feature-conversion-page) .task-actions>button:hover:not(:disabled) {
transform: translateY(-1px) !important;
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.12) !important;
}
#app .studentLayout section.app-main:has(.feature-conversion-page) .task-actions>button.btn-submit {
border-color: #2563eb !important;
color: #ffffff !important;
background: #2563eb !important;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.28) !important;
}
#app .studentLayout section.app-main:has(.feature-conversion-page) .task-actions>button.btn-submit:hover:not(:disabled) {
border-color: #1d4ed8 !important;
color: #ffffff !important;
background: #1d4ed8 !important;
box-shadow: 0 5px 14px rgba(29, 78, 216, 0.32) !important;
}
/* ============================================================
* 顶部任务工具栏(TrainingTaskToolbar 组件)
* ============================================================ */
.feature-conversion-page .task-toolbar-button.el-button {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 6px !important;
min-height: 34px !important;
height: 34px !important;
padding: 0 12px !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
border-radius: 8px !important;
color: #2563eb !important;
background: #ffffff !important;
font-size: var(--student-fs-body) !important;
font-weight: 600 !important;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease !important;
&:hover:not(.is-disabled) {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
transform: translateY(-1px) !important;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12) !important;
}
}
.feature-conversion-page .task-toolbar-button--submit {
border-color: #2563eb !important;
background: #2563eb !important;
color: #ffffff !important;
box-shadow: 0 1px 2px rgba(37, 99, 235, 0.35), 0 3px 10px rgba(37, 99, 235, 0.22) !important;
&:hover:not(.is-disabled) {
background: #1d4ed8 !important;
box-shadow: 0 2px 4px rgba(37, 99, 235, 0.4), 0 5px 14px rgba(29, 78, 216, 0.28) !important;
}
}
/* ============================================================
* 底部提交/清空按钮:training-actions
* ============================================================ */
#app .studentLayout section.app-main:has(.feature-conversion-page) .training-actions {
gap: 14px !important;
margin-top: 20px !important;
}
#app .studentLayout section.app-main:has(.feature-conversion-page) .training-actions .el-button.training-action {
min-width: 140px !important;
min-height: 34px !important;
height: 34px !important;
padding: 0 14px !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
border-radius: 8px !important;
color: #2563eb !important;
background: #ffffff !important;
font-size: var(--student-fs-body) !important;
font-weight: 700 !important;
line-height: 1 !important;
letter-spacing: 0 !important;
text-shadow: none !important;
box-shadow: none !important;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease !important;
}
#app .studentLayout section.app-main:has(.feature-conversion-page) .training-actions .el-button.training-action:hover:not(.is-disabled) {
transform: translateY(-1px) !important;
border-color: #2563eb !important;
background: #eff6ff !important;
color: #2563eb !important;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.12) !important;
}
#app .studentLayout section.app-main:has(.feature-conversion-page) .training-actions .el-button.training-action--submit {
border-color: #2563eb !important;
color: #ffffff !important;
background: #2563eb !important;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.28) !important;
}
#app .studentLayout section.app-main:has(.feature-conversion-page) .training-actions .el-button.training-action--submit:hover:not(.is-disabled) {
border-color: #1d4ed8 !important;
background: #1d4ed8 !important;
color: #ffffff !important;
box-shadow: 0 5px 14px rgba(29, 78, 216, 0.32) !important;
}
#app .studentLayout section.app-main:has(.feature-conversion-page) .training-actions .el-button.training-action--reset {
border-color: #e5e7eb !important;
color: #374151 !important;
background: #f3f4f6 !important;
}
#app .studentLayout section.app-main:has(.feature-conversion-page) .training-actions .el-button.training-action--reset:hover:not(.is-disabled) {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
}
</style>