merge: product value positioning module

dev-QQq
chenyuan 3 weeks ago
commit b1c625f255

@ -423,3 +423,91 @@ 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 },
});
}
export function checkProductValuePositioningStepTwo(payload) {
const customerProfile = payload?.customerProfile || {};
const valueMap = payload?.valueMap || {};
if (isStudentDemo()) {
const requiredValues = [
customerProfile.jobs, customerProfile.pains, customerProfile.gains,
valueMap.products, valueMap.painRelievers, valueMap.gainCreators,
payload?.canvasUrl,
];
const valid = requiredValues.every((item) => Boolean(String(item || "").trim()));
return Promise.resolve({
code: 200,
data: {
valid,
message: valid ? "价值主张画布填写完整。" : "请完整填写用户档案、产品价值地图并上传产品价值主张画布。",
},
});
}
return request({
url: "/api/student-training-answers/product-value-positioning/step-2/validate",
method: "post",
data: 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 },
});
}
export function checkProductValuePositioningStepFour(payload) {
const sectionKeys = ["marketInsights", "marketOverview", "userAnalysis", "competitorAnalysis", "productRequirements"];
if (isStudentDemo()) {
const valid = sectionKeys.every((key) => Array.isArray(payload?.[key]) && payload[key].length > 0
&& payload[key].every((row) => Boolean(String(row?.content || "").trim())))
&& Boolean(String(payload?.productPositioning || "").trim());
return Promise.resolve({
code: 200,
data: { valid, message: valid ? "MRD 文档填写完整。" : "请完整填写 MRD 的五组分析内容和产品定位。" },
});
}
return request({
url: "/api/student-training-answers/product-value-positioning/step-4/validate",
method: "post",
data: payload,
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">
@ -71,20 +82,64 @@
<h3>{{ activeStepName }}</h3>
</div>
</div>
<div class="step-content">
<label class="field-block">
<span>目标用户群体</span>
<textarea v-model="form.positionCanvas.targetUsers" />
</label>
<label class="field-block">
<span>用户核心需求与痛点</span>
<textarea v-model="form.positionCanvas.needsAndPains" />
</label>
<label class="field-block">
<span>竞品定位与差异化机会</span>
<textarea v-model="form.positionCanvas.competitorOpportunity" />
</label>
<div class="value-proposition-intro">
根据细分用户具体场景独特价值和用户收益提炼核心价值主张运用价值主张画布从用户档案产品价值地图两个维度找到匹配点
</div>
<div class="canvas-section-heading">
<h4>用户档案 <small>Customer Profile</small></h4>
<button type="button" class="canvas-export-button" @click="exportCanvasSection('用户档案', customerProfileExportContent)">
<el-icon><Download /></el-icon>
</button>
</div>
<div class="value-canvas-table-wrap">
<table class="value-canvas-table">
<thead><tr><th>维度</th><th>说明</th><th>内容</th></tr></thead>
<tbody>
<tr><td>用户目标Jobs</td><td>用户在相关场景中试图完成的任务</td><td><textarea v-model="form.customerProfile.jobs" placeholder="示例:快速入睡、提升睡眠质量、温和唤醒不犯困、起夜不刺眼" /></td></tr>
<tr><td>痛点Pains</td><td>用户遇到的障碍风险和负面体验</td><td><textarea v-model="form.customerProfile.pains" placeholder="填写用户痛点" /></td></tr>
<tr><td>收益期望Gains</td><td>用户期望的结果和额外收益</td><td><textarea v-model="form.customerProfile.gains" placeholder="填写用户收益期望" /></td></tr>
</tbody>
</table>
</div>
<div class="canvas-section-heading canvas-section-heading--spaced">
<h4>产品价值地图 <small>Value Map</small></h4>
<button type="button" class="canvas-export-button" @click="exportCanvasSection('产品价值地图', valueMapExportContent)">
<el-icon><Download /></el-icon>
</button>
</div>
<div class="value-canvas-table-wrap">
<table class="value-canvas-table">
<thead><tr><th>维度</th><th>说明</th><th>内容</th></tr></thead>
<tbody>
<tr><td>产品与服务</td><td>我们提供给用户的产品和服务清单</td><td><textarea v-model="form.valueMap.products" placeholder="示例:智能声光伴眠吸顶灯(含 App 控制)、白噪音声库、睡眠监测服务、节律光算法" /></td></tr>
<tr><td>痛点缓释器Pain Relievers</td><td>产品如何消除或缓解用户痛点</td><td><textarea v-model="form.valueMap.painRelievers" placeholder="填写痛点缓释器" /></td></tr>
<tr><td>增益创造器Gain Creators</td><td>产品如何创造额外价值和惊喜体验</td><td><textarea v-model="form.valueMap.gainCreators" placeholder="填写增益创造器" /></td></tr>
</tbody>
</table>
</div>
<div class="canvas-drawing-section">
<h4>绘制产品价值主张画布</h4>
<p>检查产品价值地图用户档案是否实现匹配Fit每个痛点应有对应的痛点缓释器每个收益期望应有对应的增益创造器根据上表绘制产品价值主张画布</p>
<input ref="valueCanvasInput" class="visually-hidden" type="file" accept="image/png,image/jpeg,image/webp" @change="handleValueCanvasUpload" />
<div v-if="form.valuePropositionCanvas.url" class="value-canvas-preview">
<el-image :src="form.valuePropositionCanvas.url" :preview-src-list="[form.valuePropositionCanvas.url]" fit="cover" preview-teleported />
<div><strong>{{ form.valuePropositionCanvas.name || '产品价值主张画布' }}</strong><span>点击缩略图可放大预览</span><button type="button" class="text-button" @click="clearValueCanvas"></button></div>
</div>
<button v-else type="button" class="canvas-upload-trigger" :disabled="uploadingValueCanvas" @click="openValueCanvasPicker">
<el-icon><UploadFilled /></el-icon>{{ uploadingValueCanvas ? '...' : '' }}
</button>
</div>
<div class="canvas-guidance">
<p>推荐工具及方法如下自行选择</p>
<p>1. 下载模板填写用户档案及产品价值地图完成产品价值主张画布</p>
<button type="button" class="template-download-button" @click="downloadCanvasTemplate"><el-icon><Download /></el-icon></button>
<p>2. 导出用户档案和价值主张地图两张表使用通用 AI 大模型辅助生成画布</p>
</div>
<p v-if="valueCanvasValidationMessage" class="core-pain-validation-message" role="status">{{ valueCanvasValidationMessage }}</p>
</section>
<section v-show="isSectionVisible(3)" class="step-card">
@ -94,32 +149,22 @@
<h3>{{ activeStepName }}</h3>
</div>
</div>
<div class="step-content value-grid">
<label class="field-block">
<span>客户任务</span>
<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 class="positioning-intro">
<p>根据前面实训分析使用经典定位陈述格式撰写简洁有力的产品价值定位陈述</p>
<p>经典定位陈述格式对于目标市场/用户我们的产品名称是一个产品类别它能提供核心价值/关键收益因为它具有主要差异化理由</p>
</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 v-show="isSectionVisible(4)" class="step-card">
@ -129,24 +174,35 @@
<h3>{{ activeStepName }}</h3>
</div>
</div>
<div class="step-content">
<label class="field-block">
<span>目标用户</span>
<textarea v-model="form.statement.targetUsers" />
</label>
<label class="field-block">
<span>核心价值主张</span>
<textarea v-model="form.statement.valueProposition" />
</label>
<label class="field-block">
<span>差异化优势</span>
<textarea v-model="form.statement.differentiation" />
</label>
<label class="field-block">
<span>产品定位陈述</span>
<textarea v-model="form.statement.positioning" />
</label>
<div class="mrd-intro">
基于前述步骤的分析成果撰写一份完整的产品市场需求说明书MRDMRD 文档全面分析产品的市场机会用户需求及竞争格局明确产品核心定位与功能方向为后续产品开发资源投入及战略决策提供数据支撑与逻辑依据
</div>
<section v-for="section in mrdSections" :key="section.key" class="mrd-section">
<h4>{{ section.order }}. {{ section.title }}</h4>
<div class="mrd-table-wrap">
<table class="mrd-table">
<thead><tr><th>序号</th><th v-if="section.hasModule"></th><th>标题</th><th>内容</th></tr></thead>
<tbody>
<tr v-for="(row, index) in form.mrd[section.key]" :key="row.id">
<td>{{ index + 1 }}</td>
<td v-if="section.hasModule" class="mrd-module">{{ row.module }}</td>
<td class="mrd-row-title">{{ row.title }}</td>
<td><textarea v-model="row.content" :aria-label="`${section.title}${row.title}内容`" :placeholder="`填写${row.title}内容`" /></td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="mrd-positioning-section">
<h4>6. 产品定位</h4>
<textarea v-model="form.mrd.productPositioning" aria-label="" placeholder="综合市场、用户、竞品和需求分析,填写产品定位" />
</section>
<div class="mrd-guidance">
<p>根据以上结构框架及信息结合实训 8-11撰写 MRD 文档</p>
<p>1. 根据报告模板自行分析撰写 MRD 文档</p>
<p>2. 使用通用 AI 大模型辅助完成报告参考步骤提示词见案例资料文档</p>
</div>
<p v-if="mrdValidationMessage" class="core-pain-validation-message" role="status">{{ mrdValidationMessage }}</p>
</section>
<section v-show="hasConfiguredSteps && form.activeStep > 4" class="step-card completion-card">
@ -197,19 +253,52 @@
<script setup>
import { computed, getCurrentInstance, onMounted, ref } from "vue";
import { ArrowLeft, ArrowRight, CircleCheck, Download, Operation } from "@element-plus/icons-vue";
import { ArrowLeft, ArrowRight, CircleCheck, Download, Operation, UploadFilled } from "@element-plus/icons-vue";
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, checkProductValuePositioningStepTwo, checkProductValuePositioningStepThree, checkProductValuePositioningStepFour, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
import { parseTrainingArray } from "@/views/training/taskKeyMap";
import { isStudentDemo } from "@/utils/studentDemo";
const TASK_KEY = "product-value-positioning";
const { proxy } = getCurrentInstance();
const saving = ref(false);
const checkingCorePain = ref(false);
const corePainValidationMessage = ref("");
const checkingValueCanvas = ref(false);
const valueCanvasValidationMessage = ref("");
const checkingPositioning = ref(false);
const positioningValidationMessage = ref("");
const checkingMrd = ref(false);
const mrdValidationMessage = 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"];
const mrdSections = [
{ key: "marketInsights", order: 1, title: "市场问题与机会", rows: [{ title: "市场痛点" }, { title: "市场机会" }, { title: "发展趋势" }] },
{ key: "marketOverview", order: 2, title: "市场概述", rows: [{ title: "目标市场" }, { title: "市场特征" }] },
{ key: "userAnalysis", order: 3, title: "用户与购买者分析", rows: [{ title: "目标用户画像" }, { title: "用户场景与需求" }, { title: "购买者与决策者" }] },
{ key: "competitorAnalysis", order: 4, title: "竞品分析", rows: [{ title: "竞品对比分析" }, { title: "差异化机会" }] },
{ key: "productRequirements", order: 5, title: "产品需求说明", hasModule: true, rows: [{ module: "功能需求", title: "核心功能" }, { module: "功能需求", title: "辅助功能" }, { module: "非功能需求", title: "性能需求" }, { module: "非功能需求", title: "安全需求" }, { module: "非功能需求", title: "兼容性" }] },
];
function createPositioningStatement(id) {
return { id, targetUser: "", productName: "", productCategory: "", coreValue: "", differentiationReason: "" };
}
function createMrd() {
const mrd = { productPositioning: "" };
mrdSections.forEach((section) => {
mrd[section.key] = section.rows.map((row, index) => ({ id: index + 1, module: row.module || "", title: row.title, content: "" }));
});
return mrd;
}
const defaultRequirement =
"产品价值主张及定位需要在市场调研和用户洞察基础上,明确目标用户、核心价值、差异化优势和定位表达。";
const defaultBackground =
@ -224,8 +313,14 @@ 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: "" },
customerProfile: { jobs: "", pains: "", gains: "" },
valueMap: { products: "", painRelievers: "", gainCreators: "" },
valuePropositionCanvas: { url: "", name: "" },
positioningStatements: [createPositioningStatement(1), createPositioningStatement(2)],
mrd: createMrd(),
valueCanvas: { customerJobs: "", pains: "", gains: "", products: "", painRelievers: "", gainCreators: "" },
statement: { targetUsers: "", valueProposition: "", differentiation: "", positioning: "" },
};
@ -277,20 +372,27 @@ 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) {
if (form.value.activeStep === 1 && !(await ensureCorePainValid())) return;
if (form.value.activeStep === 2 && !(await ensureValueCanvasValid())) return;
if (form.value.activeStep === 3 && !(await ensurePositioningValid())) return;
if (form.value.activeStep === 4 && !(await ensureMrdValid())) 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,13 +411,114 @@ function normalizeForm(value = {}) {
...base,
...value,
activeStep: normalizeActiveStep(value.activeStep),
corePainRows: normalizeCorePainRows(value.corePainRows),
research: { ...base.research, ...(value.research || {}) },
positionCanvas: { ...base.positionCanvas, ...(value.positionCanvas || {}) },
customerProfile: { ...base.customerProfile, ...(value.customerProfile || {}) },
valueMap: { ...base.valueMap, ...(value.valueMap || {}) },
valuePropositionCanvas: { ...base.valuePropositionCanvas, ...(value.valuePropositionCanvas || {}) },
positioningStatements: normalizePositioningStatements(value.positioningStatements),
mrd: normalizeMrd(value.mrd),
valueCanvas: { ...base.valueCanvas, ...(value.valueCanvas || {}) },
statement: { ...base.statement, ...(value.statement || {}) },
};
}
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 || ""),
}));
}
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 });
});
}
function normalizeMrd(mrd = {}) {
const base = createMrd();
mrdSections.forEach((section) => {
const savedRows = Array.isArray(mrd?.[section.key]) ? mrd[section.key] : [];
base[section.key] = section.rows.map((row, index) => ({
id: index + 1,
module: row.module || "",
title: row.title,
content: String(savedRows[index]?.content || ""),
}));
});
base.productPositioning = String(mrd?.productPositioning || "");
return base;
}
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;
}
}
async function ensureValueCanvasValid() {
if (checkingValueCanvas.value) return false;
checkingValueCanvas.value = true;
try {
const response = await checkProductValuePositioningStepTwo({
customerProfile: form.value.customerProfile,
valueMap: form.value.valueMap,
canvasUrl: form.value.valuePropositionCanvas.url,
});
const result = response?.data || {};
valueCanvasValidationMessage.value = result.message || (result.valid ? "价值主张画布填写完整。" : "请完整填写两张画布表并上传产品价值主张画布。");
return result.valid === true;
} catch (error) {
valueCanvasValidationMessage.value = error?.message || "价值主张画布校验失败,请稍后重试。";
return 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; }
}
async function ensureMrdValid() {
if (checkingMrd.value) return false;
checkingMrd.value = true;
try {
const response = await checkProductValuePositioningStepFour(form.value.mrd);
const result = response?.data || {};
mrdValidationMessage.value = result.message || (result.valid ? "MRD 文档填写完整。" : "请完整填写 MRD 的五组分析内容和产品定位。");
return result.valid === true;
} catch (error) {
mrdValidationMessage.value = error?.message || "MRD 校验失败,请稍后重试。";
return false;
} finally { checkingMrd.value = false; }
}
function buildOutcome() {
return {
taskKey: TASK_KEY,
@ -323,8 +526,14 @@ function buildOutcome() {
taskName: taskTitle.value,
currentStep: form.value.activeStep,
configuredSteps: configuredSteps.value,
corePainRows: form.value.corePainRows,
research: form.value.research,
positionCanvas: form.value.positionCanvas,
customerProfile: form.value.customerProfile,
valueMap: form.value.valueMap,
valuePropositionCanvas: form.value.valuePropositionCanvas,
positioningStatements: form.value.positioningStatements,
mrd: form.value.mrd,
valueCanvas: form.value.valueCanvas,
statement: form.value.statement,
updatedAt: new Date().toISOString(),
@ -341,10 +550,17 @@ function buildAnswerPayload(status) {
async function persist(status = "IN_PROGRESS") {
if (saving.value) return;
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;
if (form.value.activeStep === 4 && !(await ensureMrdValid())) return false;
}
saving.value = true;
try {
await saveStudentTrainingAnswer(TASK_KEY, buildAnswerPayload(status));
proxy?.$modal?.msgSuccess(status === "SUBMITTED" ? "提交成功" : "保存成功");
return true;
} finally {
saving.value = false;
}
@ -359,6 +575,7 @@ function submitTask() {
}
async function resetTraining() {
clearValueCanvas();
form.value = createForm();
await persist("RESET");
}
@ -372,6 +589,57 @@ function exportOutcome() {
link.click();
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`);
function downloadText(content, fileName) {
const blob = new Blob([content], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
link.click();
URL.revokeObjectURL(url);
}
function exportCanvasSection(name, content) { downloadText(content, `${taskTitle.value}-${name}.md`); }
function downloadCanvasTemplate() {
downloadText("# 产品价值主张画布\n\n## 用户档案\n- 用户目标Jobs\n- 痛点Pains\n- 收益期望Gains\n\n## 产品价值地图\n- 产品与服务:\n- 痛点缓释器Pain Relievers\n- 增益创造器Gain Creators\n\n## 匹配Fit说明\n", "产品价值主张画布模板.md");
}
function openValueCanvasPicker() { valueCanvasInput.value?.click(); }
function clearValueCanvas() {
if (form.value?.valuePropositionCanvas?.url?.startsWith("blob:")) URL.revokeObjectURL(form.value.valuePropositionCanvas.url);
if (form.value) form.value.valuePropositionCanvas = { url: "", name: "" };
}
async function handleValueCanvasUpload(event) {
const file = event.target?.files?.[0];
if (!file) return;
if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) {
proxy?.$modal?.msgError?.("请上传 PNG、JPG 或 WEBP 格式的图片"); event.target.value = ""; return;
}
if (file.size > 5 * 1024 * 1024) { proxy?.$modal?.msgError?.("图片大小不能超过 5MB"); event.target.value = ""; return; }
uploadingValueCanvas.value = true;
try {
if (isStudentDemo()) { clearValueCanvas(); form.value.valuePropositionCanvas = { url: URL.createObjectURL(file), name: file.name }; }
else {
const formData = new FormData(); formData.append("file", file);
const result = await uploadStudentTrainingFile(formData);
const url = result?.url || result?.data?.url;
if (!url) throw new Error("图片上传失败,请重试");
form.value.valuePropositionCanvas = { url, name: file.name };
}
valueCanvasValidationMessage.value = "";
proxy?.$modal?.msgSuccess?.("产品价值主张画布上传成功");
} catch (error) { proxy?.$modal?.msgError?.(error?.message || "图片上传失败,请重试"); }
finally { uploadingValueCanvas.value = false; event.target.value = ""; }
}
</script>
<style scoped lang="scss">
@ -723,6 +991,334 @@ 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;
}
.value-proposition-intro {
margin-bottom: 18px;
padding: 14px 16px;
border: 1px solid rgba(71, 183, 232, 0.28);
border-radius: 14px;
color: #c9e9f5;
background: rgba(4, 48, 71, 0.28);
font-size: 14px;
line-height: 1.8;
}
.canvas-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin: 18px 0 10px;
h4 { margin: 0; color: #ffffff; font-size: 17px; }
small { margin-left: 6px; color: #8dc8df; font-size: 12px; font-weight: 600; }
}
.canvas-section-heading--spaced { margin-top: 28px; }
.canvas-export-button,
.template-download-button,
.canvas-upload-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
border: 1px solid rgba(94, 219, 255, 0.72);
border-radius: 10px;
color: #effcff;
background: linear-gradient(105deg, #078bbf, #095c89);
cursor: pointer;
font: inherit;
font-weight: 800;
transition: .2s ease;
}
.canvas-export-button { min-width: 92px; height: 34px; padding: 0 14px; font-size: 13px; }
.template-download-button { min-width: 116px; height: 36px; padding: 0 14px; font-size: 13px; }
.canvas-upload-trigger { min-width: 218px; min-height: 54px; padding: 0 22px; font-size: 15px; }
.canvas-export-button:hover,
.template-download-button:hover,
.canvas-upload-trigger:hover:not(:disabled) {
border-color: #9bf1ff;
transform: translateY(-1px);
box-shadow: 0 7px 18px rgba(0, 158, 213, 0.2);
}
.canvas-upload-trigger:disabled { cursor: not-allowed; opacity: .65; }
.value-canvas-table-wrap { overflow-x: auto; }
.value-canvas-table {
width: 100%;
min-width: 820px;
border-collapse: collapse;
table-layout: fixed;
th,
td { border: 1px solid rgba(77, 167, 220, 0.3); }
th { padding: 10px; color: #f6fdff; background: linear-gradient(100deg, #0787bd, #075276); font-size: 14px; font-weight: 800; }
td { padding: 10px; color: #d7f1ff; background: rgba(1, 14, 26, 0.48); font-size: 13px; line-height: 1.65; vertical-align: middle; }
th:nth-child(1), td:nth-child(1) { width: 19%; color: #c8edfa; font-weight: 800; text-align: center; }
th:nth-child(2), td:nth-child(2) { width: 24%; color: #9fc4d4; }
textarea {
display: block; width: 100%; min-height: 72px; padding: 10px; 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; resize: vertical;
&:focus { border-color: rgba(92, 224, 255, .7); box-shadow: 0 0 0 3px rgba(0, 174, 255, .13); outline: none; }
}
}
.canvas-drawing-section {
margin-top: 28px;
padding: 18px;
border: 1px solid rgba(84, 185, 228, 0.32);
border-radius: 16px;
background: linear-gradient(110deg, rgba(8, 66, 94, .32), rgba(0, 19, 34, .26));
text-align: center;
h4 { margin: 0; color: #ffffff; font-size: 17px; text-align: left; }
p { margin: 9px 0 18px; color: #bfdeeb; font-size: 13px; line-height: 1.75; text-align: left; }
}
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; clip-path: inset(50%); }
.value-canvas-preview {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
margin: 0 auto;
text-align: left;
:deep(.el-image) { width: 116px; height: 76px; border: 1px solid rgba(92, 224, 255, .66); border-radius: 10px; cursor: zoom-in; }
div { display: grid; gap: 5px; }
strong { color: #eefcff; font-size: 14px; }
span { color: #8fb9c9; font-size: 12px; }
}
.text-button { width: fit-content; padding: 0; border: 0; color: #77e4ff; background: transparent; cursor: pointer; font: inherit; font-size: 12px; text-decoration: underline; }
.canvas-guidance {
position: relative;
margin-top: 22px;
padding: 15px 18px 15px 152px;
border-left: 3px solid #38bee7;
border-radius: 0 12px 12px 0;
background: rgba(3, 43, 65, .24);
p { margin: 0; color: #bedce8; font-size: 13px; line-height: 1.75; }
p + p { margin-top: 4px; }
.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; }
.mrd-intro {
margin-bottom: 20px;
padding: 15px 18px;
border: 1px solid rgba(78, 183, 227, .3);
border-radius: 14px;
color: #c9e6f1;
background: linear-gradient(100deg, rgba(5, 78, 112, .28), rgba(3, 26, 42, .18));
font-size: 14px;
line-height: 1.85;
}
.mrd-section { margin-top: 24px; }
.mrd-section h4,
.mrd-positioning-section h4 { margin: 0 0 10px; color: #ffffff; font-size: 16px; font-weight: 900; }
.mrd-table-wrap { overflow-x: auto; }
.mrd-table {
width: 100%;
min-width: 760px;
border-collapse: collapse;
table-layout: fixed;
th, td { border: 1px solid rgba(77, 167, 220, .3); }
th { padding: 9px; color: #f5fcff; background: linear-gradient(100deg, #0787bd, #075276); font-size: 13px; font-weight: 800; }
td { min-height: 42px; padding: 7px; color: #d5edf7; background: rgba(1, 14, 26, .48); font-size: 13px; text-align: center; vertical-align: middle; }
th:nth-child(1), td:nth-child(1) { width: 8%; }
th:nth-child(2), td:nth-child(2) { width: 17%; }
.mrd-row-title { color: #bbebf6; font-weight: 800; }
.mrd-module { color: #a6d7e6; }
textarea {
display: block; width: 100%; min-height: 50px; padding: 8px 10px; border: 1px solid rgba(45, 95, 126, .95); border-radius: 8px; color: #eef5ff; background: rgba(4, 24, 38, .72); box-sizing: border-box; font: inherit; font-size: 13px; line-height: 1.55; resize: vertical;
&:focus { border-color: rgba(92, 224, 255, .7); box-shadow: 0 0 0 3px rgba(0, 174, 255, .13); outline: none; }
}
}
.mrd-positioning-section {
margin-top: 25px;
padding: 16px;
border: 1px solid rgba(74, 178, 223, .34);
border-radius: 14px;
background: rgba(1, 31, 48, .35);
textarea {
display: block; width: 100%; min-height: 120px; padding: 12px; border: 1px solid rgba(45, 95, 126, .95); border-radius: 10px; color: #eef5ff; background: rgba(4, 24, 38, .72); box-sizing: border-box; font: inherit; font-size: 14px; line-height: 1.65; resize: vertical;
&:focus { border-color: rgba(92, 224, 255, .7); box-shadow: 0 0 0 3px rgba(0, 174, 255, .13); outline: none; }
}
}
.mrd-guidance {
margin-top: 20px;
padding: 13px 16px;
border-left: 3px solid #4fdbf5;
border-radius: 0 12px 12px 0;
background: rgba(3, 46, 67, .26);
p { margin: 0; color: #bfdeea; font-size: 13px; line-height: 1.75; }
p + p { margin-top: 4px; }
}
.step-navigation {
display: flex;
justify-content: center;
@ -803,6 +1399,10 @@ function exportOutcome() {
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; }
}
.workbench-head--actions-only {
justify-content: flex-end;

Loading…
Cancel
Save