diff --git a/src/api/studentTrainingAnswer.js b/src/api/studentTrainingAnswer.js index aeadb48..c39e28e 100644 --- a/src/api/studentTrainingAnswer.js +++ b/src/api/studentTrainingAnswer.js @@ -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 }, + }); +} diff --git a/src/views/demand/feature-conversion.vue b/src/views/demand/feature-conversion.vue index 43cc388..5013d93 100644 --- a/src/views/demand/feature-conversion.vue +++ b/src/views/demand/feature-conversion.vue @@ -106,7 +106,7 @@ 需求ID - 需求名称 + 功能名称 KANO属性 Reach(覆盖范围) Impact(影响程度) @@ -147,29 +147,34 @@

{{ activeStepName }}

-

- 假设项目首期 BOM 成本控制在 300 元以内,研发周期 4 个月。根据上一步骤完成的需求打分表,填写以下内容: -

- - - - - - - - +

基于步骤二的 RICE 评分结果,输出 P0-P3 四级功能清单,并说明每一等级的划分理由。

+
+ + + + + + + + + + + + + + + +
功能名称优先级理由
{{ row.name || "请先完成步骤二 RICE 评分" }} + +
+
+
+

{{ level.value }}{{ level.description }}

+
+

{{ priorityValidationMessage }}

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