feat: add value positioning statements

dev-QQq
chenyuan 3 weeks ago
parent 8f1b6ecdfb
commit db8f07c0ee

@ -473,3 +473,22 @@ export function checkProductValuePositioningStepTwo(payload) {
headers: { repeatSubmit: false }, headers: { repeatSubmit: false },
}); });
} }
export function checkProductValuePositioningStepThree(payload) {
const statements = Array.isArray(payload?.statements) ? payload.statements : [];
const requiredFields = ["targetUser", "productName", "productCategory", "coreValue", "differentiationReason"];
if (isStudentDemo()) {
const valid = statements.length === 2 && statements.every((statement, index) => statement?.id === index + 1
&& requiredFields.every((field) => Boolean(String(statement?.[field] || "").trim())));
return Promise.resolve({
code: 200,
data: { valid, message: valid ? "两版定位陈述填写完整。" : "请完整填写两个产品价值定位陈述版本。" },
});
}
return request({
url: "/api/student-training-answers/product-value-positioning/step-3/validate",
method: "post",
data: { statements },
headers: { repeatSubmit: false },
});
}

@ -149,32 +149,22 @@
<h3>{{ activeStepName }}</h3> <h3>{{ activeStepName }}</h3>
</div> </div>
</div> </div>
<div class="step-content value-grid"> <div class="positioning-intro">
<label class="field-block"> <p>根据前面实训分析使用经典定位陈述格式撰写简洁有力的产品价值定位陈述</p>
<span>客户任务</span> <p>经典定位陈述格式对于目标市场/用户我们的产品名称是一个产品类别它能提供核心价值/关键收益因为它具有主要差异化理由</p>
<textarea v-model="form.valueCanvas.customerJobs" />
</label>
<label class="field-block">
<span>用户痛点</span>
<textarea v-model="form.valueCanvas.pains" />
</label>
<label class="field-block">
<span>用户收益</span>
<textarea v-model="form.valueCanvas.gains" />
</label>
<label class="field-block">
<span>产品与服务</span>
<textarea v-model="form.valueCanvas.products" />
</label>
<label class="field-block">
<span>痛点缓解方案</span>
<textarea v-model="form.valueCanvas.painRelievers" />
</label>
<label class="field-block">
<span>收益创造方案</span>
<textarea v-model="form.valueCanvas.gainCreators" />
</label>
</div> </div>
<article v-for="(statement, index) in form.positioningStatements" :key="statement.id" class="positioning-version-card">
<header><span>版本{{ numberToChinese(index + 1) }}</span><small>至少设计两个不同目标用户版本</small></header>
<p class="statement-preview">对于 <strong>{{ statement.targetUser || '【目标市场/用户】' }}</strong>我们的 <strong>{{ statement.productName || '【产品名称】' }}</strong> 是一个 <strong>{{ statement.productCategory || '【产品类别】' }}</strong>它能提供 <strong>{{ statement.coreValue || '【核心价值/关键收益】' }}</strong>因为它具有 <strong>{{ statement.differentiationReason || '【主要差异化理由】' }}</strong></p>
<div class="positioning-fields">
<label><span>目标市场/用户</span><input v-model="statement.targetUser" :aria-label="`${index + 1}`" placeholder="填写目标市场/用户" /></label>
<label><span>产品名称</span><input v-model="statement.productName" :aria-label="`${index + 1}`" placeholder="填写产品名称" /></label>
<label><span>产品类别</span><input v-model="statement.productCategory" :aria-label="`${index + 1}`" placeholder="填写产品类别" /></label>
<label><span>核心价值/关键收益</span><input v-model="statement.coreValue" :aria-label="`${index + 1}`" placeholder="填写核心价值/关键收益" /></label>
<label class="positioning-fields__wide"><span>主要差异化理由</span><textarea v-model="statement.differentiationReason" :aria-label="`${index + 1}`" placeholder="填写主要差异化理由" /></label>
</div>
</article>
<p v-if="positioningValidationMessage" class="core-pain-validation-message" role="status">{{ positioningValidationMessage }}</p>
</section> </section>
<section v-show="isSectionVisible(4)" class="step-card"> <section v-show="isSectionVisible(4)" class="step-card">
@ -257,7 +247,7 @@ import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue"; import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue"; import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
import { getTrainingTaskByKey } from "@/api/trainingTask"; import { getTrainingTaskByKey } from "@/api/trainingTask";
import { checkProductValuePositioningStepOne, checkProductValuePositioningStepTwo, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer"; import { checkProductValuePositioningStepOne, checkProductValuePositioningStepTwo, checkProductValuePositioningStepThree, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
import { parseTrainingArray } from "@/views/training/taskKeyMap"; import { parseTrainingArray } from "@/views/training/taskKeyMap";
import { isStudentDemo } from "@/utils/studentDemo"; import { isStudentDemo } from "@/utils/studentDemo";
@ -268,11 +258,18 @@ const checkingCorePain = ref(false);
const corePainValidationMessage = ref(""); const corePainValidationMessage = ref("");
const checkingValueCanvas = ref(false); const checkingValueCanvas = ref(false);
const valueCanvasValidationMessage = ref(""); const valueCanvasValidationMessage = ref("");
const checkingPositioning = ref(false);
const positioningValidationMessage = ref("");
const uploadingValueCanvas = ref(false); const uploadingValueCanvas = ref(false);
const valueCanvasInput = ref(null); const valueCanvasInput = ref(null);
const taskConfig = ref(null); const taskConfig = ref(null);
const corePainTypes = ["核心痛点1", "核心痛点2", "核心痛点3", "弱需求", "伪需求"]; const corePainTypes = ["核心痛点1", "核心痛点2", "核心痛点3", "弱需求", "伪需求"];
const positioningStatementFields = ["targetUser", "productName", "productCategory", "coreValue", "differentiationReason"];
function createPositioningStatement(id) {
return { id, targetUser: "", productName: "", productCategory: "", coreValue: "", differentiationReason: "" };
}
const defaultRequirement = const defaultRequirement =
"产品价值主张及定位需要在市场调研和用户洞察基础上,明确目标用户、核心价值、差异化优势和定位表达。"; "产品价值主张及定位需要在市场调研和用户洞察基础上,明确目标用户、核心价值、差异化优势和定位表达。";
@ -294,6 +291,7 @@ function createForm() {
customerProfile: { jobs: "", pains: "", gains: "" }, customerProfile: { jobs: "", pains: "", gains: "" },
valueMap: { products: "", painRelievers: "", gainCreators: "" }, valueMap: { products: "", painRelievers: "", gainCreators: "" },
valuePropositionCanvas: { url: "", name: "" }, valuePropositionCanvas: { url: "", name: "" },
positioningStatements: [createPositioningStatement(1), createPositioningStatement(2)],
valueCanvas: { customerJobs: "", pains: "", gains: "", products: "", painRelievers: "", gainCreators: "" }, valueCanvas: { customerJobs: "", pains: "", gains: "", products: "", painRelievers: "", gainCreators: "" },
statement: { targetUsers: "", valueProposition: "", differentiation: "", positioning: "" }, statement: { targetUsers: "", valueProposition: "", differentiation: "", positioning: "" },
}; };
@ -350,6 +348,7 @@ async function setCurrentStep(step) {
if (targetStep > form.value.activeStep) { if (targetStep > form.value.activeStep) {
if (form.value.activeStep === 1 && !(await ensureCorePainValid())) return; if (form.value.activeStep === 1 && !(await ensureCorePainValid())) return;
if (form.value.activeStep === 2 && !(await ensureValueCanvasValid())) return; if (form.value.activeStep === 2 && !(await ensureValueCanvasValid())) return;
if (form.value.activeStep === 3 && !(await ensurePositioningValid())) return;
} }
form.value.activeStep = targetStep; form.value.activeStep = targetStep;
} }
@ -388,6 +387,7 @@ function normalizeForm(value = {}) {
customerProfile: { ...base.customerProfile, ...(value.customerProfile || {}) }, customerProfile: { ...base.customerProfile, ...(value.customerProfile || {}) },
valueMap: { ...base.valueMap, ...(value.valueMap || {}) }, valueMap: { ...base.valueMap, ...(value.valueMap || {}) },
valuePropositionCanvas: { ...base.valuePropositionCanvas, ...(value.valuePropositionCanvas || {}) }, valuePropositionCanvas: { ...base.valuePropositionCanvas, ...(value.valuePropositionCanvas || {}) },
positioningStatements: normalizePositioningStatements(value.positioningStatements),
valueCanvas: { ...base.valueCanvas, ...(value.valueCanvas || {}) }, valueCanvas: { ...base.valueCanvas, ...(value.valueCanvas || {}) },
statement: { ...base.statement, ...(value.statement || {}) }, statement: { ...base.statement, ...(value.statement || {}) },
}; };
@ -403,6 +403,14 @@ function normalizeCorePainRows(rows = []) {
})); }));
} }
function normalizePositioningStatements(statements = []) {
const savedStatements = Array.isArray(statements) ? statements : [];
return [1, 2].map((id, index) => {
const saved = savedStatements[index] || {};
return positioningStatementFields.reduce((result, field) => ({ ...result, [field]: String(saved[field] || "") }), { id });
});
}
async function ensureCorePainValid() { async function ensureCorePainValid() {
if (checkingCorePain.value) return false; if (checkingCorePain.value) return false;
checkingCorePain.value = true; checkingCorePain.value = true;
@ -437,6 +445,20 @@ async function ensureValueCanvasValid() {
} finally { checkingValueCanvas.value = false; } } finally { checkingValueCanvas.value = false; }
} }
async function ensurePositioningValid() {
if (checkingPositioning.value) return false;
checkingPositioning.value = true;
try {
const response = await checkProductValuePositioningStepThree({ statements: form.value.positioningStatements });
const result = response?.data || {};
positioningValidationMessage.value = result.message || (result.valid ? "两版定位陈述填写完整。" : "请完整填写两个产品价值定位陈述版本。");
return result.valid === true;
} catch (error) {
positioningValidationMessage.value = error?.message || "定位陈述校验失败,请稍后重试。";
return false;
} finally { checkingPositioning.value = false; }
}
function buildOutcome() { function buildOutcome() {
return { return {
taskKey: TASK_KEY, taskKey: TASK_KEY,
@ -450,6 +472,7 @@ function buildOutcome() {
customerProfile: form.value.customerProfile, customerProfile: form.value.customerProfile,
valueMap: form.value.valueMap, valueMap: form.value.valueMap,
valuePropositionCanvas: form.value.valuePropositionCanvas, valuePropositionCanvas: form.value.valuePropositionCanvas,
positioningStatements: form.value.positioningStatements,
valueCanvas: form.value.valueCanvas, valueCanvas: form.value.valueCanvas,
statement: form.value.statement, statement: form.value.statement,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
@ -469,6 +492,7 @@ async function persist(status = "IN_PROGRESS") {
if (status !== "RESET") { if (status !== "RESET") {
if (form.value.activeStep === 1 && !(await ensureCorePainValid())) return false; if (form.value.activeStep === 1 && !(await ensureCorePainValid())) return false;
if (form.value.activeStep === 2 && !(await ensureValueCanvasValid())) return false; if (form.value.activeStep === 2 && !(await ensureValueCanvasValid())) return false;
if (form.value.activeStep === 3 && !(await ensurePositioningValid())) return false;
} }
saving.value = true; saving.value = true;
try { try {
@ -504,6 +528,8 @@ function exportOutcome() {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
function numberToChinese(number) { return ["一", "二", "三", "四"][number - 1] || String(number); }
const customerProfileExportContent = computed(() => `# 用户档案Customer Profile\n\n## 用户目标Jobs\n${form.value.customerProfile.jobs}\n\n## 痛点Pains\n${form.value.customerProfile.pains}\n\n## 收益期望Gains\n${form.value.customerProfile.gains}\n`); const customerProfileExportContent = computed(() => `# 用户档案Customer Profile\n\n## 用户目标Jobs\n${form.value.customerProfile.jobs}\n\n## 痛点Pains\n${form.value.customerProfile.pains}\n\n## 收益期望Gains\n${form.value.customerProfile.gains}\n`);
const valueMapExportContent = computed(() => `# 产品价值地图Value Map\n\n## 产品与服务\n${form.value.valueMap.products}\n\n## 痛点缓释器Pain Relievers\n${form.value.valueMap.painRelievers}\n\n## 增益创造器Gain Creators\n${form.value.valueMap.gainCreators}\n`); const valueMapExportContent = computed(() => `# 产品价值地图Value Map\n\n## 产品与服务\n${form.value.valueMap.products}\n\n## 痛点缓释器Pain Relievers\n${form.value.valueMap.painRelievers}\n\n## 增益创造器Gain Creators\n${form.value.valueMap.gainCreators}\n`);
@ -1112,6 +1138,67 @@ async function handleValueCanvasUpload(event) {
.template-download-button { position: absolute; top: 44px; left: 18px; } .template-download-button { position: absolute; top: 44px; left: 18px; }
} }
.positioning-intro {
margin-bottom: 18px;
padding: 15px 18px;
border-left: 3px solid #55ddff;
border-radius: 0 14px 14px 0;
background: linear-gradient(90deg, rgba(7, 92, 132, .28), rgba(5, 34, 51, .12));
p { margin: 0; color: #c9e7f3; font-size: 14px; line-height: 1.8; }
p + p { margin-top: 5px; color: #a9ccda; }
}
.positioning-version-card {
overflow: hidden;
margin-top: 16px;
border: 1px solid rgba(74, 178, 223, .36);
border-radius: 16px;
background: linear-gradient(120deg, rgba(5, 55, 81, .42), rgba(1, 17, 29, .5));
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 10px 16px;
border-bottom: 1px solid rgba(75, 174, 218, .24);
background: rgba(4, 86, 125, .35);
}
header span { color: #f2fdff; font-size: 16px; font-weight: 900; }
header small { color: #8ecadd; font-size: 12px; }
}
.statement-preview {
margin: 0;
padding: 15px 16px;
color: #afd2df;
background: rgba(0, 16, 29, .36);
font-size: 14px;
line-height: 1.85;
strong { color: #64daf8; font-weight: 800; }
}
.positioning-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
padding: 16px;
label { display: grid; gap: 6px; min-width: 0; }
label span { color: #d8f2fb; font-size: 12px; font-weight: 800; }
input,
textarea {
width: 100%; padding: 10px 11px; border: 1px solid rgba(45, 95, 126, .95); border-radius: 9px; color: #eef5ff; background: rgba(4, 24, 38, .72); box-sizing: border-box; font: inherit; font-size: 13px; line-height: 1.55;
&:focus { border-color: rgba(92, 224, 255, .7); box-shadow: 0 0 0 3px rgba(0, 174, 255, .13); outline: none; }
}
textarea { min-height: 66px; resize: vertical; }
}
.positioning-fields__wide { grid-column: 1 / -1; }
.step-navigation { .step-navigation {
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -1192,6 +1279,8 @@ async function handleValueCanvasUpload(event) {
writing-mode: vertical-rl; writing-mode: vertical-rl;
} }
.value-grid { grid-template-columns: 1fr; } .value-grid { grid-template-columns: 1fr; }
.positioning-fields { grid-template-columns: 1fr; }
.positioning-fields__wide { grid-column: auto; }
.canvas-guidance { padding: 15px; } .canvas-guidance { padding: 15px; }
.canvas-guidance .template-download-button { position: static; margin: 8px 0; } .canvas-guidance .template-download-button { position: static; margin: 8px 0; }
} }

Loading…
Cancel
Save