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/demand/consumer-scenario.vue

2066 lines
68 KiB
Vue

<template>
<div class="consumer-scenario-page student-training-shell">
<main class="consumer-scenario-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="consumer-scenario-workbench">
<nav v-if="trainingSteps.length" class="step-indicator" aria-label="消费场景解构步骤">
<template v-for="(step, index) in trainingSteps" :key="`${index}-${step}`">
<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 < trainingSteps.length - 1" class="step-connector" aria-hidden="true"></span>
</template>
</nav>
<section class="step-card consumer-scenario-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>
<section v-show="isSectionVisible(1)" class="scenario-step-section">
<div class="journey-sort-head">
<div class="section-heading"><span>01</span>
<div>
<h3>将购买旅程卡片按正确顺序排列</h3>
<p>从需求触发到购买后的使用反馈,共五个阶段。拖动卡片,或使用卡片右侧的上下移动按钮调整顺序。</p>
</div>
</div>
<button type="button" class="journey-check-action" :disabled="checkingJourney"
@click="checkJourneyOrder">
{{ checkingJourney ? "正在校验…" : "检查排序" }}
</button>
</div>
<div class="journey-sort-list" role="list" aria-label="消费者购买旅程排序">
<template v-for="(stage, index) in orderedJourneyStages" :key="stage.id">
<article class="journey-sort-card" :class="{ dragging: draggedStageId === stage.id }"
draggable="true" role="listitem" @dragstart="startJourneyDrag(stage.id)"
@dragover.prevent @drop="dropJourneyStage(stage.id)" @dragend="endJourneyDrag">
<span class="journey-card-order">{{ String(index + 1).padStart(2, "0") }}</span>
<div class="journey-card-copy">
<strong>{{ stage.title }}</strong>
<span>{{ stage.description }}</span>
</div>
<div class="journey-card-actions" aria-label="调整卡片顺序">
<button type="button" :disabled="index === 0" :aria-label="`将${stage.title}上移`"
@click="moveJourneyStage(index, -1)">↑</button>
<button type="button" :disabled="index === orderedJourneyStages.length - 1"
:aria-label="`将${stage.title}下移`"
@click="moveJourneyStage(index, 1)">↓</button>
</div>
</article>
<span v-if="index < orderedJourneyStages.length - 1" class="journey-sort-arrow"
aria-hidden="true">→</span>
</template>
</div>
<p v-if="journeyCheckMessage"
:class="['journey-check-message', { success: journeyCheckedCorrectly }]" role="status">
{{ journeyCheckMessage }}
</p>
</section>
<section v-show="isSectionVisible(2)" class="scenario-step-section">
<div class="section-heading"><span>01</span>
<div>
<h3>分析购买旅程中的行为与体验</h3>
<p>请根据访谈记录,分析用户在购买旅程各阶段的行为表现,识别关键触点,并评估用户的情感体验水平。</p>
</div>
</div>
<div class="journey-analysis-wrap">
<table class="journey-analysis-table">
<thead>
<tr>
<th>序号</th>
<th>旅程阶段</th>
<th>用户行为描述</th>
<th>关键触点</th>
<th>情感体验</th>
<th>关键决策因素</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in form.journeyAnalysis" :key="row.stageId">
<td class="journey-analysis-index">{{ index + 1 }}</td>
<th class="journey-analysis-stage">{{ stageTitle(row.stageId) }}</th>
<td>
<textarea v-model="row.behaviorDescription"
placeholder="描述用户在此阶段的行为表现"></textarea>
</td>
<td>
<textarea v-model="row.keyTouchpoints"
placeholder="填写平台、内容、服务或产品触点"></textarea>
</td>
<td>
<select v-model="row.emotionExperience" aria-label="选择情感体验">
<option value="" disabled>请选择</option>
<option v-for="emotion in emotionOptions" :key="emotion"
:value="emotion">{{ emotion }}
</option>
</select>
</td>
<td>
<textarea v-model="row.decisionFactors"
placeholder="填写推动或阻碍决策的关键因素"></textarea>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="journeyAnalysisMessage" class="journey-check-message" role="status">{{
journeyAnalysisMessage }}</p>
</section>
<section v-show="isSectionVisible(3)" class="scenario-step-section">
<div class="section-heading"><span>01</span>
<div>
<h3>绘制用户购买旅程图</h3>
<p>基于步骤二的分析结果,绘制完整的用户购买旅程图,呈现旅程阶段、情感体验曲线、关键触点、痛点和机会点。</p>
</div>
</div>
<div class="journey-map-upload-panel">
<input ref="journeyMapInput" class="hidden-file-input" type="file"
accept="image/png,image/jpeg,image/webp" @change="handleJourneyMapUpload" />
<template v-if="form.journeyMap.url">
<el-image class="journey-map-preview" :src="form.journeyMap.url"
:preview-src-list="[form.journeyMap.url]" preview-teleported fit="contain" />
<div class="journey-map-file-info">
<el-icon>
<UploadFilled />
</el-icon>
<span>{{ form.journeyMap.name || "用户购买旅程图" }}</span>
</div>
<div class="journey-map-actions">
<el-button type="primary" @click="openJourneyMapPicker">更换用户购买旅程图</el-button>
<el-button @click="clearJourneyMap">移除图片</el-button>
</div>
</template>
<template v-else>
<el-icon class="journey-map-upload-icon">
<UploadFilled />
</el-icon>
<strong>上传用户购买旅程图</strong>
<p>支持 PNG、JPG、JPEG、WEBP 格式,单个文件不超过 5MB</p>
<el-button type="primary" :loading="uploadingJourneyMap"
@click="openJourneyMapPicker">上传用户购买旅程图</el-button>
</template>
</div>
</section>
<section v-show="isSectionVisible(4)" class="scenario-step-section">
<div class="section-heading"><span>01</span>
<div>
<h3>识别旅程痛点,形成优化建议</h3>
<p>结合用户购买旅程图,分析各阶段存在的痛点及未满足的需求,提出可落地的产品优化建议。</p>
</div>
</div>
<div class="journey-optimization-wrap">
<table class="journey-optimization-table">
<thead>
<tr>
<th>旅程阶段</th>
<th>识别的痛点</th>
<th>优化建议</th>
</tr>
</thead>
<tbody>
<tr v-for="row in form.journeyOptimizations" :key="row.stageId">
<th>{{ stageShortTitle(row.stageId) }}</th>
<td><textarea v-model="row.painPoint"
placeholder="填写该阶段用户遇到的痛点或未满足需求"></textarea></td>
<td><textarea v-model="row.optimizationSuggestion"
placeholder="填写针对痛点的产品、服务或体验优化建议"></textarea>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="journeyOptimizationMessage" class="journey-check-message" role="status">{{
journeyOptimizationMessage
}}</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(trainingSteps.length).padStart(2, "0") }}</span>
<button v-if="form.activeStep < trainingSteps.length" type="button"
class="btn-nav btn-primary" @click="goNextStep">
下一步
<el-icon>
<ArrowRight />
</el-icon>
</button>
</div>
</div>
</section>
</section>
</main>
<TrainingAiSidebar study-tip="当前步骤建议:先把文本判断到购买旅程阶段,再填写每个阶段的用户目标、行为、触点、情绪和痛点。" reference-title="消费场景解构参考"
reference-text="· 旅程阶段:问题感知、信息搜索、评估决策、购买转化、购后行为<br />· 拆解维度:用户目标、用户行为、接触点、情绪曲线、痛点/机会点<br />· 输出重点:找到情绪低点和产品可介入的关键节点"
assist-text="可让 AI 帮你把一段用户故事拆成旅程阶段,再检查每个阶段是否有明确触点和痛点。" :progress-items="progressItems" />
</div>
</template>
<script setup>
import { computed, getCurrentInstance, onMounted, ref } from "vue"
import { ArrowLeft, ArrowRight, CircleCheck, Download, 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 { parseTrainingArray } from "@/views/training/taskKeyMap"
import { checkConsumerScenarioStepOne, checkConsumerScenarioStepTwo, checkConsumerScenarioStepThree, checkConsumerScenarioStepFour, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer"
import { isStudentDemo } from "@/utils/studentDemo"
const TASK_KEY = "consumer-scenario"
const { proxy } = getCurrentInstance()
const defaultRequirement =
"消费场景解构用于把抽象需求落到具体时间、地点、人物、任务和情绪中,找到用户真正发生问题的瞬间,从而发现产品机会和功能设计切入点。"
const defaultBackground =
"很多智能产品失败并不是功能少,而是没有进入用户真实生活场景。通过拆解场景,可以识别触发因素、使用阻碍、情绪变化和可被产品改善的关键节点。"
const defaultGoals = [
"能够描述目标用户的典型消费场景。",
"识别场景中的触发点、痛点和阻碍因素。",
"将场景洞察转化为产品机会。",
]
const journeyStages = [
{
id: "problem-awareness",
title: "问题感知(需求触发)",
description: "用户意识到自身存在某个问题或需求",
},
{
id: "information-search",
title: "信息搜索(产品探索)",
description: "用户主动搜索相关信息,了解可选方案",
},
{
id: "evaluation-decision",
title: "评估决策(对比选择)",
description: "用户对比不同产品的功能、价格、口碑",
},
{
id: "purchase-conversion",
title: "购买转化(下单支付)",
description: "用户完成购买决策并下单",
},
{
id: "post-purchase",
title: "购后行为(使用反馈)",
description: "用户使用产品后形成体验评价,决定是否推荐或复购",
},
]
const emotionOptions = ["负面", "中性", "正面"]
const phases = [
{ key: "problem", label: "问题感知(需求触发)" },
{ key: "search", label: "信息搜索(产品探索)" },
{ key: "evaluate", label: "评估决策(对比选择)" },
{ key: "purchase", label: "购买转化(下单支付)" },
{ key: "post", label: "购后行为(使用反馈)" },
]
const scenarioStatements = [
{
key: "needTrigger",
text: "用户刷小红书看到寝室智能做出好多美味的营养饮品和养生美汤,也想要自己尝试来做,这种行为属于用户购买旅程的",
defaultPhase: "problem",
},
{
key: "productSearch",
text: "用户在小红书、抖音、京东、淘宝等平台关注破壁机核心参数(转速、分贝值)、性价比、使用场景、演示等内容,属于用户购买旅程的",
defaultPhase: "search",
},
{
key: "compareDecision",
text: "用户选定了3款不同品牌破壁机,针对自动清洁、预约等实用功能以及价格、售后服务进行全方位比对,评估性价比,属于用户购买旅程的",
defaultPhase: "evaluate",
},
{
key: "orderPayment",
text: "用户心仪的破壁机在810期间大促,而且支持先用后付,用户最终以原价八折的八百多下单购买,属于用户购买旅程的",
defaultPhase: "purchase",
},
{
key: "usageReview",
text: "用户在使用该破壁机后给出好评,并在小红书上晒单分享使用过程,属于用户购买旅程的",
defaultPhase: "post",
},
]
const journeyRows = [
{ key: "goal", label: "用户目标", placeholder: "填写该阶段用户想达成什么" },
{ key: "behavior", label: "用户行为", placeholder: "填写用户具体动作" },
{ key: "touchpoint", label: "接触点", placeholder: "填写平台、页面、人员或设备触点" },
{ key: "emotion", label: "情绪曲线", placeholder: "填写情绪变化和原因" },
{ key: "pain", label: "痛点/机会点", placeholder: "填写阻碍、痛点和产品机会" },
]
const referencePhases = [
{
name: "了解产品",
goals: ["有哪些特色功能?", "有哪些利益点?"],
touchpoint: "小红书、抖音、豆瓣、官网、百度、应用市场",
pain: "不知道从哪里获取可靠信息",
},
{
name: "找房",
goals: ["性价比高、干净、氛围好", "交通便利", "距离景点近"],
touchpoint: "首页、搜索、推荐页、详情页、聊天页",
pain: "房型好多,看不完;有过踩雷经历",
},
{
name: "订房",
goals: ["下单方便快捷", "可以正常通订"],
touchpoint: "收藏、付款页",
pain: "详情页广告多,且介绍文本过长",
},
{
name: "订房后/住前",
goals: ["办理入住简单", "与房东联系方便"],
touchpoint: "详情页、线下",
pain: "客服回复慢或答非所问",
},
{
name: "住中",
goals: ["房间干净、安全、噪声低", "设施完善,24h热水"],
touchpoint: "详情页、线下、客服电话",
pain: "卫生不过关,联系流程复杂",
},
{
name: "住后",
goals: ["顺利完成退房", "返还押金"],
touchpoint: "我的订单、评价",
pain: "没有想找用户、做意见问卷",
},
]
const behaviorNodes = ["了解同类产品", "浏览房源信息", "查看房间详情", "与客服沟通咨询", "下单付款", "联系酒店,确认入住时间", "办理入住", "游玩", "退房", "发布入住评价"]
const emotionPoints = [
{ icon: "🙂", label: "期待", left: "4%", top: "58%" },
{ icon: "😍", label: "满意", left: "17%", top: "30%" },
{ icon: "🙂", label: "犹豫", left: "30%", top: "60%" },
{ icon: "🙁", label: "担心", left: "43%", top: "74%" },
{ icon: "😍", label: "高兴", left: "56%", top: "34%" },
{ icon: "🙁", label: "焦虑", left: "70%", top: "74%" },
{ icon: "😍", label: "满意", left: "82%", top: "52%" },
{ icon: "🙂", label: "平稳", left: "93%", top: "44%" },
]
const taskConfig = ref(null)
const saving = ref(false)
const checkingJourney = ref(false)
const checkingJourneyAnalysis = ref(false)
const checkingJourneyOptimization = ref(false)
const journeyMapInput = ref(null)
const uploadingJourneyMap = ref(false)
const draggedStageId = ref("")
const journeyCheckMessage = ref("")
const journeyCheckedCorrectly = ref(false)
const journeyAnalysisMessage = ref("")
const journeyOptimizationMessage = ref("")
function createMatrix() {
return journeyRows.reduce((matrix, row) => {
matrix[row.key] = phases.reduce((phaseMap, phase) => {
phaseMap[phase.key] = ""
return phaseMap
}, {})
return matrix
}, {})
}
function createClassifications() {
return scenarioStatements.reduce((map, item) => {
map[item.key] = item.defaultPhase
return map
}, {})
}
function createJourneyAnalysis() {
return journeyStages.map((stage) => ({
stageId: stage.id,
behaviorDescription: "",
keyTouchpoints: "",
emotionExperience: "",
decisionFactors: "",
}))
}
function createJourneyOptimizations() {
return journeyStages.map((stage) => ({
stageId: stage.id,
painPoint: "",
optimizationSuggestion: "",
}))
}
function createForm() {
return {
activeStep: 1,
journeyOrder: createShuffledJourneyOrder(),
journeyAnalysis: createJourneyAnalysis(),
journeyMap: { url: "", name: "" },
journeyOptimizations: createJourneyOptimizations(),
classifications: createClassifications(),
matrix: createMatrix(),
}
}
const form = ref(createForm())
const taskTitle = computed(() => taskConfig.value?.taskName || "消费场景解构")
const briefOpen = ref(true)
const taskBackground = computed(() => taskConfig.value?.background || defaultBackground)
const taskGoals = computed(() => parseArray(taskConfig.value?.objectives, defaultGoals))
const taskRequirement = computed(() => taskConfig.value?.requirements || defaultRequirement)
const trainingSteps = computed(() => parseTrainingArray(taskConfig.value?.steps, []))
const hasConfiguredSteps = computed(() => trainingSteps.value.length > 0)
const activeStepName = computed(() => trainingSteps.value[form.value.activeStep - 1] || "")
const orderedJourneyStages = computed(() =>
form.value.journeyOrder
.map((stageId) => journeyStages.find((stage) => stage.id === stageId))
.filter(Boolean)
)
const progressItems = computed(() => [
{ text: journeyCheckedCorrectly.value ? "已完成:购买旅程排序" : "进行中:购买旅程排序", status: journeyCheckedCorrectly.value ? "done" : "doing" },
{ text: hasJourneyAnalysisContent.value ? "进行中:用户旅程分析" : "待完成:用户旅程分析", status: hasJourneyAnalysisContent.value ? "doing" : "" },
{ text: "待完成:提交实训成果", status: "" },
])
function createShuffledJourneyOrder() {
const order = journeyStages.map((stage) => stage.id)
for (let index = order.length - 1; index > 0; index -= 1) {
const randomIndex = Math.floor(Math.random() * (index + 1));
[order[index], order[randomIndex]] = [order[randomIndex], order[index]]
}
if (order.every((stageId, index) => stageId === journeyStages[index].id)) {
[order[0], order[1]] = [order[1], order[0]]
}
return order
}
const isClassifiedCorrectly = computed(() =>
scenarioStatements.every((item) => form.value.classifications[item.key] === item.defaultPhase)
)
const hasMatrixContent = computed(() =>
Object.values(form.value.matrix).some((row) =>
Object.values(row).some((value) => String(value || "").trim())
)
)
const hasJourneyAnalysisContent = computed(() =>
form.value.journeyAnalysis.some((row) =>
[row.behaviorDescription, row.keyTouchpoints, row.emotionExperience, row.decisionFactors]
.some((value) => String(value || "").trim())
)
)
onMounted(async () => {
await loadTaskConfig()
await loadSavedAnswer()
})
async function loadTaskConfig() {
try {
const res = await getTrainingTaskByKey(TASK_KEY)
taskConfig.value = res?.data || null
} catch (error) {
taskConfig.value = null
}
}
async function loadSavedAnswer() {
try {
const res = await getStudentTrainingAnswer(TASK_KEY)
const answer = res?.data
if (answer?.step1Answer) {
applySavedAnswer(answer.step1Answer)
}
} catch (error) {
// Keep local defaults when history cannot be loaded.
}
}
function parseArray(value, fallback = []) {
if (Array.isArray(value)) return value.filter(Boolean)
if (!value) return fallback
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) && parsed.length ? parsed : fallback
} catch (error) {
const list = String(value)
.split(/[;;||\n]/)
.map((item) => item.trim())
.filter(Boolean)
return list.length ? list : fallback
}
}
function applySavedAnswer(value) {
try {
const parsed = JSON.parse(value)
if (!parsed || typeof parsed !== "object") return
form.value = {
activeStep: normalizeActiveStep(parsed.currentStep),
journeyOrder: normalizeJourneyOrder(parsed.journeyOrder),
journeyAnalysis: normalizeJourneyAnalysis(parsed.journeyAnalysis),
journeyMap: normalizeJourneyMap(parsed.journeyMap),
journeyOptimizations: normalizeJourneyOptimizations(parsed.journeyOptimizations),
classifications: {
...createClassifications(),
...(parsed.classifications || {}),
},
matrix: normalizeMatrix(parsed.matrix),
}
journeyCheckedCorrectly.value = parsed.journeyCheckedCorrectly === true
journeyCheckMessage.value = journeyCheckedCorrectly.value ? "排序正确,已完成本步骤。" : ""
} catch (error) {
// Ignore malformed historical answers.
}
}
function normalizeJourneyMap(value) {
return {
url: typeof value?.url === "string" ? value.url : "",
name: typeof value?.name === "string" ? value.name : "",
}
}
function normalizeJourneyAnalysis(value) {
const savedRows = Array.isArray(value) ? value : []
return createJourneyAnalysis().map((row) => {
const savedRow = savedRows.find((item) => item?.stageId === row.stageId) || {}
return {
...row,
behaviorDescription: savedRow.behaviorDescription || "",
keyTouchpoints: savedRow.keyTouchpoints || "",
emotionExperience: emotionOptions.includes(savedRow.emotionExperience) ? savedRow.emotionExperience : "",
decisionFactors: savedRow.decisionFactors || "",
}
})
}
function normalizeJourneyOptimizations(value) {
const savedRows = Array.isArray(value) ? value : []
return createJourneyOptimizations().map((row) => {
const savedRow = savedRows.find((item) => item?.stageId === row.stageId) || {}
return {
...row,
painPoint: savedRow.painPoint || "",
optimizationSuggestion: savedRow.optimizationSuggestion || "",
}
})
}
function normalizeJourneyOrder(value) {
if (!Array.isArray(value) || value.length !== journeyStages.length) return createShuffledJourneyOrder()
const uniqueOrder = [...new Set(value)]
const validIds = journeyStages.map((stage) => stage.id)
if (uniqueOrder.length !== validIds.length || uniqueOrder.some((stageId) => !validIds.includes(stageId))) {
return createShuffledJourneyOrder()
}
return uniqueOrder
}
function normalizeMatrix(value = {}) {
const matrix = createMatrix()
journeyRows.forEach((row) => {
phases.forEach((phase) => {
matrix[row.key][phase.key] = value?.[row.key]?.[phase.key] || ""
})
})
return matrix
}
function normalizeActiveStep(value) {
const step = Number(value)
const total = trainingSteps.value.length
if (!Number.isInteger(step) || step < 1) return 1
return total ? Math.min(step, total) : 1
}
async function setCurrentStep(step) {
if (!hasConfiguredSteps.value) return
const targetStep = normalizeActiveStep(step)
if (targetStep > form.value.activeStep && [1, 2, 3].includes(form.value.activeStep) && !(await persist("IN_PROGRESS"))) return
form.value.activeStep = targetStep
}
function isSectionVisible(section) {
if (!hasConfiguredSteps.value) return true
return Math.min(form.value.activeStep, 4) === section
}
function goPreviousStep() {
setCurrentStep(form.value.activeStep - 1)
}
async function goNextStep() {
await setCurrentStep(form.value.activeStep + 1)
}
function startJourneyDrag(stageId) {
draggedStageId.value = stageId
}
function endJourneyDrag() {
draggedStageId.value = ""
}
function dropJourneyStage(targetStageId) {
const sourceStageId = draggedStageId.value
endJourneyDrag()
if (!sourceStageId || sourceStageId === targetStageId) return
const sourceIndex = form.value.journeyOrder.indexOf(sourceStageId)
const targetIndex = form.value.journeyOrder.indexOf(targetStageId)
if (sourceIndex < 0 || targetIndex < 0) return
const nextOrder = [...form.value.journeyOrder]
nextOrder.splice(sourceIndex, 1)
nextOrder.splice(targetIndex, 0, sourceStageId)
updateJourneyOrder(nextOrder)
}
function moveJourneyStage(index, offset) {
const targetIndex = index + offset
if (targetIndex < 0 || targetIndex >= form.value.journeyOrder.length) return
const nextOrder = [...form.value.journeyOrder];
[nextOrder[index], nextOrder[targetIndex]] = [nextOrder[targetIndex], nextOrder[index]]
updateJourneyOrder(nextOrder)
}
function updateJourneyOrder(nextOrder) {
form.value.journeyOrder = nextOrder
journeyCheckedCorrectly.value = false
journeyCheckMessage.value = ""
}
async function checkJourneyOrder() {
await ensureJourneyOrderValid(true)
}
async function ensureJourneyOrderValid(showSuccess = false) {
if (checkingJourney.value) return false
checkingJourney.value = true
try {
const response = await checkConsumerScenarioStepOne({ stageOrder: form.value.journeyOrder })
const result = response?.data || {}
journeyCheckedCorrectly.value = result.valid === true
journeyCheckMessage.value = result.message || (result.valid ? "排序正确,已完成本步骤。" : "排序还不正确,请继续调整。")
if (result.valid && showSuccess) proxy?.$modal?.msgSuccess("排序正确,可以进入下一步")
return result.valid === true
} catch (error) {
journeyCheckedCorrectly.value = false
journeyCheckMessage.value = error?.message || "排序校验失败,请稍后重试。"
return false
} finally {
checkingJourney.value = false
}
}
function stageTitle(stageId) {
return journeyStages.find((stage) => stage.id === stageId)?.title || "未知阶段"
}
function stageShortTitle(stageId) {
return stageTitle(stageId).split("(")[0]
}
async function ensureJourneyAnalysisValid() {
if (checkingJourneyAnalysis.value) return false
checkingJourneyAnalysis.value = true
try {
const response = await checkConsumerScenarioStepTwo({ rows: form.value.journeyAnalysis })
const result = response?.data || {}
journeyAnalysisMessage.value = result.message || (result.valid ? "旅程分析填写完整。" : "请完整填写五个阶段的旅程分析。")
return result.valid === true
} catch (error) {
journeyAnalysisMessage.value = error?.message || "旅程分析校验失败,请稍后重试。"
return false
} finally {
checkingJourneyAnalysis.value = false
}
}
function openJourneyMapPicker() {
journeyMapInput.value?.click()
}
function revokeBlobUrl(url) {
if (typeof url === "string" && url.startsWith("blob:")) URL.revokeObjectURL(url)
}
async function handleJourneyMapUpload(event) {
const file = event.target?.files?.[0]
if (!file) return
const allowedTypes = ["image/png", "image/jpeg", "image/webp"]
if (!allowedTypes.includes(file.type)) {
proxy?.$modal?.msgError("请上传 PNG、JPG、JPEG 或 WEBP 图片")
event.target.value = ""
return
}
if (file.size > 5 * 1024 * 1024) {
proxy?.$modal?.msgError("图片大小不能超过 5MB")
event.target.value = ""
return
}
uploadingJourneyMap.value = true
try {
let url = ""
if (isStudentDemo()) {
url = URL.createObjectURL(file)
} else {
const formData = new FormData()
formData.append("file", file)
const response = await uploadStudentTrainingFile(formData)
url = response?.url || response?.data?.url || ""
}
if (!url) throw new Error("上传失败,请稍后重试")
revokeBlobUrl(form.value.journeyMap.url)
form.value.journeyMap = { url, name: file.name }
proxy?.$modal?.msgSuccess("用户购买旅程图上传成功")
} catch (error) {
proxy?.$modal?.msgError(error?.message || "上传失败,请稍后重试")
} finally {
uploadingJourneyMap.value = false
event.target.value = ""
}
}
function clearJourneyMap() {
revokeBlobUrl(form.value.journeyMap.url)
form.value.journeyMap = { url: "", name: "" }
}
async function ensureJourneyMapValid() {
try {
const response = await checkConsumerScenarioStepThree({ journeyMapUrl: form.value.journeyMap.url })
const result = response?.data || {}
if (!result.valid) proxy?.$modal?.msgError(result.message || "请上传用户购买旅程图")
return result.valid === true
} catch (error) {
proxy?.$modal?.msgError(error?.message || "请先上传用户购买旅程图")
return false
}
}
async function ensureJourneyOptimizationValid() {
if (checkingJourneyOptimization.value) return false
checkingJourneyOptimization.value = true
try {
const response = await checkConsumerScenarioStepFour({ rows: form.value.journeyOptimizations })
const result = response?.data || {}
journeyOptimizationMessage.value = result.message || (result.valid ? "五个阶段的优化分析填写完整。" : "请完整填写五个阶段的痛点和优化建议。")
return result.valid === true
} catch (error) {
journeyOptimizationMessage.value = error?.message || "优化建议校验失败,请稍后重试。"
return false
} finally {
checkingJourneyOptimization.value = false
}
}
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 buildOutcome() {
return {
taskKey: TASK_KEY,
taskId: taskConfig.value?.id || "",
taskName: taskTitle.value,
currentStep: form.value.activeStep,
journeyOrder: form.value.journeyOrder,
journeyCheckedCorrectly: journeyCheckedCorrectly.value,
journeyAnalysis: form.value.journeyAnalysis,
journeyMap: form.value.journeyMap,
journeyOptimizations: form.value.journeyOptimizations,
phases,
classifications: form.value.classifications,
matrix: form.value.matrix,
updatedAt: new Date().toISOString(),
}
}
function buildAnswerPayload(status) {
const saveAction = status === "SUBMITTED" ? "SUBMIT" : status === "RESET" ? "RESET" : "SAVE"
return {
saveAction,
currentStep: form.value.activeStep,
step1Answer: JSON.stringify(buildOutcome()),
}
}
async function persist(status = "IN_PROGRESS") {
if (saving.value) return
if (status !== "RESET" && form.value.activeStep === 1 && !(await ensureJourneyOrderValid())) return false
if (status !== "RESET" && form.value.activeStep === 2 && !(await ensureJourneyAnalysisValid())) return false
if (status !== "RESET" && form.value.activeStep === 3 && !(await ensureJourneyMapValid())) return false
if (status !== "RESET" && form.value.activeStep === 4 && !(await ensureJourneyOptimizationValid())) 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() {
revokeBlobUrl(form.value.journeyMap.url)
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)
}
</script>
<style scoped lang="scss">
/* ===== 页面级令牌(教育蓝极简主题) ===== */
.consumer-scenario-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;
}
/* 核心容器:透明 grid,无边框/背景/阴影 */
.consumer-scenario-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: 12px;
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: 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 {
margin: 0;
padding: 10px 16px;
:deep(.training-task-toolbar) {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
}
/* 顶部任务工具栏(保留 DOM,按钮由全局接管) */
.workbench-head--actions-only {
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
max-width: 100%;
margin: 0 0 14px;
padding: 0;
border: 0;
}
.workbench-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
/* workbench 容器:透明 */
.consumer-scenario-workbench {
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
display: grid;
gap: 10px;
}
/* step-card:透明容器 */
.consumer-scenario-step-card {
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
display: grid;
gap: 10px;
}
.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;
color: var(--student-text-secondary);
font-weight: 700;
font-size: var(--student-fs-caption);
letter-spacing: 0.12em;
text-transform: uppercase;
}
/* ===== 步骤内小节标题(蓝底序号)布局辅助 ===== */
.journey-sort-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
.section-heading {
flex: 1 1 auto;
min-width: 0;
margin-bottom: 0;
}
}
/* 检查排序按钮 */
.journey-check-action {
flex: 0 0 auto;
min-height: 40px;
padding: 0 18px;
border: 1px solid var(--student-accent);
border-radius: 10px;
color: #ffffff;
background: var(--student-accent);
font: inherit;
font-size: var(--student-fs-button);
font-weight: 700;
cursor: pointer;
transition: background 0.16s ease, border-color 0.16s ease, transform 0.16s ease, box-shadow 0.16s ease;
&:hover:not(:disabled) {
background: var(--student-accent-deep);
border-color: var(--student-accent-deep);
transform: translateY(-1px);
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.18);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
/* ===== 旅程排序卡片 ===== */
.journey-sort-list {
display: grid;
grid-template-columns: repeat(9, minmax(0, 1fr));
align-items: center;
gap: 8px;
}
.journey-sort-card {
position: relative;
display: grid;
grid-column: span 1;
min-height: 162px;
min-width: 0;
grid-template-rows: auto 1fr auto;
padding: 16px;
border: 1px solid var(--student-panel-border);
border-radius: 14px;
color: var(--student-text);
background: #ffffff;
box-shadow: 0 1px 3px rgba(37, 99, 235, 0.08);
cursor: grab;
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, opacity 0.18s ease;
&:hover {
transform: translateY(-2px);
border-color: var(--student-accent);
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.12);
}
&.dragging {
opacity: 0.45;
cursor: grabbing;
}
}
.journey-card-order {
color: var(--student-text-secondary);
font-family: ui-monospace, "SF Mono", Consolas, monospace;
font-size: var(--student-fs-body-sm);
font-weight: 700;
letter-spacing: 0.04em;
}
.journey-card-copy {
align-self: center;
strong,
span {
display: block;
}
strong {
margin-bottom: 8px;
color: var(--student-text);
font-size: var(--student-fs-body);
line-height: 1.45;
}
span {
color: var(--student-text-secondary);
font-size: var(--student-fs-caption);
line-height: 1.65;
}
}
.journey-card-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
button {
display: inline-grid;
width: 26px;
height: 26px;
place-items: center;
border: 1px solid var(--student-panel-border);
border-radius: 7px;
color: var(--student-text-secondary);
background: #ffffff;
font: inherit;
font-weight: 700;
cursor: pointer;
transition: border-color 0.16s ease, color 0.16s ease, background 0.16s ease;
&:hover:not(:disabled) {
border-color: var(--student-accent);
color: var(--student-accent);
background: var(--student-accent-soft);
}
&:disabled {
cursor: not-allowed;
opacity: 0.4;
}
}
}
.journey-sort-arrow {
justify-self: center;
color: var(--student-text-secondary);
font-size: var(--student-fs-display);
font-weight: 700;
}
/* ===== 校验消息 ===== */
.journey-check-message {
margin: 14px 0 0;
padding: 11px 14px;
border: 1px solid var(--student-panel-border);
border-radius: 8px;
color: var(--student-text-secondary);
background: #fafbfc;
font-size: var(--student-fs-body-sm);
&.success {
border-color: #e5e7eb;
color: #16915f;
background: rgba(74, 157, 110, 0.08);
}
}
/* ===== 表格 ===== */
.journey-analysis-wrap,
.journey-optimization-wrap {
overflow-x: auto;
}
.journey-analysis-table,
.journey-optimization-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.journey-analysis-table {
min-width: 1120px;
}
.journey-optimization-table {
min-width: 900px;
}
.journey-analysis-table th,
.journey-analysis-table td,
.journey-optimization-table th,
.journey-optimization-table td {
border: 1px solid var(--student-panel-border);
}
.journey-analysis-table thead th,
.journey-optimization-table thead th {
padding: 10px 12px;
color: var(--student-text);
background: #fafbfc;
font-size: var(--student-fs-body-sm);
font-weight: 800;
text-align: left;
}
.journey-analysis-table tbody td,
.journey-analysis-table tbody th,
.journey-optimization-table tbody td,
.journey-optimization-table tbody th {
padding: 10px;
vertical-align: top;
background: transparent;
}
.journey-analysis-table thead th:nth-child(1) {
width: 54px;
}
.journey-analysis-table thead th:nth-child(2) {
width: 132px;
}
.journey-analysis-table thead th:nth-child(3),
.journey-analysis-table thead th:nth-child(4),
.journey-analysis-table thead th:nth-child(6) {
width: 27%;
}
.journey-analysis-table thead th:nth-child(5) {
width: 118px;
}
.journey-optimization-table thead th:first-child {
width: 170px;
}
/* 序号列 */
.journey-analysis-index {
color: var(--student-text-secondary);
font-family: ui-monospace, "SF Mono", Consolas, monospace;
font-size: var(--student-fs-body-sm);
font-weight: 700;
text-align: center;
vertical-align: middle;
}
.journey-analysis-stage,
.journey-optimization-table tbody th {
color: var(--student-text);
font-weight: 700;
text-align: center;
vertical-align: middle;
}
.journey-analysis-stage {
line-height: 1.65;
}
/* textarea + select */
.journey-analysis-table textarea,
.journey-analysis-table select,
.journey-optimization-table textarea {
box-sizing: border-box;
width: 100%;
border: 1px solid transparent;
border-radius: 0;
padding: 8px 10px;
color: var(--student-text);
background: transparent;
font: inherit;
font-size: var(--student-fs-body-sm);
line-height: 1.55;
outline: none;
}
.journey-analysis-table textarea,
.journey-optimization-table textarea {
min-height: 112px;
resize: vertical;
}
.journey-analysis-table textarea:focus,
.journey-analysis-table select:focus,
.journey-optimization-table textarea:focus {
outline: 2px solid rgba(37, 99, 235, 0.15);
outline-offset: -2px;
}
.journey-analysis-table textarea::placeholder,
.journey-optimization-table textarea::placeholder {
color: var(--student-text-secondary);
}
.journey-analysis-table select {
min-height: 38px;
padding: 8px 10px;
border: 1px solid var(--student-panel-border);
border-radius: 6px;
background: #ffffff;
color: var(--student-text);
cursor: pointer;
}
.journey-analysis-table select:focus {
border-color: var(--student-accent);
outline: 2px solid rgba(37, 99, 235, 0.15);
outline-offset: -2px;
}
/* ===== 上传面板 ===== */
.hidden-file-input {
display: none;
}
.journey-map-upload-panel {
min-height: 330px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 28px;
border: 1px dashed var(--student-panel-border);
border-radius: 16px;
color: var(--student-text);
background: #ffffff;
strong {
font-size: var(--student-fs-h1);
color: var(--student-text);
}
p {
margin: 0 0 8px;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
}
}
.journey-map-upload-icon {
width: 52px;
height: 52px;
display: grid;
place-items: center;
border: 1px solid var(--student-panel-border);
border-radius: 50%;
color: var(--student-accent);
background: var(--student-accent-soft);
font-size: 25px;
}
.journey-map-preview {
width: min(100%, 760px);
max-height: 360px;
overflow: hidden;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: #ffffff;
cursor: zoom-in;
:deep(img) {
object-fit: contain;
}
}
.journey-map-file-info {
max-width: 100%;
display: flex;
align-items: center;
gap: 8px;
color: var(--student-text);
font-size: var(--student-fs-body);
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.journey-map-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
/* ===== 底部步骤导航按钮 ===== */
.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);
}
&:disabled {
cursor: not-allowed;
opacity: 0.45;
transform: none;
box-shadow: none;
}
}
.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 {
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-save {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid #e5e7eb;
border-radius: 8px;
color: #4b5563;
background: #f9fafb;
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: #d1d5db;
background: #f3f4f6;
}
}
.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;
}
/* training-actions 容器:el-button 由全局样式接管 */
.training-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 14px;
margin-top: 20px;
}
/* ===== 响应式 ===== */
@media (max-width: 900px) {
.workbench-head--actions-only,
.step-title-row,
.training-actions {
flex-direction: column;
align-items: stretch;
}
.step-navigation {
flex-direction: column;
align-items: stretch;
}
.step-nav-left,
.step-nav-right {
justify-content: center;
}
.btn-nav,
.btn-primary,
.btn-ghost,
.btn-save {
width: 100%;
}
.journey-sort-head {
flex-direction: column;
}
.journey-check-action {
width: 100%;
}
.journey-sort-list {
grid-template-columns: 1fr;
}
.journey-sort-card {
min-height: 126px;
}
.journey-sort-arrow {
transform: rotate(90deg);
}
}
/* ===== 步骤内小节标题(蓝底序号,与 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">
/* ===== 步骤导航:分段连接胶囊(SKILL.md 规格,.step-marker 变体) ===== */
#app .studentLayout section.app-main:has(.consumer-scenario-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 {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
min-width: 0 !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;
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;
flex: 0 0 auto !important;
min-width: 22px !important;
height: 22px !important;
padding: 0 5px !important;
border: none !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 */
&: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(.consumer-scenario-page) .step-indicator {
justify-content: flex-start !important;
overflow-x: auto !important;
}
#app .studentLayout section.app-main:has(.consumer-scenario-page) .step-indicator .step-tab {
flex: 0 0 140px !important;
max-width: none !important;
}
}
/* ============================================================
* 顶部工具栏:TrainingMaterialButton 资料按钮 → 教育蓝
* ============================================================ */
.consumer-scenario-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(.consumer-scenario-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;
cursor: pointer !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(.consumer-scenario-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;
}
/* ============================================================
* 底部提交/清空按钮:training-actions
* ============================================================ */
#app .studentLayout section.app-main:has(.consumer-scenario-page) .training-actions .el-button.training-action {
min-width: 112px !important;
min-height: 34px !important;
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: 700 !important;
line-height: 1 !important;
letter-spacing: 0 !important;
text-shadow: none !important;
box-shadow: none !important;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease !important;
}
#app .studentLayout section.app-main:has(.consumer-scenario-page) .training-actions .el-button.training-action:hover:not(.is-disabled) {
transform: translateY(-1px) !important;
border-color: #2563eb !important;
background: #eff6ff !important;
color: #2563eb !important;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.12) !important;
}
#app .studentLayout section.app-main:has(.consumer-scenario-page) .training-actions .el-button.training-action--submit {
border-color: #2563eb !important;
color: #ffffff !important;
background: #2563eb !important;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.28) !important;
}
#app .studentLayout section.app-main:has(.consumer-scenario-page) .training-actions .el-button.training-action--submit:hover:not(.is-disabled) {
border-color: #1d4ed8 !important;
background: #1d4ed8 !important;
color: #ffffff !important;
box-shadow: 0 5px 14px rgba(29, 78, 216, 0.32) !important;
}
#app .studentLayout section.app-main:has(.consumer-scenario-page) .training-actions .el-button.training-action--reset {
border-color: #e5e7eb !important;
color: #374151 !important;
background: #f3f4f6 !important;
}
#app .studentLayout section.app-main:has(.consumer-scenario-page) .training-actions .el-button.training-action--reset:hover:not(.is-disabled) {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
}
</style>