|
|
|
|
@ -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;
|
|
|
|
|
|