feat: add feature conversion priority table

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

@ -483,3 +483,29 @@ export function checkFeatureConversionStepTwo(payload) {
headers: { repeatSubmit: false },
});
}
export function checkFeatureConversionStepThree(payload) {
const scoreRows = Array.isArray(payload?.scoreRows) ? payload.scoreRows : [];
const rows = Array.isArray(payload?.rows) ? payload.rows : [];
const priorities = ["P0", "P1", "P2", "P3"];
if (isStudentDemo()) {
const valid = scoreRows.length >= 8 && scoreRows.length <= 12 && rows.length === scoreRows.length
&& rows.every((row, index) => row?.name === scoreRows[index]?.name
&& Boolean(String(scoreRows[index]?.name || "").trim())
&& priorities.includes(row?.priority)
&& Boolean(String(row?.reason || "").trim()));
return Promise.resolve({
code: 200,
data: {
valid,
message: valid ? "功能优先级清单填写完整。" : "请为步骤二中的每项功能选择 P0-P3 优先级并填写划分理由。",
},
});
}
return request({
url: "/api/student-training-answers/feature-conversion/step-3/validate",
method: "post",
data: { scoreRows, rows },
headers: { repeatSubmit: false },
});
}

@ -106,7 +106,7 @@
<thead>
<tr>
<th>需求ID</th>
<th>需求名称</th>
<th>功能名称</th>
<th>KANO属性</th>
<th>Reach覆盖范围</th>
<th>Impact影响程度</th>
@ -147,29 +147,34 @@
<h3>{{ activeStepName }}</h3>
</div>
</div>
<p class="decision-intro">
假设项目首期 BOM 成本控制在 300 元以内研发周期 4 个月根据上一步骤完成的需求打分表填写以下内容
</p>
<label class="decision-field">
<span>P0首期必做 - 生存线不做这些产品无法上市或会被立即退货</span>
<textarea v-model="form.priorityDecision.p0" aria-label="P0首期必做"></textarea>
</label>
<label class="decision-field">
<span>P1首期核心 - 竞争力在成本允许下最大化期望属性提升性价比感知</span>
<textarea v-model="form.priorityDecision.p1" aria-label="P1首期核心"></textarea>
</label>
<label class="decision-field">
<span>P2二期迭代 - 差异化虽然可以提升用户体验但是成本较高或者增加合规风险的</span>
<textarea v-model="form.priorityDecision.p2" aria-label="P2二期迭代"></textarea>
</label>
<label class="decision-field">
<span>P3暂缓/砍掉需求伪命题偏离核心场景</span>
<textarea v-model="form.priorityDecision.p3" aria-label="P3暂缓砍掉"></textarea>
</label>
<p class="decision-intro">基于步骤二的 RICE 评分结果输出 P0-P3 四级功能清单并说明每一等级的划分理由</p>
<div class="conversion-table-wrap">
<table class="priority-decision-table">
<thead>
<tr>
<th>功能名称</th>
<th>优先级</th>
<th>理由</th>
</tr>
</thead>
<tbody>
<tr v-for="row in form.priorityRows" :key="row.uid">
<td class="auto-score-cell auto-score-cell--name">{{ row.name || "请先完成步骤二 RICE 评分" }}</td>
<td>
<select v-model="row.priority" :aria-label="`${row.name || row.uid} 优先级`">
<option value="">请选择</option>
<option v-for="level in priorityLevels" :key="level.value" :value="level.value">{{ level.value }}</option>
</select>
</td>
<td><textarea v-model="row.reason" :aria-label="`${row.name || row.uid} 划分理由`" placeholder="结合 RICE 评分、KANO 属性和开发投入说明划分理由"></textarea></td>
</tr>
</tbody>
</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 v-show="isSectionVisible(4)" class="step-card conversion-content completion-content">
@ -225,12 +230,18 @@ 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, checkFeatureConversionStepTwo, getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
import { checkFeatureConversionStepOne, checkFeatureConversionStepTwo, checkFeatureConversionStepThree, getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
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 defaultRequirement =
@ -299,8 +310,10 @@ const taskConfig = ref(null);
const saving = ref(false);
const checkingConversion = ref(false);
const checkingScore = ref(false);
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) => ({
@ -326,6 +339,15 @@ function createScoreRows(sourceRows = createRows()) {
}));
}
function createPriorityRows(sourceRows = createScoreRows()) {
return sourceRows.map((sourceRow, index) => ({
uid: index + 1,
name: sourceRow.name || "",
priority: "",
reason: "",
}));
}
function createPriorityDecision() {
return {
p0: "",
@ -342,6 +364,7 @@ function createForm() {
productName: "",
rows,
scoreRows: createScoreRows(rows),
priorityRows: createPriorityRows(createScoreRows(rows)),
priorityDecision: createPriorityDecision(),
};
}
@ -378,7 +401,7 @@ const hasScoreContent = 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 () => {
@ -432,8 +455,9 @@ function normalizeActiveStep(value) {
async function setCurrentStep(step) {
const targetStep = normalizeActiveStep(step);
if (targetStep > form.value.activeStep && [1, 2].includes(form.value.activeStep) && !(await persist("IN_PROGRESS"))) return;
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;
}
@ -459,6 +483,7 @@ function applySavedAnswer(value) {
...parsed,
rows,
scoreRows: normalizeScoreRows(parsed.scoreRows, rows),
priorityRows: normalizePriorityRows(parsed.priorityRows, normalizeScoreRows(parsed.scoreRows, rows)),
priorityDecision: normalizePriorityDecision(parsed.priorityDecision),
activeStep: normalizeActiveStep(parsed.activeStep),
};
@ -527,6 +552,20 @@ 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 : [];
return createPriorityRows(sourceRows).map((row, index) => ({
...row,
...(savedRows[index] || {}),
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);
@ -555,6 +594,25 @@ async function ensureScoreValid() {
}
}
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 = {}) {
return {
...createPriorityDecision(),
@ -572,6 +630,7 @@ function buildOutcome() {
activeStep: form.value.activeStep,
rows: form.value.rows,
scoreRows: form.value.scoreRows,
priorityRows: form.value.priorityRows,
priorityDecision: form.value.priorityDecision,
updatedAt: new Date().toISOString(),
};
@ -590,6 +649,7 @@ 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;
if (status !== "RESET" && form.value.activeStep === 3 && !(await ensurePriorityValid())) return false;
saving.value = true;
try {
await saveStudentTrainingAnswer(TASK_KEY, buildAnswerPayload(status));
@ -1768,6 +1828,104 @@ function exportOutcome() {
}
}
.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;

Loading…
Cancel
Save