diff --git a/src/api/studentTrainingAnswer.js b/src/api/studentTrainingAnswer.js index 4850d5e..aeadb48 100644 --- a/src/api/studentTrainingAnswer.js +++ b/src/api/studentTrainingAnswer.js @@ -445,3 +445,41 @@ export function checkFeatureConversionStepOne(payload) { headers: { repeatSubmit: false }, }); } + +export function checkFeatureConversionStepTwo(payload) { + const sourceDefinitions = Array.isArray(payload?.sourceDefinitions) ? payload.sourceDefinitions : []; + const rows = Array.isArray(payload?.rows) ? payload.rows : []; + const kanoTypes = ["基本型", "期望型", "兴奋型", "无差异型", "反向型"]; + if (isStudentDemo()) { + const valid = rows.length >= 8 && rows.length <= 12 && sourceDefinitions.length === rows.length + && rows.every((row, index) => { + const reach = Number(row?.reach); + const impact = Number(row?.impact); + const confidence = Number(row?.confidence); + const effort = Number(row?.effort); + const riceScore = ((reach * impact * (confidence / 100)) / effort).toFixed(2); + return row?.requirementId === `F${String(index + 1).padStart(2, "0")}` + && row?.name === sourceDefinitions[index] + && Boolean(String(sourceDefinitions[index] || "").trim()) + && kanoTypes.includes(row?.kano) + && Number.isInteger(reach) && reach >= 1 && reach <= 5 + && Number.isInteger(impact) && impact >= 1 && impact <= 5 + && Number.isInteger(confidence) && confidence >= 1 && confidence <= 100 + && Number.isFinite(effort) && effort > 0 + && row?.riceScore === riceScore; + }); + return Promise.resolve({ + code: 200, + data: { + valid, + message: valid ? "RICE 评分填写完整。" : "请完整填写 RICE 评分表,并保持需求 ID 与功能名称和第一步一致。", + }, + }); + } + return request({ + url: "/api/student-training-answers/feature-conversion/step-2/validate", + method: "post", + data: { sourceDefinitions, rows }, + headers: { repeatSubmit: false }, + }); +} diff --git a/src/views/demand/feature-conversion.vue b/src/views/demand/feature-conversion.vue index 67b1a98..43cc388 100644 --- a/src/views/demand/feature-conversion.vue +++ b/src/views/demand/feature-conversion.vue @@ -97,74 +97,47 @@

{{ activeStepName }}

-

- 对于智能硬件,建议采用RICE 模型(Reach 覆盖范围、Impact 影响程度、Confidence 置信度、Effort 投入成本)进行量化打分,但必须加一个前置过滤器:合规与安全。并使用KANO模型辅助决策。 -

-

- 必备属性(Must-be):如“跌倒检测”、“语音唤醒”。做不到用户会极度不满,做到了是理所应当。优先保障,不计成本投入稳定性。 -

-

- 期望属性(Performance):如“续航时间”、“识别准确率”。做得越好用户越满意。在 BOM 成本允许范围内最大化。 -

-

- 魅力属性(Delighters):如“情绪识别主动安慰”。没有用户无所谓,有了是惊喜。在核心功能稳定后,作为差异化卖点迭代。 -

-

智能硬件需求优先级评估打分表模板:

- -
- - - - - - - - - - - - - - - - - -
字段说明填写示例来源/依据
{{ row.field }}{{ row.source }}
+
+

将步骤一的功能纳入 RICE 评估模型,进行量化打分。RICE 模型包含覆盖用户数、影响程度、置信度、开发成本四个维度。

+

请根据上一步骤需求转化后的功能定义,针对具体需求进行量化打分,填写以下打分表。

- -

请根据上一步骤需求转化后的功能定义,针对具体需求进行量化打分,填写以下打分表:

- - + - - - - - - - - - - - + + + + + + + +
需求ID 需求名称需求来源 KANO属性 Reach(覆盖范围) Impact(影响程度) Confidence(置信度)Effort(投入成本)Effort(人/月) RICE得分合规性检查项
{{ row.requirementId }}{{ row.name || "请先在步骤一填写功能定义" }} + + +
%
+
{{ calculateRiceScore(row) }}
+

