feat: add feature conversion rice scoring

dev-QQq
chenyuan 3 weeks ago
parent 8f65477052
commit d66e1af14b

@ -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 },
});
}

@ -97,74 +97,47 @@
<h3>{{ activeStepName }}</h3>
</div>
</div>
<p>
对于智能硬件建议采用RICE 模型Reach 覆盖范围Impact 影响程度Confidence 置信度Effort 投入成本进行量化打分但必须加一个前置过滤器合规与安全并使用KANO模型辅助决策
</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 class="rice-intro">
<p>将步骤一的功能纳入 RICE 评估模型进行量化打分RICE 模型包含覆盖用户数影响程度置信度开发成本四个维度</p>
<p>请根据上一步骤需求转化后的功能定义针对具体需求进行量化打分填写以下打分表</p>
</div>
<p class="table-tip">请根据上一步骤需求转化后的功能定义针对具体需求进行量化打分填写以下打分表</p>
<div class="conversion-table-wrap">
<table class="priority-score-table">
<thead>
<tr>
<th>需求ID</th>
<th>需求名称</th>
<th>需求来源</th>
<th>KANO属性</th>
<th>Reach覆盖范围</th>
<th>Impact影响程度</th>
<th>Confidence置信度</th>
<th>Effort投入成本</th>
<th>Effort/</th>
<th>RICE得分</th>
<th>合规性检查项</th>
</tr>
</thead>
<tbody>
<tr v-for="row in form.scoreRows" :key="row.uid">
<td><textarea v-model="row.requirementId" aria-label="需求ID"></textarea></td>
<td><textarea v-model="row.name" aria-label="需求名称"></textarea></td>
<td><textarea v-model="row.source" aria-label="需求来源"></textarea></td>
<td><textarea v-model="row.kano" aria-label="KANO属性"></textarea></td>
<td><textarea v-model="row.reach" aria-label="Reach覆盖范围"></textarea></td>
<td><textarea v-model="row.impact" aria-label="Impact影响程度"></textarea></td>
<td><textarea v-model="row.confidence" aria-label="Confidence置信度"></textarea></td>
<td><textarea v-model="row.effort" aria-label="Effort投入成本"></textarea></td>
<td><textarea v-model="row.rice" aria-label="RICE得分"></textarea></td>
<td><textarea v-model="row.compliance" aria-label="合规性检查项"></textarea></td>
<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">
@ -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;

Loading…
Cancel
Save