feat: refine product value positioning step one

fix/non-blocking-route-navigation
chenyuan 2 months ago
parent ec33c97b93
commit e6e0707b59

@ -423,3 +423,27 @@ export function checkConsumerBehaviorStepFour(payload) {
headers: { repeatSubmit: false },
});
}
export function checkProductValuePositioningStepOne(payload) {
const rows = Array.isArray(payload?.rows) ? payload.rows : [];
const types = ["核心痛点1", "核心痛点2", "核心痛点3", "弱需求", "伪需求"];
if (isStudentDemo()) {
const valid = rows.length === types.length && rows.every((row, index) => row?.id === index + 1
&& row?.type === types[index]
&& Boolean(String(row?.description || "").trim())
&& Boolean(String(row?.scene || "").trim()));
return Promise.resolve({
code: 200,
data: {
valid,
message: valid ? "核心痛点分类填写完整。" : "请完成 3 个核心痛点、弱需求和伪需求的描述与场景。",
},
});
}
return request({
url: "/api/student-training-answers/product-value-positioning/step-1/validate",
method: "post",
data: { rows },
headers: { repeatSubmit: false },
});
}

@ -48,20 +48,31 @@
<h3>{{ activeStepName }}</h3>
</div>
</div>
<div class="step-content">
<label class="field-block">
<span>收集并分析当前智能健身镜市场的主要品牌、产品特点、用户评价:</span>
<textarea v-model="form.research.marketAnalysis" />
</label>
<label class="field-block">
<span>开展初步访谈或问卷调研,梳理潜在用户对智能健身设备的需求与痛点:</span>
<textarea v-model="form.research.userResearch" />
</label>
<label class="field-block">
<span>整理用户画像,明确目标用户群体特征:</span>
<textarea v-model="form.research.persona" />
</label>
<div class="core-pain-intro">
<p>价值主张的起点不是产品功能,而是用户痛点。不要试图满足所有人,只抓占 80% 的核心用户最痛的 3 个问题。</p>
<p>参考实训 11 的需求优先级评估结果,整理出 Top3 核心痛点,同时识别弱需求和伪需求——它们不作为核心价值主张,只作为加分项。</p>
</div>
<div class="core-pain-table-wrap">
<table class="core-pain-table">
<thead>
<tr>
<th>序号</th>
<th>需求类型</th>
<th>需求描述</th>
<th>场景描述</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in form.corePainRows" :key="row.id">
<td>{{ index + 1 }}</td>
<td class="pain-type-cell">{{ row.type }}</td>
<td><textarea v-model="row.description" :placeholder="index === 0 ? '示例:入睡困难' : '填写需求描述'" :aria-label="`${row.type}需求描述`" /></td>
<td><textarea v-model="row.scene" :placeholder="index === 0 ? '示例:高压职场人群睡前大脑兴奋难以切换到放松状态,传统吸顶灯只有冷白光切换,缺乏渐进式助眠的光环境引导,导致入睡时间平均超过40分钟' : '填写场景描述'" :aria-label="`${row.type}场景描述`" /></td>
</tr>
</tbody>
</table>
</div>
<p v-if="corePainValidationMessage" class="core-pain-validation-message" role="status">{{ corePainValidationMessage }}</p>
</section>
<section v-show="isSectionVisible(2)" class="step-card">
@ -202,14 +213,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 { getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
import { checkProductValuePositioningStepOne, getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
import { parseTrainingArray } from "@/views/training/taskKeyMap";
const TASK_KEY = "product-value-positioning";
const { proxy } = getCurrentInstance();
const saving = ref(false);
const checkingCorePain = ref(false);
const corePainValidationMessage = ref("");
const taskConfig = ref(null);
const corePainTypes = ["核心痛点1", "核心痛点2", "核心痛点3", "弱需求", "伪需求"];
const defaultRequirement =
"产品价值主张及定位需要在市场调研和用户洞察基础上,明确目标用户、核心价值、差异化优势和定位表达。";
const defaultBackground =
@ -224,6 +239,7 @@ const defaultGoals = [
function createForm() {
return {
activeStep: 1,
corePainRows: corePainTypes.map((type, index) => ({ id: index + 1, type, description: "", scene: "" })),
research: { marketAnalysis: "", userResearch: "", persona: "" },
positionCanvas: { targetUsers: "", needsAndPains: "", competitorOpportunity: "" },
valueCanvas: { customerJobs: "", pains: "", gains: "", products: "", painRelievers: "", gainCreators: "" },
@ -277,20 +293,22 @@ function normalizeActiveStep(value) {
return Math.min(Math.max(step, 1), configuredSteps.value.length);
}
function setCurrentStep(step) {
form.value.activeStep = normalizeActiveStep(step);
async function setCurrentStep(step) {
const targetStep = normalizeActiveStep(step);
if (targetStep > form.value.activeStep && form.value.activeStep === 1 && !(await ensureCorePainValid())) return;
form.value.activeStep = targetStep;
}
function isSectionVisible(section) {
return !hasConfiguredSteps.value || form.value.activeStep === section;
}
function goPreviousStep() {
setCurrentStep(form.value.activeStep - 1);
async function goPreviousStep() {
await setCurrentStep(form.value.activeStep - 1);
}
function goNextStep() {
setCurrentStep(form.value.activeStep + 1);
async function goNextStep() {
await setCurrentStep(form.value.activeStep + 1);
}
function applySavedAnswer(value) {
@ -309,6 +327,7 @@ function normalizeForm(value = {}) {
...base,
...value,
activeStep: normalizeActiveStep(value.activeStep),
corePainRows: normalizeCorePainRows(value.corePainRows),
research: { ...base.research, ...(value.research || {}) },
positionCanvas: { ...base.positionCanvas, ...(value.positionCanvas || {}) },
valueCanvas: { ...base.valueCanvas, ...(value.valueCanvas || {}) },
@ -316,6 +335,32 @@ function normalizeForm(value = {}) {
};
}
function normalizeCorePainRows(rows = []) {
const savedRows = Array.isArray(rows) ? rows : [];
return corePainTypes.map((type, index) => ({
id: index + 1,
type,
description: String(savedRows[index]?.description || ""),
scene: String(savedRows[index]?.scene || ""),
}));
}
async function ensureCorePainValid() {
if (checkingCorePain.value) return false;
checkingCorePain.value = true;
try {
const response = await checkProductValuePositioningStepOne({ rows: form.value.corePainRows });
const result = response?.data || {};
corePainValidationMessage.value = result.message || (result.valid ? "核心痛点分类填写完整。" : "请完成 3 个核心痛点、弱需求和伪需求的描述与场景。");
return result.valid === true;
} catch (error) {
corePainValidationMessage.value = error?.message || "核心痛点校验失败,请稍后重试。";
return false;
} finally {
checkingCorePain.value = false;
}
}
function buildOutcome() {
return {
taskKey: TASK_KEY,
@ -323,6 +368,7 @@ function buildOutcome() {
taskName: taskTitle.value,
currentStep: form.value.activeStep,
configuredSteps: configuredSteps.value,
corePainRows: form.value.corePainRows,
research: form.value.research,
positionCanvas: form.value.positionCanvas,
valueCanvas: form.value.valueCanvas,
@ -341,10 +387,12 @@ function buildAnswerPayload(status) {
async function persist(status = "IN_PROGRESS") {
if (saving.value) return;
if (status !== "RESET" && form.value.activeStep === 1 && !(await ensureCorePainValid())) return false;
saving.value = true;
try {
await saveStudentTrainingAnswer(TASK_KEY, buildAnswerPayload(status));
proxy?.$modal?.msgSuccess(status === "SUBMITTED" ? "提交成功" : "保存成功");
return true;
} finally {
saving.value = false;
}
@ -723,6 +771,97 @@ function exportOutcome() {
.value-grid .field-block { margin: 0; }
.core-pain-intro {
margin-bottom: 18px;
padding: 16px 18px;
border-left: 3px solid #55ddff;
border-radius: 0 14px 14px 0;
background: linear-gradient(90deg, rgba(7, 92, 132, 0.28), rgba(5, 34, 51, 0.12));
p {
margin: 0;
color: #cae7f2;
font-size: 14px;
line-height: 1.8;
}
p + p { margin-top: 6px; }
}
.core-pain-table-wrap { overflow-x: auto; }
.core-pain-table {
width: 100%;
min-width: 820px;
border-collapse: collapse;
table-layout: fixed;
th,
td {
border: 1px solid rgba(77, 167, 220, 0.28);
text-align: center;
vertical-align: middle;
}
th {
padding: 11px 10px;
color: #f5fcff;
background: linear-gradient(100deg, #0787bd, #075276);
font-size: 14px;
font-weight: 800;
}
td {
height: 84px;
padding: 9px;
color: #d7f1ff;
background: rgba(1, 14, 26, 0.48);
}
th:nth-child(1),
td:nth-child(1) { width: 7%; }
th:nth-child(2),
td:nth-child(2) { width: 15%; }
th:nth-child(3),
td:nth-child(3) { width: 32%; }
.pain-type-cell {
color: #b9e7f6;
font-weight: 800;
background: rgba(7, 62, 88, 0.46);
}
textarea {
display: block;
width: 100%;
height: 76px;
min-height: 52px;
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);
box-sizing: border-box;
font: inherit;
font-size: 13px;
line-height: 1.55;
resize: vertical;
&:focus {
border-color: rgba(92, 224, 255, 0.7);
box-shadow: 0 0 0 3px rgba(0, 174, 255, 0.13);
}
}
}
.core-pain-validation-message {
margin: 12px 0 0;
color: #ffab9d;
font-size: 13px;
font-weight: 800;
}
.step-navigation {
display: flex;
justify-content: center;

Loading…
Cancel
Save