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