Merge branch 'codex/feature-conversion-step1'

# Conflicts:
#	src/api/studentTrainingAnswer.js
dev-QQq
chenyuan 2 weeks ago
commit 6dd2945589

@ -1737,3 +1737,19 @@ export function checkConsumerScenarioStepFour(payload) {
if (isStudentDemo()) { const valid = rows.length === consumerScenarioJourneyOrder.length && rows.every((row) => consumerScenarioJourneyOrder.includes(row?.stageId) && String(row?.painPoint || "").trim() && String(row?.optimizationSuggestion || "").trim()) && new Set(rows.map((row) => row.stageId)).size === consumerScenarioJourneyOrder.length; return Promise.resolve({ code: 200, data: { valid, message: valid ? "五个阶段的优化分析填写完整。" : "请完整填写五个阶段的痛点和优化建议。" } }); } if (isStudentDemo()) { const valid = rows.length === consumerScenarioJourneyOrder.length && rows.every((row) => consumerScenarioJourneyOrder.includes(row?.stageId) && String(row?.painPoint || "").trim() && String(row?.optimizationSuggestion || "").trim()) && new Set(rows.map((row) => row.stageId)).size === consumerScenarioJourneyOrder.length; return Promise.resolve({ code: 200, data: { valid, message: valid ? "五个阶段的优化分析填写完整。" : "请完整填写五个阶段的痛点和优化建议。" } }); }
return request({ url: "/api/student-training-answers/consumer-scenario/step-4/validate", method: "post", data: { rows }, headers: { repeatSubmit: false } }); return request({ url: "/api/student-training-answers/consumer-scenario/step-4/validate", method: "post", data: { rows }, headers: { repeatSubmit: false } });
} }
export function checkFeatureConversionStepOne(payload) {
const rows = Array.isArray(payload?.rows) ? payload.rows : [];
if (isStudentDemo()) { const valid = rows.length >= 8 && rows.length <= 12 && rows.every((row) => ["voice", "insight", "definition", "description", "acceptance"].every((key) => String(row?.[key] || "").trim())); return Promise.resolve({ code: 200, data: { valid, message: valid ? "需求转化表填写完整。" : "请完整填写 8 至 12 行需求转化信息。" } }); }
return request({ url: "/api/student-training-answers/feature-conversion/step-1/validate", method: "post", data: { rows }, headers: { repeatSubmit: false } });
}
export function checkFeatureConversionStepTwo(payload) {
const sourceDefinitions = Array.isArray(payload?.sourceDefinitions) ? payload.sourceDefinitions : []; const rows = Array.isArray(payload?.rows) ? payload.rows : [];
if (isStudentDemo()) { const valid = rows.length >= 8 && rows.length <= 12 && sourceDefinitions.length === rows.length && rows.every((row) => ["reach", "impact", "confidence", "effort"].every((key) => Number.isFinite(Number(row?.[key])))); return Promise.resolve({ code: 200, data: { valid, message: valid ? "RICE 评分填写完整。" : "请完整填写 RICE 评分。" } }); }
return request({ url: "/api/student-training-answers/feature-conversion/step-2/validate", method: "post", data: { sourceDefinitions, rows }, headers: { repeatSubmit: false } });
}
export function checkFeatureConversionStepThree(payload) {
const scoreRows = Array.isArray(payload?.scoreRows) ? payload.scoreRows : []; const rows = Array.isArray(payload?.rows) ? payload.rows : [];
if (isStudentDemo()) { const valid = scoreRows.length >= 8 && scoreRows.length <= 12 && rows.length === scoreRows.length && rows.every((row, index) => row?.name === scoreRows[index]?.name && ["P0", "P1", "P2", "P3"].includes(row?.priority) && String(row?.reason || "").trim()); return Promise.resolve({ code: 200, data: { valid, message: valid ? "功能优先级填写完整。" : "请完整填写功能优先级与理由。" } }); }
return request({ url: "/api/student-training-answers/feature-conversion/step-3/validate", method: "post", data: { scoreRows, rows }, headers: { repeatSubmit: false } });
}

