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

1565 lines
52 KiB
Vue

<template>
<div class="feature-conversion-page student-training-shell">
<main class="feature-conversion-core">
<section class="task-header">
<div class="task-head-main">
<div class="task-badge">市场洞察与需求分析</div>
<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-card">
<div class="workbench-head workbench-head--actions-only">
<div class="workbench-actions">
<TrainingMaterialButton
:material-name="taskConfig?.materialName"
:material-url="taskConfig?.materialUrl"
:training-data-name="taskConfig?.trainingDataName"
:training-data-url="taskConfig?.trainingDataUrl"
:guide-manual-name="taskConfig?.guideManualName"
:guide-manual-url="taskConfig?.guideManualUrl"
:ai-manual-name="taskConfig?.aiManualName"
:ai-manual-url="taskConfig?.aiManualUrl"
/>
<button type="button" class="outline-action" @click="exportOutcome">
<el-icon><Download /></el-icon>
导出实训成果
</button>
</div>
</div>
<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>
<div class="conversion-helper">
<h5>可参考以下方法:</h5>
<p>1. 自行阅读案例信息,将用户原声转化为需求,再通过需求分析转化为产品功能。</p>
<p>2. 使用通用 AI 大模型辅助完成场景洞察与功能转化,参考提示词见案例文档。</p>
</div>
<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">
<button type="button" class="btn-nav" :disabled="form.activeStep === 1" @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>
<button type="button" class="btn-nav btn-primary" :disabled="form.activeStep === flowSteps.length" @click="goNextStep">
下一步
<el-icon><ArrowRight /></el-icon>
</button>
</div>
<div class="training-actions">
<el-button class="training-action training-action--submit" :loading="saving" @click="submitTask">
提交任务
</el-button>
<el-button class="training-action training-action--reset" @click="resetTraining">
清空填写
</el-button>
</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, Download } from "@element-plus/icons-vue";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.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(false);
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: #1f2937;
--student-accent-deep: #111827;
--student-accent-light: #4b5563;
--student-accent-soft: #f3f4f6;
--student-danger: #d9554c;
--student-success: #4a9d6e;
--student-layer-1: #374151;
--student-layer-2: #43a294;
--student-layer-3: #8b6fc0;
--student-panel-border: #e5e7eb;
--student-card-bg: #ffffff;
--student-panel-bg: #fafbfc;
--student-text: #1f2937;
--student-text-secondary: #6b7280;
--student-fs-display: 17px;
--student-fs-h1: 15px;
--student-fs-h2: 14px;
--student-fs-h3: 13px;
--student-fs-body: 13px;
--student-fs-body-sm: 12px;
--student-fs-caption: 11px;
--student-fs-button: 13px;
--student-fs-button-lg: 13px;
--student-fs-data: 14px;
/* 不设 grid-template-columns,由全局 .student-training-shell 单列布局接管 */
min-width: 0;
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 {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin: 0;
padding: 16px;
border: 1px solid #e7e9ed;
border-radius: 16px;
background: #ffffff;
}
.task-head-main {
display: grid;
justify-items: start;
gap: 8px;
min-width: 0;
flex: 1 1 auto;
}
.task-badge {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 4px 12px;
border: 1px solid var(--student-panel-border);
border-radius: 999px;
color: var(--student-accent-deep);
background: var(--student-accent-soft);
font-size: 12px;
font-weight: 700;
text-transform: none;
letter-spacing: 0;
}
.task-title {
margin: 0;
color: var(--student-text);
font-size: var(--student-fs-display);
font-weight: 700;
line-height: 1.35;
}
.task-brief {
margin: 0;
color: var(--student-text-secondary);
font-size: 12px;
line-height: 1.6;
}
.task-brief-text {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.task-brief-toggle {
padding: 0;
border: 0;
color: var(--student-accent-deep);
background: transparent;
font: inherit;
font-weight: 700;
cursor: pointer;
&:hover { text-decoration: underline; }
}
.task-brief-more {
display: grid;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--student-panel-border);
b { color: var(--student-text); font-size: 12px; }
ul { display: grid; gap: 4px; margin: 0; padding-left: 18px; }
p { margin: 0; }
}
/* task-card / 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: 40px;
padding: 0 18px;
border: 1px solid rgba(31, 41, 55, 0.4);
border-radius: 10px;
color: var(--student-accent);
background: #ffffff;
font-size: 13px;
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: 11px;
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(31, 41, 55, 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(31, 41, 55, 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(31, 41, 55, 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(31, 41, 55, 0.15);
outline-offset: -2px;
border-color: var(--student-accent);
}
/* ===== 辅助块 ===== */
.conversion-helper {
margin-top: 14px;
padding: 12px 14px;
border: 1px solid var(--student-panel-border);
border-radius: 8px;
color: var(--student-text-secondary);
background: var(--student-accent-soft);
h5 {
margin: 0 0 6px;
color: var(--student-text);
font-size: var(--student-fs-body-sm);
font-weight: 700;
}
p {
margin: 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
line-height: 1.6;
}
p + p { margin-top: 4px; }
}
.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 {
display: flex;
justify-content: center;
gap: 14px;
margin-top: 20px;
}
.btn-nav {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 40px;
padding: 0 18px;
border: 1px solid rgba(31, 41, 55, 0.4);
border-radius: 10px;
color: var(--student-text);
background: #ffffff;
font-size: var(--student-fs-button);
font-weight: 700;
cursor: pointer;
text-shadow: none;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, transform 0.16s ease, box-shadow 0.16s ease;
}
.btn-nav:hover:not(:disabled) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
color: var(--student-accent);
transform: translateY(-1px);
box-shadow: 0 3px 10px rgba(31, 41, 55, 0.12);
}
.btn-nav.btn-primary {
border-color: var(--student-accent);
color: #ffffff;
background: var(--student-accent);
font-weight: 700;
box-shadow: 0 3px 10px rgba(31, 41, 55, 0.28);
}
.btn-nav.btn-primary:hover:not(:disabled) {
background: var(--student-accent-deep);
border-color: var(--student-accent-deep);
box-shadow: 0 5px 14px rgba(17, 24, 39, 0.32);
}
.btn-nav.btn-save {
border-color: #e5e7eb;
color: var(--student-accent-light);
background: var(--student-accent-soft);
box-shadow: none;
}
.btn-nav.btn-save:hover:not(:disabled) {
border-color: var(--student-accent);
background: #e9ecef;
color: var(--student-accent);
box-shadow: none;
}
.btn-nav:disabled {
cursor: not-allowed;
opacity: 0.5;
transform: none;
box-shadow: none;
}
/* 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 {
width: 100%;
}
.step-navigation {
flex-direction: column;
}
.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: 10px !important;
width: 100% !important;
margin: 4px 0 16px !important;
padding: 0 !important;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
.step-connector { display: none !important; }
.step-tab {
flex: 0 0 auto !important;
min-width: auto !important;
max-width: none !important;
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
height: auto !important;
min-height: 42px !important;
margin: 0 !important;
padding: 8px 14px !important;
border: 1px solid #e5e7eb !important;
border-radius: 8px !important;
background: #ffffff !important;
box-shadow: none !important;
color: #6b7280 !important;
font-size: 13px !important;
font-weight: 600 !important;
line-height: 1.3 !important;
letter-spacing: 0 !important;
white-space: nowrap !important;
text-align: center !important;
cursor: pointer;
transition: background-color 0.16s ease, color 0.16s ease, border-color 0.16s ease !important;
.step-marker {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
flex: 0 0 auto !important;
min-width: 22px !important;
width: auto !important;
height: 22px !important;
padding: 0 5px !important;
border: none !important;
border-radius: 6px !important;
background: #f3f4f6 !important;
color: #6b7280 !important;
font-family: ui-monospace, "SF Mono", Consolas, monospace !important;
font-size: 12px !important;
font-weight: 700 !important;
line-height: 1 !important;
transition: background-color 0.16s ease, color 0.16s ease !important;
}
.step-copy {
display: flex !important;
flex-direction: column !important;
gap: 0 !important;
min-width: 0 !important;
}
.step-name {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: normal !important;
line-height: 1.3 !important;
color: inherit !important;
}
.step-action { display: none !important; }
/* 未到达项 hover */
&:hover:not(.active):not(.complete) {
border-color: #1f2937 !important;
background: #f9fafb !important;
color: #1f2937 !important;
.step-marker { background: #1f2937 !important; color: #ffffff !important; }
}
/* 已完成 */
&.complete:not(.active) {
border-color: #dce9e2 !important;
color: #374151 !important;
.step-marker { background: rgba(74, 157, 110, 0.12) !important; color: #3f8a5f !important; }
&:hover {
border-color: #4a9d6e !important;
background: #f9fafb !important;
.step-marker { background: #4a9d6e !important; color: #ffffff !important; }
}
}
/* 当前步骤 */
&.active {
border-color: #1f2937 !important;
background: #1f2937 !important;
color: #ffffff !important;
font-weight: 700 !important;
.step-marker { background: #ffffff !important; color: #1f2937 !important; }
&:hover {
background: #111827 !important;
border-color: #111827 !important;
}
}
}
}
/* ===== 窄屏 ===== */
@media (max-width: 900px) {
#app .studentLayout section.app-main:has(.feature-conversion-page) .step-indicator {
justify-content: flex-start !important;
gap: 8px !important;
overflow-x: auto !important;
}
#app .studentLayout section.app-main:has(.feature-conversion-page) .step-indicator .step-tab {
flex: 0 0 auto !important;
max-width: none !important;
}
}
/* ===== 顶部任务工具栏换肤 ===== */
.feature-conversion-page .task-toolbar-button.el-button,
.feature-conversion-page .material-button.el-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
color: #374151;
background: #ffffff;
box-shadow: none;
text-shadow: none;
font-size: 13px;
font-weight: 600;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease;
}
.feature-conversion-page .task-toolbar-button.el-button .el-icon,
.feature-conversion-page .material-button.el-button .el-icon {
font-size: 14px;
color: inherit;
}
.feature-conversion-page .task-toolbar-button.el-button:hover,
.feature-conversion-page .task-toolbar-button.el-button:focus,
.feature-conversion-page .material-button.el-button:hover,
.feature-conversion-page .material-button.el-button:focus {
border-color: #1f2937;
color: #1f2937;
background: #f9fafb;
box-shadow: 0 2px 6px rgba(17, 24, 39, 0.1);
}
.feature-conversion-page .task-toolbar-button--submit.el-button {
border-color: #1f2937;
color: #ffffff;
background: #1f2937;
box-shadow: 0 2px 8px rgba(17, 24, 39, 0.22);
}
.feature-conversion-page .task-toolbar-button--submit.el-button:hover,
.feature-conversion-page .task-toolbar-button--submit.el-button:focus {
border-color: #111827;
color: #ffffff;
background: #111827;
box-shadow: 0 4px 12px rgba(17, 24, 39, 0.28);
}
.feature-conversion-page .task-toolbar-button.el-button.is-loading {
border-color: #d1d5db;
color: #374151;
background: #ffffff;
}
</style>