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 模型包含覆盖用户数、影响程度、置信度、开发成本四个维度。
+
请根据上一步骤需求转化后的功能定义,针对具体需求进行量化打分,填写以下打分表。
-
-
请根据上一步骤需求转化后的功能定义,针对具体需求进行量化打分,填写以下打分表:
+
计算公式:(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;