You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
dianshang-qianduan/src/views/product/differentiation.vue

1915 lines
66 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<div class="product-positioning-page student-training-shell">
<main class="product-positioning-core">
<section class="task-header">
<div class="task-head-main">
<h1 class="task-title">{{ taskTitle }}</h1>
<p class="task-brief">
<span class="task-brief-text">{{ taskBackground }}</span>
<button type="button" class="task-brief-toggle" @click="briefOpen = !briefOpen">
{{ briefOpen ? "收起" : "展开目标与要求" }}
</button>
<span v-if="briefOpen" class="task-brief-more">
<b>实训目标</b>
<ul>
<li v-for="goal in taskGoals" :key="goal">{{ goal }}</li>
</ul>
<b>实训要求</b>
<p>{{ taskRequirement }}</p>
</span>
</p>
</div>
</section>
<section class="task-action-strip">
<TrainingTaskToolbar :task-key="TASK_KEY" :on-submit="submitTask" :on-reset="resetTraining"
:on-export="exportOutcome" />
</section>
<section class="task-card">
<nav v-if="configuredSteps.length" class="step-indicator" aria-label="产品价值主张及定位步骤">
<template v-for="(step, index) in configuredSteps" :key="`${step}-${index}`">
<button type="button"
:class="['step-tab', { active: form.activeStep === index + 1, complete: form.activeStep > index + 1 }]"
:aria-current="form.activeStep === index + 1 ? 'step' : undefined"
:aria-label="`第${index + 1}步:${step}`" @click="setCurrentStep(index + 1)">
<span class="step-marker">{{ String(index + 1).padStart(2, '0') }}</span>
<span class="step-copy">
<strong class="step-name" :title="step">{{ step }}</strong>
<span class="step-action">{{ form.activeStep === index + 1 ? "当前步骤" : "点击切换" }} <span
aria-hidden="true">→</span></span>
</span>
</button>
<span v-if="index < configuredSteps.length - 1" class="step-connector"
aria-hidden="true"></span>
</template>
</nav>
<section v-show="isSectionVisible(1)" class="step-card">
<div v-if="activeStepName" class="step-title-row">
<div>
<p class="section-eyebrow">Step {{ String(form.activeStep).padStart(2, '0') }}</p>
<h3>{{ activeStepName }}</h3>
</div>
</div>
<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">
<div v-if="activeStepName" class="step-title-row">
<div>
<p class="section-eyebrow">Step {{ String(form.activeStep).padStart(2, '0') }}</p>
<h3>{{ activeStepName }}</h3>
</div>
</div>
<div class="value-proposition-intro">
根据细分用户、具体场景、独特价值和用户收益提炼核心价值主张,运用价值主张画布从“用户档案”和“产品价值地图”两个维度找到匹配点。
</div>
<div class="canvas-section-heading">
<div class="section-heading"><span>01</span>
<div>
<h3>用户档案 <small>Customer Profile</small></h3>
</div>
</div>
<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">
<div class="section-heading"><span>02</span>
<div>
<h3>产品价值地图 <small>Value Map</small></h3>
</div>
</div>
<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">
<div class="section-heading"><span>03</span>
<div>
<h3>绘制产品价值主张画布</h3>
<p>检查“产品价值地图”与“用户档案”是否实现匹配(Fit):每个痛点应有对应的痛点缓释器,每个收益期望应有对应的增益创造器。根据上表绘制产品价值主张画布。</p>
</div>
</div>
<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>
<p v-if="valueCanvasValidationMessage" class="core-pain-validation-message" role="status">{{
valueCanvasValidationMessage }}</p>
</section>
<section v-show="isSectionVisible(3)" class="step-card">
<div v-if="activeStepName" class="step-title-row">
<div>
<p class="section-eyebrow">Step {{ String(form.activeStep).padStart(2, '0') }}</p>
<h3>{{ activeStepName }}</h3>
</div>
</div>
<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">
<div v-if="activeStepName" class="step-title-row">
<div>
<p class="section-eyebrow">Step {{ String(form.activeStep).padStart(2, '0') }}</p>
<h3>{{ activeStepName }}</h3>
</div>
</div>
<div class="mrd-intro">
基于前述步骤的分析成果,撰写一份完整的产品市场需求说明书(MRD)。MRD
文档全面分析产品的市场机会、用户需求及竞争格局,明确产品核心定位与功能方向,为后续产品开发、资源投入及战略决策提供数据支撑与逻辑依据。
</div>
<section v-for="section in mrdSections" :key="section.key" class="mrd-section">
<div class="section-heading"><span>{{ chineseNumerals[section.order - 1] }}</span>
<div>
<h3>{{ section.title }}</h3>
</div>
</div>
<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">
<div class="section-heading"><span>06</span>
<div>
<h3>产品定位</h3>
</div>
</div>
<textarea v-model="form.mrd.productPositioning" aria-label="产品定位"
placeholder="综合市场、用户、竞品和需求分析,填写产品定位" />
</section>
<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">
<div class="step-title-row">
<div>
<p class="section-eyebrow">Step {{ String(form.activeStep).padStart(2, '0') }}</p>
<h3>{{ activeStepName }}</h3>
</div>
</div>
<p class="step-desc">请检查已填写的调研、定位画布、价值主张和定位陈述,再保存或提交实训成果。</p>
</section>
<div v-if="hasConfiguredSteps" class="step-navigation">
<div class="step-nav-left">
<button v-if="form.activeStep > 1" type="button" class="btn-nav btn-ghost"
@click="goPreviousStep">
<el-icon>
<ArrowLeft />
</el-icon>
上一步
</button>
<button type="button" class="btn-nav btn-save" :disabled="saving" @click="saveCurrentProgress">
<el-icon>
<CircleCheck />
</el-icon>
保存
</button>
</div>
<div class="step-nav-right">
<span class="step-progress">Step {{ String(form.activeStep).padStart(2, "0") }} / {{
String(configuredSteps.length).padStart(2, "0") }}</span>
<button v-if="form.activeStep < configuredSteps.length" type="button"
class="btn-nav btn-primary" :disabled="saving" @click="goNextStep">
下一步
<el-icon>
<ArrowRight />
</el-icon>
</button>
</div>
</div>
</section>
</main>
<TrainingAiSidebar study-tip="当前任务建议:先明确目标人群和核心痛点,再把定位画布、价值主张画布和定位陈述串成一条完整逻辑。" reference-title="定位分析参考"
reference-text="· 市场调研:竞品、用户评价、需求痛点<br />· 定位画布:用户、场景、差异化机会<br />· 价值主张:客户任务、痛点、收益与解决方案<br />· 定位陈述:目标用户 + 核心价值 + 差异化理由"
:progress-items="progressItems" />
</div>
</template>
<script setup>
import { computed, getCurrentInstance, onMounted, ref } from "vue"
import { ArrowLeft, ArrowRight, CircleCheck, Download, Operation, UploadFilled } from "@element-plus/icons-vue"
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"
import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue"
import { getTrainingTaskByKey } from "@/api/trainingTask"
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: "兼容性" }] },
]
const chineseNumerals = ["01", "02", "03", "04", "05", "06"]
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 =
"某智能家居公司计划推出一款面向家庭用户的 AI 智能健身镜,希望通过该产品进入家庭健身市场。作为产品经理,需要基于市场调研与用户洞察完成系统化定位。"
const defaultGoals = [
"理解产品定位的概念、作用与核心要素。",
"掌握定位画布与价值主张画布的系统化工具。",
"运用产品定位方法完成一次 AI 智能健身镜的定位设计。",
"撰写清晰、有力的产品定位陈述。",
]
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: "" },
}
}
const form = ref(createForm())
const taskTitle = computed(() => taskConfig.value?.taskName || "产品价值主张及定位")
const briefOpen = ref(true)
const taskBackground = computed(() => taskConfig.value?.background || defaultBackground)
const taskRequirement = computed(() => taskConfig.value?.requirements || defaultRequirement)
const taskGoals = computed(() => parseTrainingArray(taskConfig.value?.objectives, defaultGoals))
const configuredSteps = computed(() => parseTrainingArray(taskConfig.value?.steps))
const hasConfiguredSteps = computed(() => configuredSteps.value.length > 0)
const activeStepName = computed(() => configuredSteps.value[form.value.activeStep - 1] || "")
const progressItems = computed(() =>
configuredSteps.value.map((step, index) => ({
text: step,
status: index + 1 < form.value.activeStep ? "done" : index + 1 === form.value.activeStep ? "doing" : "",
}))
)
onMounted(async () => {
await loadTaskConfig()
await loadSavedAnswer()
})
async function loadTaskConfig() {
try {
const res = await getTrainingTaskByKey(TASK_KEY)
taskConfig.value = res?.data || null
form.value.activeStep = normalizeActiveStep(form.value.activeStep)
} catch (error) {
taskConfig.value = null
form.value.activeStep = 1
}
}
async function loadSavedAnswer() {
try {
const res = await getStudentTrainingAnswer(TASK_KEY)
if (res?.data?.step1Answer) applySavedAnswer(res.data.step1Answer)
} catch (error) {
// Keep the local form when no historical answer is available.
}
}
function normalizeActiveStep(value) {
if (!configuredSteps.value.length) return 1
const step = Number(value) || 1
return Math.min(Math.max(step, 1), configuredSteps.value.length)
}
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
}
async function goPreviousStep() {
await setCurrentStep(form.value.activeStep - 1)
}
async function goNextStep() {
await setCurrentStep(form.value.activeStep + 1)
}
function applySavedAnswer(value) {
try {
const parsed = JSON.parse(value)
if (!parsed || typeof parsed !== "object") return
form.value = normalizeForm(parsed)
} catch (error) {
// Ignore malformed historical answers.
}
}
function normalizeForm(value = {}) {
const base = createForm()
return {
...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,
taskId: taskConfig.value?.id || "",
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(),
}
}
function buildAnswerPayload(status) {
return {
saveAction: status === "SUBMITTED" ? "SUBMIT" : status === "RESET" ? "RESET" : "SAVE",
currentStep: form.value.activeStep,
step1Answer: JSON.stringify(buildOutcome()),
}
}
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
}
}
function saveCurrentProgress() {
return persist("IN_PROGRESS")
}
function submitTask() {
return persist("SUBMITTED")
}
async function resetTraining() {
clearValueCanvas()
form.value = createForm()
await persist("RESET")
}
function exportOutcome() {
const blob = new Blob([JSON.stringify(buildOutcome(), null, 2)], { type: "application/json;charset=utf-8" })
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = `${taskTitle.value}-实训成果.json`
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 downloadBuiltInFile(url, fileName) {
const link = document.createElement("a")
link.href = url
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
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">
/* ===== 页面级令牌(教育蓝极简主题) ===== */
.product-positioning-page {
--student-accent: #2563eb;
--student-accent-deep: #1d4ed8;
--student-accent-light: #3b82f6;
--student-accent-soft: #eff6ff;
--student-danger: #ef4444;
--student-success: #2ba471;
--student-layer-1: #1d4ed8;
--student-layer-2: #3b82f6;
--student-layer-3: #93c5fd;
--student-panel-border: #e5e7eb;
--student-card-bg: #ffffff;
--student-panel-bg: #fafbfc;
--student-text: #1f2937;
--student-text-secondary: #6b7280;
--el-color-primary: #2563eb;
--el-color-primary-light-3: #60a5fa;
--el-color-primary-light-5: #93c5fd;
--el-color-primary-light-7: #bfdbfe;
--el-color-primary-light-8: #dbeafe;
--el-color-primary-light-9: #eff6ff;
--el-color-primary-dark-2: #1d4ed8;
--el-input-hover-border-color: #3b82f6;
--el-input-focus-border-color: #2563eb;
--el-input-focus-ring-color: rgba(59, 130, 246, 0.15);
--el-textarea-hover-border-color: #3b82f6;
--el-textarea-focus-border-color: #2563eb;
/* 字号梯度统一继承 .student-training-shell,页面不再重复声明 */
position: relative;
z-index: 2;
display: grid;
grid-template-columns: minmax(720px, 1fr) 340px;
gap: 24px;
width: 100%;
max-width: 100%;
min-height: calc(100vh - 106px);
margin: 0;
padding: 24px 24px 32px;
color: var(--student-text);
background: #ffffff;
}
/* 核心容器:透明,对齐 .industry-core / .analysis-core */
.product-positioning-core {
display: grid;
gap: 10px;
align-content: start;
min-width: 0;
border: 0;
background: transparent;
box-shadow: none;
}
/* 任务头:独立卡片 + 左侧教育蓝竖条 */
.task-header {
position: relative;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin: 0;
padding: 18px 20px 18px 24px;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: var(--student-card-bg);
overflow: hidden;
&::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 4px;
background: var(--student-accent);
}
}
.task-head-main {
display: grid;
justify-items: start;
gap: 8px;
min-width: 0;
flex: 1 1 auto;
}
.task-title {
margin: 4px 0 0;
color: var(--student-text);
font-size: var(--student-fs-display);
font-weight: 700;
line-height: 1.35;
}
.task-brief {
margin: 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
line-height: 1.6;
}
.task-brief-text {
display: block;
}
.task-brief-toggle {
padding: 0;
border: 0;
color: var(--student-accent-deep);
background: transparent;
font: inherit;
font-weight: 700;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.task-brief-more {
display: grid;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--student-panel-border);
b {
color: var(--student-text);
font-size: var(--student-fs-body-sm);
}
ul {
display: grid;
gap: 4px;
margin: 0;
padding-left: 18px;
}
p {
margin: 0;
}
}
/* task-action-strip:TrainingTaskToolbar 横条 */
.task-action-strip {
margin: 0;
padding: 10px 16px;
}
.task-action-strip :deep(.training-task-toolbar) {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
/* task-card / step-card 层:透明容器 */
.task-card,
.step-card {
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
display: grid;
gap: 10px;
}
/* 顶部任务工具栏(保留 DOM 结构) */
.workbench-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
margin-bottom: 18px;
padding-bottom: 0;
border-bottom: 0;
}
.workbench-head--actions-only {
justify-content: flex-end;
margin-bottom: 18px;
padding-bottom: 0;
border-bottom: 0;
}
.workbench-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
margin-left: auto;
gap: 10px;
flex-shrink: 0;
}
.outline-action {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 36px;
min-height: 36px;
padding: 0 14px;
border: 1px solid rgba(37, 99, 235, 0.35);
border-radius: 8px;
color: var(--student-accent);
background: #ffffff;
font-size: var(--student-fs-body);
font-weight: 700;
cursor: pointer;
text-shadow: none;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease;
}
.outline-action:hover {
border-color: var(--student-accent);
background: var(--student-accent-soft);
color: var(--student-accent);
}
/* step-title-row + section-eyebrow */
.step-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.step-title-row h3 {
margin: 4px 0 0;
color: var(--student-text);
font-size: var(--student-fs-h1);
font-weight: 700;
line-height: 1.35;
}
.section-eyebrow {
margin: 0 0 6px;
color: var(--student-text-secondary);
font-weight: 700;
font-size: var(--student-fs-caption);
letter-spacing: 0.12em;
text-transform: uppercase;
}
.step-desc {
margin: 14px 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
line-height: 1.6;
}
/* ===== 引导区(intro) ===== */
.core-pain-intro,
.value-proposition-intro,
.positioning-intro,
.mrd-intro {
margin: 14px 0 12px;
padding: 12px 14px;
border-left: 3px solid var(--student-accent);
background: var(--student-accent-soft);
border-radius: 0 8px 8px 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body);
line-height: 1.6;
}
.core-pain-intro p,
.positioning-intro p {
margin: 0 0 6px;
color: var(--student-text-secondary);
}
.core-pain-intro p+p,
.positioning-intro p+p {
margin-top: 6px;
}
/* ===== 表格 ===== */
.core-pain-table-wrap,
.value-canvas-table-wrap,
.mrd-table-wrap {
overflow-x: auto;
}
.core-pain-table,
.value-canvas-table,
.mrd-table {
width: 100%;
border-collapse: collapse;
}
.core-pain-table {
min-width: 820px;
table-layout: fixed;
}
.value-canvas-table {
min-width: 820px;
table-layout: fixed;
}
.mrd-table {
min-width: 760px;
table-layout: fixed;
}
.core-pain-table th,
.core-pain-table td,
.value-canvas-table th,
.value-canvas-table td,
.mrd-table th,
.mrd-table td {
border: 1px solid var(--student-panel-border);
vertical-align: middle;
}
.core-pain-table th,
.value-canvas-table th,
.mrd-table th {
padding: 10px 12px;
color: var(--student-text);
background: #fafbfc;
font-size: var(--student-fs-body-sm);
font-weight: 800;
text-align: left;
}
.core-pain-table td,
.value-canvas-table td,
.mrd-table td {
padding: 8px 10px;
color: var(--student-text);
background: transparent;
font-size: var(--student-fs-body-sm);
line-height: 1.55;
}
.core-pain-table th:nth-child(1),
.core-pain-table td:nth-child(1) {
width: 7%;
text-align: center;
}
.core-pain-table th:nth-child(2),
.core-pain-table td:nth-child(2) {
width: 15%;
text-align: center;
font-weight: 700;
}
.core-pain-table th:nth-child(3),
.core-pain-table td:nth-child(3) {
width: 32%;
}
.pain-type-cell {
color: var(--student-text);
font-weight: 700;
background: transparent;
}
.value-canvas-table th:nth-child(1),
.value-canvas-table td:nth-child(1) {
width: 19%;
text-align: center;
font-weight: 700;
}
.value-canvas-table th:nth-child(2),
.value-canvas-table td:nth-child(2) {
width: 24%;
}
.value-canvas-table td:nth-child(2) {
color: var(--student-text-secondary);
}
.mrd-table th:nth-child(1),
.mrd-table td:nth-child(1) {
width: 8%;
text-align: center;
}
.mrd-table th:nth-child(2),
.mrd-table td:nth-child(2) {
width: 17%;
text-align: center;
}
.mrd-row-title {
color: var(--student-text);
font-weight: 700;
}
.mrd-module {
color: var(--student-text-secondary);
}
/* 表格内 textarea:透明底 */
.core-pain-table textarea,
.value-canvas-table textarea,
.mrd-table textarea,
.mrd-positioning-section textarea {
box-sizing: border-box;
display: block;
width: 100%;
min-height: 72px;
resize: vertical;
border: 0;
padding: 8px 10px;
color: var(--student-text);
background: transparent;
font: inherit;
font-size: var(--student-fs-body);
line-height: 1.55;
}
.core-pain-table textarea:focus,
.value-canvas-table textarea:focus,
.mrd-table textarea:focus,
.mrd-positioning-section textarea:focus {
outline: 2px solid rgba(59, 130, 246, 0.15);
outline-offset: -2px;
}
/* ===== 画布区 ===== */
.canvas-section-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin: 18px 0 10px;
.section-heading {
flex: 1 1 auto;
min-width: 0;
margin-bottom: 0;
}
}
.canvas-section-heading--spaced {
margin-top: 28px;
}
.canvas-section-heading small {
margin-left: 6px;
color: var(--student-text-secondary);
font-size: var(--student-fs-caption);
font-weight: 600;
}
/* 导出 / 上传按钮:白底灰边(对齐 .outline-action 风格) */
.canvas-export-button,
.canvas-upload-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
border: 1px solid rgba(37, 99, 235, 0.35);
border-radius: 8px;
color: var(--student-accent);
background: #ffffff;
cursor: pointer;
font: inherit;
font-weight: 700;
text-shadow: none;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease;
}
.canvas-export-button {
min-width: 92px;
min-height: 36px;
height: 36px;
padding: 0 14px;
font-size: var(--student-fs-body-sm);
}
.canvas-upload-trigger {
min-width: 218px;
min-height: 36px;
height: 36px;
padding: 0 14px;
font-size: var(--student-fs-body);
}
.canvas-export-button:hover,
.canvas-upload-trigger:hover:not(:disabled) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
color: var(--student-accent);
}
.canvas-upload-trigger:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.canvas-drawing-section {
margin-top: 28px;
padding: 18px;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: #ffffff;
box-shadow: 0 1px 3px rgba(37, 99, 235, 0.06);
text-align: center;
}
.canvas-drawing-section .section-heading {
margin-bottom: 18px;
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 var(--student-panel-border);
border-radius: 10px;
cursor: zoom-in;
}
div {
display: grid;
gap: 5px;
}
strong {
color: var(--student-text);
font-size: var(--student-fs-body);
font-weight: 700;
}
span {
color: var(--student-text-secondary);
font-size: var(--student-fs-caption);
}
}
.text-button {
width: fit-content;
padding: 0;
border: 0;
color: var(--student-accent);
background: transparent;
cursor: pointer;
font: inherit;
font-size: var(--student-fs-caption);
text-decoration: underline;
}
/* ===== 定位陈述卡片 ===== */
.positioning-version-card {
overflow: hidden;
margin-top: 16px;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: #ffffff;
box-shadow: 0 1px 3px rgba(37, 99, 235, 0.06);
transition: border-color 0.16s ease, box-shadow 0.16s ease;
}
.positioning-version-card:hover {
border-color: var(--student-accent);
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.08);
}
.positioning-version-card header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 10px 16px;
border-bottom: 1px solid var(--student-panel-border);
background: var(--student-accent-soft);
}
.positioning-version-card header span {
color: var(--student-text);
font-size: var(--student-fs-h2);
font-weight: 700;
}
.positioning-version-card header small {
color: var(--student-text-secondary);
font-size: var(--student-fs-caption);
}
.statement-preview {
margin: 0;
padding: 15px 16px;
color: var(--student-text-secondary);
background: #ffffff;
font-size: var(--student-fs-body);
line-height: 1.6;
}
.statement-preview strong {
color: var(--student-text);
font-weight: 700;
}
.positioning-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
padding: 16px;
}
.positioning-fields label {
display: grid;
gap: 6px;
min-width: 0;
}
.positioning-fields label span {
color: var(--student-text-secondary);
font-size: var(--student-fs-caption);
font-weight: 700;
}
.positioning-fields input,
.positioning-fields textarea {
width: 100%;
padding: 10px 11px;
border: 1px solid var(--student-panel-border);
border-radius: 8px;
color: var(--student-text);
background: #ffffff;
box-sizing: border-box;
font: inherit;
font-size: var(--student-fs-body);
line-height: 1.55;
}
.positioning-fields input:focus,
.positioning-fields textarea:focus {
outline: 2px solid rgba(59, 130, 246, 0.15);
outline-offset: -2px;
}
.positioning-fields textarea {
min-height: 66px;
resize: vertical;
}
.positioning-fields__wide {
grid-column: 1 / -1;
}
/* ===== MRD 区 ===== */
.mrd-section {
margin-top: 24px;
}
.mrd-positioning-section {
margin-top: 25px;
padding: 16px;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: #ffffff;
box-shadow: 0 1px 3px rgba(37, 99, 235, 0.06);
}
.mrd-positioning-section textarea {
min-height: 120px;
border: 1px solid var(--student-panel-border);
border-radius: 8px;
background: #ffffff;
}
/* ===== 消息提示 ===== */
.core-pain-validation-message {
margin: 12px 0 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
font-weight: 600;
}
.core-pain-validation-message.success {
color: var(--student-success);
}
/* ============================================================
* 底部导航:工具栏式
* ============================================================ */
.step-navigation {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 16px;
padding: 12px 16px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: #ffffff;
}
.step-nav-left,
.step-nav-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
}
.step-nav-right {
margin-left: auto;
}
.btn-nav {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid rgba(37, 99, 235, 0.35);
border-radius: 8px;
color: var(--student-accent);
background: #ffffff;
font-family: inherit;
font-size: var(--student-fs-button);
font-weight: 600;
cursor: pointer;
transition: all 0.16s ease;
white-space: nowrap;
.el-icon {
font-size: 18px;
}
&:hover:not(:disabled) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12);
}
}
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid var(--student-accent);
border-radius: 8px;
color: #ffffff;
background: var(--student-accent);
font-family: inherit;
font-size: var(--student-fs-button);
font-weight: 600;
box-shadow: 0 1px 2px rgba(37, 99, 235, 0.2), 0 2px 8px rgba(37, 99, 235, 0.15);
cursor: pointer;
transition: all 0.16s ease;
white-space: nowrap;
.el-icon {
font-size: 18px;
}
&:hover:not(:disabled) {
border-color: var(--student-accent-deep);
background: var(--student-accent-deep);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(29, 78, 216, 0.3), 0 4px 12px rgba(29, 78, 216, 0.22);
}
&:active:not(:disabled) {
transform: translateY(0);
}
}
.btn-ghost {
border-color: rgba(37, 99, 235, 0.35);
color: var(--student-accent);
background: #ffffff;
&:hover:not(:disabled) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
}
}
.btn-save {
border-color: #e5e7eb;
color: #4b5563;
background: #f9fafb;
&:hover:not(:disabled) {
border-color: #d1d5db;
background: #f3f4f6;
transform: none;
box-shadow: none;
}
}
.btn-primary:disabled,
.btn-ghost:disabled,
.btn-save:disabled,
.btn-nav:disabled {
cursor: not-allowed;
opacity: 0.5;
transform: none;
box-shadow: none;
}
.step-progress {
color: var(--student-text-secondary);
font-family: var(--student-font-data);
font-size: var(--student-fs-caption);
font-weight: 700;
letter-spacing: 0.05em;
white-space: nowrap;
}
@media (max-width: 1100px) {
.step-nav-right {
margin-left: 0;
}
}
@media (max-width: 900px) {
.step-navigation {
flex-direction: column;
align-items: stretch;
}
.step-nav-left,
.step-nav-right {
justify-content: center;
}
.btn-nav,
.btn-primary {
width: 100%;
}
}
/* ===== 响应式 ===== */
@media (max-width: 840px) {
.positioning-fields {
grid-template-columns: 1fr;
}
.positioning-fields__wide {
grid-column: auto;
}
}
@media (max-width: 720px) {
.task-header {
flex-direction: column;
align-items: flex-start;
}
.workbench-head,
.workbench-actions {
flex-direction: column;
align-items: stretch;
}
.workbench-actions,
.outline-action {
width: 100%;
}
}
/* ===== 步骤内小节标题(蓝底序号,与 diagnosis.vue 统一) ===== */
.section-heading {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 10px;
>span {
display: grid;
flex: 0 0 28px;
width: 28px;
height: 28px;
place-items: center;
border-radius: 6px;
color: #ffffff;
background: var(--student-accent, #2563eb);
font-size: var(--student-fs-body, 14px);
font-weight: 700;
}
h3 {
margin: 0;
color: var(--student-text, #1f2937);
font-size: var(--student-fs-h1, 18px);
font-weight: 700;
}
p {
margin: 4px 0 0;
color: var(--student-text-secondary, #6b7280);
font-size: var(--student-fs-body, 14px);
line-height: 1.6;
}
}
</style>
<style lang="scss">
/* ============================================================
* 步骤导航:分段连接胶囊(教育蓝主题)
* ============================================================ */
#app .studentLayout section.app-main:has(.product-positioning-page) .step-indicator {
display: flex !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: center !important;
gap: 0 !important;
width: 100% !important;
margin: 0 0 20px !important;
padding: 0 !important;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
.step-connector {
display: none !important;
}
.step-tab {
min-width: 0 !important;
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
height: auto !important;
min-height: 40px !important;
margin: 0 !important;
padding: 8px 14px !important;
border: 1px solid #e5e7eb !important;
border-right: none !important;
border-radius: 0 !important;
background: #ffffff !important;
box-shadow: none !important;
color: #6b7280 !important;
font-size: var(--student-fs-body) !important;
font-weight: 600 !important;
line-height: 1.3 !important;
letter-spacing: 0 !important;
white-space: nowrap !important;
text-align: center !important;
cursor: pointer;
transition: all 0.16s ease !important;
&:first-child {
border-radius: 8px 0 0 8px !important;
}
&:last-child {
border-radius: 0 8px 8px 0 !important;
border-right: 1px solid #e5e7eb !important;
}
.step-marker {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
min-width: 22px !important;
height: 22px !important;
padding: 0 5px !important;
border-radius: 6px !important;
background: #f3f4f6 !important;
color: #6b7280 !important;
font-family: var(--student-font-data), ui-monospace, "SF Mono", Consolas, monospace !important;
font-size: var(--student-fs-body-sm) !important;
font-weight: 700 !important;
line-height: 1 !important;
transition: all 0.16s ease !important;
}
.step-copy {
display: flex !important;
flex-direction: column !important;
gap: 0 !important;
min-width: 0 !important;
}
.step-name {
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
line-height: 1.3 !important;
color: inherit !important;
}
.step-action {
display: none !important;
}
&:hover:not(.active):not(.complete) {
border-color: #2563eb !important;
background: #eff6ff !important;
color: #2563eb !important;
z-index: 1 !important;
.step-marker {
background: #2563eb !important;
color: #ffffff !important;
}
}
&.complete:not(.active) {
border-color: #e5e7eb !important;
color: #374151 !important;
.step-marker {
background: rgba(43, 164, 113, 0.12) !important;
color: #16915f !important;
}
&:hover {
border-color: #2ba471 !important;
background: #f0fdf6 !important;
z-index: 1 !important;
.step-marker {
background: #2ba471 !important;
color: #ffffff !important;
}
}
}
&.active {
border-color: #2563eb !important;
background: #2563eb !important;
color: #ffffff !important;
font-weight: 700 !important;
z-index: 2 !important;
.step-marker {
background: #ffffff !important;
color: #2563eb !important;
}
&:hover {
background: #1d4ed8 !important;
border-color: #1d4ed8 !important;
}
}
}
}
/* ===== 窄屏:横向滑动 ===== */
@media (max-width: 900px) {
#app .studentLayout section.app-main:has(.product-positioning-page) .step-indicator {
justify-content: flex-start !important;
overflow-x: auto !important;
}
#app .studentLayout section.app-main:has(.product-positioning-page) .step-indicator .step-tab {
flex: 0 0 140px !important;
max-width: none !important;
}
}
/* ============================================================
* 顶部工具栏:TrainingMaterialButton 资料按钮 → 教育蓝
* ============================================================ */
.product-positioning-page .workbench-actions .material-button.el-button {
height: 34px !important;
min-height: 34px !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
border-radius: 8px !important;
color: #2563eb !important;
background: #ffffff !important;
font-size: var(--student-fs-body) !important;
font-weight: 600 !important;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease !important;
&:hover {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12) !important;
}
&.is-plain {
border-color: #e5e7eb !important;
color: #9ca3af !important;
&:hover {
border-color: #e5e7eb !important;
color: #9ca3af !important;
background: #ffffff !important;
box-shadow: none !important;
}
}
}
/* ============================================================
* 顶部导出按钮(原生 <button.outline-action>)
* ============================================================ */
#app .studentLayout section.app-main:has(.product-positioning-page) .workbench-actions>button.outline-action {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 6px !important;
height: 34px !important;
min-height: 34px !important;
padding: 0 14px !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
border-radius: 8px !important;
color: #2563eb !important;
background: #ffffff !important;
font-size: var(--student-fs-body) !important;
font-weight: 600 !important;
line-height: 1 !important;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease !important;
}
#app .studentLayout section.app-main:has(.product-positioning-page) .workbench-actions>button.outline-action:hover {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12) !important;
transform: none !important;
}
</style>