diff --git a/src/api/studentTrainingAnswer.js b/src/api/studentTrainingAnswer.js
index 81238b7..b3cf55b 100644
--- a/src/api/studentTrainingAnswer.js
+++ b/src/api/studentTrainingAnswer.js
@@ -473,3 +473,22 @@ export function checkProductValuePositioningStepTwo(payload) {
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 },
+ });
+}
diff --git a/src/views/product/differentiation.vue b/src/views/product/differentiation.vue
index feb1ece..c0afae2 100644
--- a/src/views/product/differentiation.vue
+++ b/src/views/product/differentiation.vue
@@ -149,32 +149,22 @@
-
-
-
-
-
-
+
+
根据前面实训分析,使用经典定位陈述格式撰写简洁有力的产品价值定位陈述。
+
经典定位陈述格式:对于【目标市场/用户】,我们的【产品名称】是一个【产品类别】,它能提供【核心价值/关键收益】,因为它具有【主要差异化理由】。
+
+ 版本{{ numberToChinese(index + 1) }}至少设计两个不同目标用户版本
+ 对于 {{ statement.targetUser || '【目标市场/用户】' }},我们的 {{ statement.productName || '【产品名称】' }} 是一个 {{ statement.productCategory || '【产品类别】' }},它能提供 {{ statement.coreValue || '【核心价值/关键收益】' }},因为它具有 {{ statement.differentiationReason || '【主要差异化理由】' }}。
+
+
+
+
+
+
+
+
+
{{ positioningValidationMessage }}
@@ -257,7 +247,7 @@ 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 { 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 { isStudentDemo } from "@/utils/studentDemo";
@@ -268,11 +258,18 @@ const checkingCorePain = ref(false);
const corePainValidationMessage = ref("");
const checkingValueCanvas = ref(false);
const valueCanvasValidationMessage = ref("");
+const checkingPositioning = ref(false);
+const positioningValidationMessage = ref("");
const uploadingValueCanvas = ref(false);
const valueCanvasInput = ref(null);
const taskConfig = ref(null);
const corePainTypes = ["核心痛点1", "核心痛点2", "核心痛点3", "弱需求", "伪需求"];
+const positioningStatementFields = ["targetUser", "productName", "productCategory", "coreValue", "differentiationReason"];
+
+function createPositioningStatement(id) {
+ return { id, targetUser: "", productName: "", productCategory: "", coreValue: "", differentiationReason: "" };
+}
const defaultRequirement =
"产品价值主张及定位需要在市场调研和用户洞察基础上,明确目标用户、核心价值、差异化优势和定位表达。";
@@ -294,6 +291,7 @@ function createForm() {
customerProfile: { jobs: "", pains: "", gains: "" },
valueMap: { products: "", painRelievers: "", gainCreators: "" },
valuePropositionCanvas: { url: "", name: "" },
+ positioningStatements: [createPositioningStatement(1), createPositioningStatement(2)],
valueCanvas: { customerJobs: "", pains: "", gains: "", products: "", painRelievers: "", gainCreators: "" },
statement: { targetUsers: "", valueProposition: "", differentiation: "", positioning: "" },
};
@@ -350,6 +348,7 @@ async function setCurrentStep(step) {
if (targetStep > form.value.activeStep) {
if (form.value.activeStep === 1 && !(await ensureCorePainValid())) return;
if (form.value.activeStep === 2 && !(await ensureValueCanvasValid())) return;
+ if (form.value.activeStep === 3 && !(await ensurePositioningValid())) return;
}
form.value.activeStep = targetStep;
}
@@ -388,6 +387,7 @@ function normalizeForm(value = {}) {
customerProfile: { ...base.customerProfile, ...(value.customerProfile || {}) },
valueMap: { ...base.valueMap, ...(value.valueMap || {}) },
valuePropositionCanvas: { ...base.valuePropositionCanvas, ...(value.valuePropositionCanvas || {}) },
+ positioningStatements: normalizePositioningStatements(value.positioningStatements),
valueCanvas: { ...base.valueCanvas, ...(value.valueCanvas || {}) },
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() {
if (checkingCorePain.value) return false;
checkingCorePain.value = true;
@@ -437,6 +445,20 @@ async function ensureValueCanvasValid() {
} 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() {
return {
taskKey: TASK_KEY,
@@ -450,6 +472,7 @@ function buildOutcome() {
customerProfile: form.value.customerProfile,
valueMap: form.value.valueMap,
valuePropositionCanvas: form.value.valuePropositionCanvas,
+ positioningStatements: form.value.positioningStatements,
valueCanvas: form.value.valueCanvas,
statement: form.value.statement,
updatedAt: new Date().toISOString(),
@@ -469,6 +492,7 @@ async function persist(status = "IN_PROGRESS") {
if (status !== "RESET") {
if (form.value.activeStep === 1 && !(await ensureCorePainValid())) return false;
if (form.value.activeStep === 2 && !(await ensureValueCanvasValid())) return false;
+ if (form.value.activeStep === 3 && !(await ensurePositioningValid())) return false;
}
saving.value = true;
try {
@@ -504,6 +528,8 @@ function exportOutcome() {
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 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; }
}
+.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 {
display: flex;
justify-content: center;
@@ -1192,6 +1279,8 @@ async function handleValueCanvasUpload(event) {
writing-mode: vertical-rl;
}
.value-grid { grid-template-columns: 1fr; }
+ .positioning-fields { grid-template-columns: 1fr; }
+ .positioning-fields__wide { grid-column: auto; }
.canvas-guidance { padding: 15px; }
.canvas-guidance .template-download-button { position: static; margin: 8px 0; }
}