|
|
<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>
|