@ -50,49 +50,13 @@
<h3>{{ activeStepName }}</h3> <h3>{{ activeStepName }}</h3>
</div> </div>
</div> </div>
<div class="conversion-intro">
<label class="field-block product-field"> <p>用户反馈通常混杂重复描述情绪化表达无关信息请阅读案例资料中原始用户反馈逐条进行场景洞察拆解分析用户在什么时间什么情境下产生该需求背后的深层动机是什么然后将用户语言转化为可执行的产品功能定义填写以下需求转化表</p>
<span>产品名称</span> <div class="conversion-row-actions">
<input v-model="form.productName" type="text" /> <el-button type="primary" :disabled="form.rows.length >= MAX_CONVERSION_ROWS" @click="addConversionRow"></el-button>
</label> <el-button type="primary" plain :disabled="form.rows.length <= MIN_CONVERSION_ROWS" @click="removeConversionRow"></el-button>
</div>
<h4>需求转化用户语言工程语言</h4>
<p>用户往往只描述现象或愿望研发团队需要将其转化为可执行可验收的技术指标</p>
<div class="example-block">
<h5>需求转化过程示例</h5>
<p><strong>1. 场景还原与痛点提炼</strong></p>
<p>不要只听用户说我想要个能陪孩子的机器人而要深入场景</p>
<p class="indent">用户原话孩子在家总看电视我希望机器人能陪他玩还能教我孩子学英语</p>
<p class="indent">场景洞察家长30-40 双职工下班晚担心孩子视力及教育缺失孩子3-6 注意力短需要强互动</p>
<p class="indent">转化动作陪玩转化为多模态交互能力语音 + 视觉 + 运动学英语转化为内容生态接入与个性化推荐算法</p>
<p><strong>2. 标准化需求定义PRD 要素</strong></p>
<p>将洞察转化为开发团队能看懂的文档必须包含验收标准</p>
<p class="indent">需求名称主动式英语启蒙互动</p>
<p class="indent">功能描述当摄像头识别到孩子注视屏幕超过 10 分钟或检测到孩子无聊发呆时机器人主动发起英语对话或游戏邀请</p>
<p class="indent">验收标准</p>
<p class="indent">识别响应时间 &lt; 1 </p>
<p class="indent">对话自然度MOS &gt; 4.0</p>
<p class="indent">内容准确率由教育专家评估 &gt; 90%</p>
</div>
<p class="source-title">原始需求基础材料</p>
<div class="feedback-grid">
<section>
<h5>家长需求反馈</h5>
<ol>
<li v-for="item in parentFeedback" :key="item">{{ item }}</li>
</ol>
</section>
<section>
<h5>孩子需求反馈</h5>
<ol>
<li v-for="item in childFeedback" :key="item">{{ item }}</li>
</ol>
</section>
</div> </div>
<p class="table-tip">请根据本题需求填写以下需求转化表</p>
<div class="conversion-table-wrap"> <div class="conversion-table-wrap">
<table class="conversion-table"> <table class="conversion-table">
<thead> <thead>
@ -108,15 +72,22 @@
<tbody> <tbody>
<tr v-for="(row, index) in form.rows" :key="row.id"> <tr v-for="(row, index) in form.rows" :key="row.id">
<td>{{ index + 1 }}</td> <td>{{ index + 1 }}</td>
<td><textarea v-model="row.voice" placeholder="填写用户原话"></textarea></td> <td><textarea v-model="row.voice" :placeholder="index === 0 ? '示例:起夜需要温和照明,避免强光刺激' : '填写用户原声'"></textarea></td>
<td><textarea v-model="row.insight" placeholder="填写场景与痛点洞察"></textarea></td> <td><textarea v-model="row.insight" :placeholder="index === 0 ? '示例凌晨2-5点用户从深度睡眠中被尿意唤醒处于半梦半醒状态瞳孔对光线极其敏感' : '填写场景洞察'"></textarea></td>
<td><textarea v-model="row.definition" placeholder="填写转化后的功能定义"></textarea></td> <td><textarea v-model="row.definition" :placeholder="index === 0 ? '示例:起夜微光模式' : '填写转化后的功能定义'"></textarea></td>
<td><textarea v-model="row.description" placeholder="填写功能描述"></textarea></td> <td><textarea v-model="row.description" :placeholder="index === 0 ? '示例感应到用户起身时自动开启亮度≤10%的暖黄地脚光延迟3秒渐亮' : '填写功能描述'"></textarea></td>
<td><textarea v-model="row.acceptance" placeholder="填写技术指标或验收标准"></textarea></td> <td><textarea v-model="row.acceptance" :placeholder="index === 0 ? '示例亮度5-15流明可调色温2700K启动延时≤0.5秒光通量渐升时间3±0.5秒' : '填写技术指标/验收标准'"></textarea></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </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>
<section v-show="isSectionVisible(2)" class="step-card conversion-content priority-content"> <section v-show="isSectionVisible(2)" class="step-card conversion-content priority-content">
@ -126,74 +97,47 @@
<h3>{{ activeStepName }}</h3> <h3>{{ activeStepName }}</h3>
</div> </div>
</div> </div>
<p> <div class="rice-intro">
对于智能硬件建议采用RICE 模型Reach 覆盖范围Impact 影响程度Confidence 置信度Effort 投入成本进行量化打分但必须加一个前置过滤器合规与安全并使用KANO模型辅助决策 <p>将步骤一的功能纳入 RICE 评估模型进行量化打分RICE 模型包含覆盖用户数影响程度置信度开发成本四个维度</p>
</p> <p>请根据上一步骤需求转化后的功能定义针对具体需求进行量化打分填写以下打分表</p>
<p>
必备属性Must-be跌倒检测语音唤醒做不到用户会极度不满做到了是理所应当优先保障不计成本投入稳定性
</p>
<p>
期望属性Performance续航时间识别准确率做得越好用户越满意 BOM 成本允许范围内最大化
</p>
<p>
魅力属性Delighters情绪识别主动安慰没有用户无所谓有了是惊喜在核心功能稳定后作为差异化卖点迭代
</p>
<p class="source-title">智能硬件需求优先级评估打分表模板</p>
<div class="conversion-table-wrap">
<table class="priority-template-table">
<thead>
<tr>
<th>字段</th>
<th>说明</th>
<th>填写示例</th>
<th>来源/依据</th>
</tr>
</thead>
<tbody>
<tr v-for="row in priorityTemplateRows" :key="row.field">
<td>{{ row.field }}</td>
<td v-html="row.description"></td>
<td v-html="row.example"></td>
<td>{{ row.source }}</td>
</tr>
</tbody>
</table>
</div> </div>
<p class="table-tip">请根据上一步骤需求转化后的功能定义针对具体需求进行量化打分填写以下打分表</p>
<div class="conversion-table-wrap"> <div class="conversion-table-wrap">
<table class="priority-score-table"> <table class="priority-score-table">
<thead> <thead>
<tr> <tr>
<th>需求ID</th> <th>需求ID</th>
<th>需求名称</th> <th>功能名称</th>
<th>需求来源</th>
<th>KANO属性</th> <th>KANO属性</th>
<th>Reach覆盖范围</th> <th>Reach覆盖范围</th>
<th>Impact影响程度</th> <th>Impact影响程度</th>
<th>Confidence置信度</th> <th>Confidence置信度</th>
<th>Effort投入成本</th> <th>Effort/</th>
<th>RICE得分</th> <th>RICE得分</th>
<th>合规性检查项</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="row in form.scoreRows" :key="row.uid"> <tr v-for="row in form.scoreRows" :key="row.uid">
<td><textarea v-model="row.requirementId" aria-label="需求ID"></textarea></td> <td class="auto-score-cell">{{ row.requirementId }}</td>
<td><textarea v-model="row.name" aria-label="需求名称"></textarea></td> <td class="auto-score-cell auto-score-cell--name">{{ row.name || "请先在步骤一填写功能定义" }}</td>
<td><textarea v-model="row.source" aria-label="需求来源"></textarea></td> <td>
<td><textarea v-model="row.kano" aria-label="KANO属性"></textarea></td> <select v-model="row.kano" :aria-label="`${row.requirementId} KANO属性`">
<td><textarea v-model="row.reach" aria-label="Reach覆盖范围"></textarea></td> <option value="">请选择</option>
<td><textarea v-model="row.impact" aria-label="Impact影响程度"></textarea></td> <option v-for="type in kanoTypes" :key="type" :value="type">{{ type }}</option>
<td><textarea v-model="row.confidence" aria-label="Confidence置信度"></textarea></td> </select>
<td><textarea v-model="row.effort" aria-label="Effort投入成本"></textarea></td> </td>
<td><textarea v-model="row.rice" aria-label="RICE得分"></textarea></td> <td><input v-model.number="row.reach" type="number" min="1" max="5" step="1" aria-label="Reach" /></td>
<td><textarea v-model="row.compliance" aria-label="合规性检查项"></textarea></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> </tr>
</tbody> </tbody>
</table> </table>
</div> </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>
<section v-show="isSectionVisible(3)" class="step-card conversion-content decision-content"> <section v-show="isSectionVisible(3)" class="step-card conversion-content decision-content">
@ -203,29 +147,34 @@
<h3>{{ activeStepName }}</h3> <h3>{{ activeStepName }}</h3>
</div> </div>
</div> </div>
<p class="decision-intro"> <p class="decision-intro">基于步骤二的 RICE 评分结果输出 P0-P3 四级功能清单并说明每一等级的划分理由</p>
假设项目首期 BOM 成本控制在 300 元以内研发周期 4 个月根据上一步骤完成的需求打分表填写以下内容 <div class="conversion-table-wrap">
</p> <table class="priority-decision-table">
<thead>
<label class="decision-field"> <tr>
<span>P0首期必做 - 生存线不做这些产品无法上市或会被立即退货</span> <th>功能名称</th>
<textarea v-model="form.priorityDecision.p0" aria-label="P0首期必做"></textarea> <th>优先级</th>
</label> <th>理由</th>
</tr>
<label class="decision-field"> </thead>
<span>P1首期核心 - 竞争力在成本允许下最大化期望属性提升性价比感知</span> <tbody>
<textarea v-model="form.priorityDecision.p1" aria-label="P1首期核心"></textarea> <tr v-for="row in form.priorityRows" :key="row.uid">
</label> <td class="auto-score-cell auto-score-cell--name">{{ row.name || "请先完成步骤二 RICE 评分" }}</td>
<td>
<label class="decision-field"> <select v-model="row.priority" :aria-label="`${row.name || row.uid} 优先级`">
<span>P2二期迭代 - 差异化虽然可以提升用户体验但是成本较高或者增加合规风险的</span> <option value="">请选择</option>
<textarea v-model="form.priorityDecision.p2" aria-label="P2二期迭代"></textarea> <option v-for="level in priorityLevels" :key="level.value" :value="level.value">{{ level.value }}</option>
</label> </select>
</td>
<label class="decision-field"> <td><textarea v-model="row.reason" :aria-label="`${row.name || row.uid} 划分理由`" placeholder="结合 RICE 评分、KANO 属性和开发投入说明划分理由"></textarea></td>
<span>P3暂缓/砍掉需求伪命题偏离核心场景</span> </tr>
<textarea v-model="form.priorityDecision.p3" aria-label="P3暂缓砍掉"></textarea> </tbody>
</label> </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>
<section v-show="isSectionVisible(4)" class="step-card conversion-content completion-content"> <section v-show="isSectionVisible(4)" class="step-card conversion-content completion-content">
@ -281,9 +230,18 @@ import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue"; import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue"; import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
import { getTrainingTaskByKey } from "@/api/trainingTask"; import { getTrainingTaskByKey } from "@/api/trainingTask";
import { getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer"; import { checkFeatureConversionStepOne, checkFeatureConversionStepTwo, checkFeatureConversionStepThree, getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
const TASK_KEY = "feature-conversion"; 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 { proxy } = getCurrentInstance();
const defaultRequirement = const defaultRequirement =
@ -350,9 +308,15 @@ const childFeedback = [
const taskConfig = ref(null); const taskConfig = ref(null);
const saving = ref(false); const saving = ref(false);
const checkingConversion = ref(false);
function createRows() { const checkingScore = ref(false);
return Array.from({ length: 5 }, (_, index) => ({ 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, id: index + 1,
voice: "", voice: "",
insight: "", insight: "",
@ -362,19 +326,25 @@ function createRows() {
})); }));
} }
function createScoreRows() { function createScoreRows(sourceRows = createRows()) {
return Array.from({ length: 5 }, (_, index) => ({ return sourceRows.map((sourceRow, index) => ({
uid: index + 1, uid: index + 1,
requirementId: "", requirementId: formatRequirementId(index),
name: "", name: sourceRow.definition || "",
source: "",
kano: "", kano: "",
reach: "", reach: "",
impact: "", impact: "",
confidence: "", confidence: "",
effort: "", effort: "",
rice: "", }));
compliance: "", }
function createPriorityRows(sourceRows = createScoreRows()) {
return sourceRows.map((sourceRow, index) => ({
uid: index + 1,
name: sourceRow.name || "",
priority: "",
reason: "",
})); }));
} }
@ -388,11 +358,13 @@ function createPriorityDecision() {
} }
function createForm() { function createForm() {
const rows = createRows();
return { return {
activeStep: 1, activeStep: 1,
productName: "", productName: "",
rows: createRows(), rows,
scoreRows: createScoreRows(), scoreRows: createScoreRows(rows),
priorityRows: createPriorityRows(createScoreRows(rows)),
priorityDecision: createPriorityDecision(), priorityDecision: createPriorityDecision(),
}; };
} }
@ -408,7 +380,6 @@ const hasConfiguredSteps = computed(() => flowSteps.value.length > 0);
const activeStepName = computed(() => flowSteps.value[form.value.activeStep - 1] || ""); const activeStepName = computed(() => flowSteps.value[form.value.activeStep - 1] || "");
const progressItems = computed(() => [ const progressItems = computed(() => [
{ text: form.value.productName ? "已填写:产品名称" : "待填写:产品名称", status: form.value.productName ? "done" : "" },
{ text: hasTableContent.value ? "进行中:需求转化表" : "待完成:需求转化表", status: hasTableContent.value ? "doing" : "" }, { text: hasTableContent.value ? "进行中:需求转化表" : "待完成:需求转化表", status: hasTableContent.value ? "doing" : "" },
{ text: hasScoreContent.value ? "进行中:优先级打分表" : "待完成:优先级打分表", status: hasScoreContent.value ? "doing" : "" }, { text: hasScoreContent.value ? "进行中:优先级打分表" : "待完成:优先级打分表", status: hasScoreContent.value ? "doing" : "" },
{ text: hasDecisionContent.value ? "进行中:优先级排序决策" : "待完成:优先级排序决策", status: hasDecisionContent.value ? "doing" : "" }, { text: hasDecisionContent.value ? "进行中:优先级排序决策" : "待完成:优先级排序决策", status: hasDecisionContent.value ? "doing" : "" },
@ -423,14 +394,14 @@ const hasTableContent = computed(() =>
const hasScoreContent = computed(() => const hasScoreContent = computed(() =>
form.value.scoreRows.some((row) => form.value.scoreRows.some((row) =>
["requirementId", "name", "source", "kano", "reach", "impact", "confidence", "effort", "rice", "compliance"].some((key) => ["kano", "reach", "impact", "confidence", "effort"].some((key) =>
String(row[key] || "").trim() String(row[key] || "").trim()
) )
) )
); );
const hasDecisionContent = computed(() => const hasDecisionContent = computed(() =>
["p0", "p1", "p2", "p3"].some((key) => String(form.value.priorityDecision?.[key] || "").trim()) form.value.priorityRows.some((row) => String(row.priority || "").trim() || String(row.reason || "").trim())
); );
onMounted(async () => { onMounted(async () => {
@ -482,31 +453,37 @@ function normalizeActiveStep(value) {
return Math.min(Math.max(parsed, 1), flowSteps.value.length); return Math.min(Math.max(parsed, 1), flowSteps.value.length);
} }
function setCurrentStep(step) { async function setCurrentStep(step) {
form.value.activeStep = normalizeActiveStep(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) { function isSectionVisible(section) {
return !hasConfiguredSteps.value || form.value.activeStep === section; return !hasConfiguredSteps.value || form.value.activeStep === section;
} }
function goPreviousStep() { async function goPreviousStep() {
setCurrentStep(form.value.activeStep - 1); await setCurrentStep(form.value.activeStep - 1);
} }
function goNextStep() { async function goNextStep() {
setCurrentStep(form.value.activeStep + 1); await setCurrentStep(form.value.activeStep + 1);
} }
function applySavedAnswer(value) { function applySavedAnswer(value) {
try { try {
const parsed = JSON.parse(value); const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== "object") return; if (!parsed || typeof parsed !== "object") return;
const rows = normalizeRows(parsed.rows);
form.value = { form.value = {
...createForm(), ...createForm(),
...parsed, ...parsed,
rows: normalizeRows(parsed.rows), rows,
scoreRows: normalizeScoreRows(parsed.scoreRows), scoreRows: normalizeScoreRows(parsed.scoreRows, rows),
priorityRows: normalizePriorityRows(parsed.priorityRows, normalizeScoreRows(parsed.scoreRows, rows)),
priorityDecision: normalizePriorityDecision(parsed.priorityDecision), priorityDecision: normalizePriorityDecision(parsed.priorityDecision),
activeStep: normalizeActiveStep(parsed.activeStep), activeStep: normalizeActiveStep(parsed.activeStep),
}; };
@ -517,22 +494,125 @@ function applySavedAnswer(value) {
function normalizeRows(rows = []) { function normalizeRows(rows = []) {
const savedRows = Array.isArray(rows) ? rows : []; const savedRows = Array.isArray(rows) ? rows : [];
return createRows().map((row, index) => ({ 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, ...row,
...(savedRows[index] || {}), ...(savedRows[index] || {}),
id: row.id, uid: row.uid,
requirementId: row.requirementId,
name: row.name,
})); }));
} }
function normalizeScoreRows(rows = []) { 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 : []; const savedRows = Array.isArray(rows) ? rows : [];
return createScoreRows().map((row, index) => ({ return createPriorityRows(sourceRows).map((row, index) => ({
...row, ...row,
...(savedRows[index] || {}), ...(savedRows[index] || {}),
uid: row.uid, 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 = {}) { function normalizePriorityDecision(value = {}) {
return { return {
...createPriorityDecision(), ...createPriorityDecision(),
@ -550,6 +630,7 @@ function buildOutcome() {
activeStep: form.value.activeStep, activeStep: form.value.activeStep,
rows: form.value.rows, rows: form.value.rows,
scoreRows: form.value.scoreRows, scoreRows: form.value.scoreRows,
priorityRows: form.value.priorityRows,
priorityDecision: form.value.priorityDecision, priorityDecision: form.value.priorityDecision,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
@ -566,10 +647,14 @@ function buildAnswerPayload(status) {
async function persist(status = "IN_PROGRESS") { async function persist(status = "IN_PROGRESS") {
if (saving.value) return; 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; saving.value = true;
try { try {
await saveStudentTrainingAnswer(TASK_KEY, buildAnswerPayload(status)); await saveStudentTrainingAnswer(TASK_KEY, buildAnswerPayload(status));
proxy?.$modal?.msgSuccess(status === "SUBMITTED" ? "提交成功" : "保存成功"); proxy?.$modal?.msgSuccess(status === "SUBMITTED" ? "提交成功" : "保存成功");
return true;
} finally { } finally {
saving.value = false; saving.value = false;
} }
@ -789,6 +874,35 @@ function exportOutcome() {
margin: 4px 0; margin: 4px 0;
} }
.conversion-intro {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 22px;
margin-bottom: 16px;
padding: 18px 20px;
border-left: 3px solid #51dfff;
border-radius: 0 14px 14px 0;
background: linear-gradient(90deg, rgba(7, 95, 137, 0.28), rgba(4, 31, 49, 0.18));
> p {
max-width: 840px;
color: #c6dee9;
font-size: 14px;
line-height: 1.85;
}
}
.conversion-row-actions {
display: flex;
flex: 0 0 auto;
gap: 10px;
.el-button {
min-width: 112px;
}
}
.indent { .indent {
padding-left: 24px; padding-left: 24px;
} }
@ -878,6 +992,40 @@ function exportOutcome() {
color: #8fa4b1; color: #8fa4b1;
} }
.conversion-limit-tip {
margin: 12px 0 0 !important;
color: #ff958a;
font-size: 13px;
font-weight: 800;
}
.conversion-helper {
margin-top: 26px;
padding: 16px 18px;
border: 1px solid rgba(68, 163, 208, 0.3);
border-radius: 14px;
color: #b7d1de;
background: rgba(3, 27, 43, 0.4);
h5 {
margin: 0 0 4px;
color: #e8f9ff;
font-size: 14px;
}
p {
font-size: 13px;
line-height: 1.8;
}
}
.conversion-validation-message {
margin: 14px 0 0 !important;
color: #ffbbb0;
font-size: 13px;
font-weight: 800;
}
.priority-content { .priority-content {
max-width: 100%; max-width: 100%;
} }
@ -1587,6 +1735,204 @@ function exportOutcome() {
margin-top: 36px; margin-top: 36px;
} }
.rice-intro {
margin-bottom: 16px;
padding: 18px 20px;
border-left: 3px solid #53dcff;
border-radius: 0 14px 14px 0;
background: linear-gradient(90deg, rgba(7, 95, 137, 0.28), rgba(4, 31, 49, 0.16));
p {
margin: 0;
color: #c7dfe9;
font-size: 14px;
line-height: 1.85;
}
p + p {
margin-top: 5px;
}
}
.priority-score-table {
min-width: 1100px;
th,
td {
text-align: center;
vertical-align: middle;
}
td {
height: 66px;
padding: 8px;
}
.auto-score-cell {
color: #bde8f6;
background: rgba(7, 62, 88, 0.5);
font-weight: 800;
}
.auto-score-cell--name {
min-width: 180px;
color: #d9f4ff;
font-weight: 700;
line-height: 1.55;
word-break: break-word;
}
select,
input {
width: 100%;
min-height: 36px;
padding: 0 9px;
border: 1px solid rgba(74, 157, 194, 0.64);
border-radius: 8px;
outline: none;
color: #eefbff;
background: rgba(6, 38, 58, 0.78);
font: inherit;
font-size: 13px;
box-sizing: border-box;
&:focus {
border-color: rgba(92, 224, 255, 0.76);
box-shadow: 0 0 0 3px rgba(34, 207, 255, 0.1);
}
}
.metric-input {
position: relative;
input {
padding-right: 24px;
}
span {
position: absolute;
top: 50%;
right: 9px;
color: #94bece;
font-size: 12px;
transform: translateY(-50%);
pointer-events: none;
}
}
.rice-score-cell {
color: #64e1ff;
font-size: 16px;
font-weight: 900;
background: rgba(9, 94, 137, 0.28);
}
}
.priority-decision-table {
width: 100%;
min-width: 860px;
border-collapse: collapse;
table-layout: fixed;
th,
td {
border: 1px solid rgba(77, 167, 220, 0.2);
text-align: center;
vertical-align: middle;
}
th {
padding: 12px;
color: #f4fcff;
background: linear-gradient(100deg, #0787bd, #075276);
font-size: 14px;
font-weight: 800;
}
td {
height: 76px;
padding: 10px;
color: #d7f1ff;
background: rgba(1, 14, 26, 0.48);
}
th:nth-child(1),
td:nth-child(1) {
width: 22%;
}
th:nth-child(2),
td:nth-child(2) {
width: 18%;
}
select {
width: min(100%, 130px);
min-height: 36px;
padding: 0 9px;
border: 1px solid rgba(74, 157, 194, 0.64);
border-radius: 8px;
outline: none;
color: #eefbff;
background: rgba(6, 38, 58, 0.78);
font: inherit;
}
textarea {
display: block;
width: 100%;
height: 72px;
min-height: 46px;
padding: 10px;
border: 1px solid rgba(45, 95, 126, 0.95);
border-radius: 10px;
outline: none;
color: #eef5ff;
background: rgba(4, 24, 38, 0.72);
font: inherit;
font-size: 13px;
line-height: 1.55;
resize: vertical;
box-sizing: border-box;
&:focus {
border-color: rgba(92, 224, 255, 0.7);
box-shadow: 0 0 0 3px rgba(0, 174, 255, 0.13);
}
}
}
.priority-legend {
display: grid;
gap: 6px;
margin-top: 18px;
padding: 16px 18px;
border: 1px solid rgba(89, 184, 226, 0.22);
border-radius: 14px;
background: rgba(5, 37, 55, 0.44);
p {
margin: 0;
color: #bfd9e5;
font-size: 13px;
line-height: 1.65;
}
strong {
display: inline-block;
min-width: 29px;
margin-right: 8px;
color: #68e3ff;
}
}
.rice-formula {
margin: 12px 0 0 !important;
color: #ff9a8d;
font-size: 13px;
font-weight: 800;
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.feature-conversion-page { .feature-conversion-page {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@ -1645,6 +1991,16 @@ function exportOutcome() {
.step-navigation { .step-navigation {
flex-direction: column; flex-direction: column;
} }
.conversion-intro,
.conversion-row-actions {
align-items: stretch;
flex-direction: column;
}
.conversion-row-actions .el-button {
width: 100%;
}
} }
.workbench-head--actions-only { .workbench-head--actions-only {
justify-content: flex-end; justify-content: flex-end;

Loading…
Cancel
Save