计算公式:(Reach × Impact × Confidence) / Effort,结果保留两位小数。需求 ID 与功能名称由步骤一自动同步。

+

{{ scoreValidationMessage }}

@@ -252,11 +225,12 @@ import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"; import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue"; import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue"; import { getTrainingTaskByKey } from "@/api/trainingTask"; -import { checkFeatureConversionStepOne, getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer"; +import { checkFeatureConversionStepOne, checkFeatureConversionStepTwo, getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer"; const TASK_KEY = "feature-conversion"; const MIN_CONVERSION_ROWS = 8; const MAX_CONVERSION_ROWS = 12; +const kanoTypes = ["基本型", "期望型", "兴奋型", "无差异型", "反向型"]; const { proxy } = getCurrentInstance(); const defaultRequirement = @@ -324,7 +298,9 @@ const childFeedback = [ const taskConfig = ref(null); const saving = ref(false); const checkingConversion = ref(false); +const checkingScore = ref(false); const conversionValidationMessage = ref(""); +const scoreValidationMessage = ref(""); function createRows(count = MIN_CONVERSION_ROWS) { return Array.from({ length: count }, (_, index) => ({ @@ -337,19 +313,16 @@ function createRows(count = MIN_CONVERSION_ROWS) { })); } -function createScoreRows() { - return Array.from({ length: 5 }, (_, index) => ({ +function createScoreRows(sourceRows = createRows()) { + return sourceRows.map((sourceRow, index) => ({ uid: index + 1, - requirementId: "", - name: "", - source: "", + requirementId: formatRequirementId(index), + name: sourceRow.definition || "", kano: "", reach: "", impact: "", confidence: "", effort: "", - rice: "", - compliance: "", })); } @@ -363,11 +336,12 @@ function createPriorityDecision() { } function createForm() { + const rows = createRows(); return { activeStep: 1, productName: "", - rows: createRows(), - scoreRows: createScoreRows(), + rows, + scoreRows: createScoreRows(rows), priorityDecision: createPriorityDecision(), }; } @@ -397,7 +371,7 @@ const hasTableContent = computed(() => const hasScoreContent = computed(() => 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() ) ) @@ -458,7 +432,8 @@ function normalizeActiveStep(value) { async function setCurrentStep(step) { const targetStep = normalizeActiveStep(step); - if (targetStep > form.value.activeStep && form.value.activeStep === 1 && !(await persist("IN_PROGRESS"))) return; + if (targetStep > form.value.activeStep && [1, 2].includes(form.value.activeStep) && !(await persist("IN_PROGRESS"))) return; + if (form.value.activeStep === 1 && targetStep > 1) syncScoreRows(); form.value.activeStep = targetStep; } @@ -478,11 +453,12 @@ 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: normalizeRows(parsed.rows), - scoreRows: normalizeScoreRows(parsed.scoreRows), + rows, + scoreRows: normalizeScoreRows(parsed.scoreRows, rows), priorityDecision: normalizePriorityDecision(parsed.priorityDecision), activeStep: normalizeActiveStep(parsed.activeStep), }; @@ -504,6 +480,7 @@ function addConversionRow() { } 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() { @@ -512,6 +489,7 @@ function removeConversionRow() { return; } form.value.rows.pop(); + syncScoreRows(); } async function ensureConversionValid() { @@ -530,15 +508,53 @@ async function ensureConversionValid() { } } -function normalizeScoreRows(rows = []) { +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().map((row, index) => ({ + 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 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; + } +} + function normalizePriorityDecision(value = {}) { return { ...createPriorityDecision(), @@ -573,6 +589,7 @@ function buildAnswerPayload(status) { 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; saving.value = true; try { await saveStudentTrainingAnswer(TASK_KEY, buildAnswerPayload(status)); @@ -1658,6 +1675,106 @@ function exportOutcome() { 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); + } +} + +.rice-formula { + margin: 12px 0 0 !important; + color: #ff9a8d; + font-size: 13px; + font-weight: 800; +} + @media (max-width: 1100px) { .feature-conversion-page { grid-template-columns: 1fr;