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

2277 lines
60 KiB
Vue

This file contains ambiguous Unicode characters!

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

<template>
<div class="consumer-scenario-page student-training-shell">
<main class="training-core">
<section class="task-header">
<div class="task-badge">市场洞察与需求分析</div>
<h1 class="task-title">{{ taskTitle }}</h1>
</section>
<TrainingTaskBrief
:background="taskBackground"
:goals="taskGoals"
:requirements="taskRequirement"
:show-material="false"
/>
<section class="task-card">
<div class="workbench-head workbench-head--actions-only">
<div class="workbench-actions">
<TrainingMaterialButton :material-name="taskConfig?.materialName" :material-url="taskConfig?.materialUrl" />
<button type="button" class="outline-action" @click="exportOutcome">
<el-icon><Download /></el-icon>
导出实训成果
</button>
</div>
</div>
<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>
<p class="journey-sort-kicker">Customer Journey</p>
<h4>将购买旅程卡片按正确顺序排列</h4>
<p>从需求触发到购买后的使用反馈,共五个阶段。拖动卡片,或使用卡片右侧的上下移动按钮调整顺序。</p>
</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="journey-analysis-intro">
<p class="journey-sort-kicker">Journey evidence map</p>
<h4>分析购买旅程中的行为与体验</h4>
<p>请根据访谈记录,分析用户在购买旅程各阶段的行为表现,识别关键触点,并评估用户的情感体验水平。</p>
</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="journey-map-intro">
<span>Step 03</span>
<h4>绘制用户购买旅程图</h4>
<p>基于步骤二的分析结果,绘制完整的用户购买旅程图,呈现旅程阶段、情感体验曲线、关键触点、痛点和机会点。</p>
</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>
<div class="journey-map-helper">
<div>
<h5>推荐工具及方法如下,自行选择:</h5>
<p>1. 下载模板,填写各阶段数据,完成用户购买旅程图。</p>
<p>2. 下载步骤二用户购买旅程表,使用通用 AI 大模型辅助生成。</p>
<p class="journey-map-prompt">参考提示词:请根据用户购买旅程表文件,生成一个美观的用户购买旅程图。</p>
</div>
<el-button plain @click="downloadJourneyMapTemplate"><el-icon><Download /></el-icon>下载用户购买旅程模板</el-button>
</div>
</section>
<section v-show="isSectionVisible(4)" class="scenario-step-section">
<div class="journey-optimization-intro">
<span>Step 04</span>
<h4>识别旅程痛点,形成优化建议</h4>
<p>结合用户购买旅程图,分析各阶段存在的痛点及未满足的需求,提出可落地的产品优化建议。</p>
</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-actions">
<button type="button" class="nav-action nav-action--previous" :disabled="form.activeStep === 1" @click="goPreviousStep">
<el-icon><ArrowLeft /></el-icon>
上一步
</button>
<button type="button" class="nav-action nav-action--save" :disabled="saving" @click="saveCurrentProgress">
<el-icon><CircleCheck /></el-icon>
保存当前进度
</button>
<button type="button" class="nav-action nav-action--next" :disabled="form.activeStep >= trainingSteps.length" @click="goNextStep">
下一步
<el-icon><ArrowRight /></el-icon>
</button>
</div>
<div class="training-actions">
<el-button class="training-action training-action--submit" :loading="saving" @click="submitTask">
提交任务
</el-button>
<el-button class="training-action training-action--reset" @click="resetTraining">
重新实训
</el-button>
</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 TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
import { getTrainingTaskByKey } from "@/api/trainingTask";
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 taskBackground = computed(() => taskConfig.value?.background || defaultBackground);
const taskGoals = computed(() => parseArray(taskConfig.value?.objectives, defaultGoals));
const taskRequirement = computed(() => taskConfig.value?.requirements || defaultRequirement);
const trainingSteps = computed(() => parseArray(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 downloadJourneyMapTemplate() {
const escapeCsvValue = (value) => `"${String(value || "").replaceAll('"', '""')}"`;
const header = ["序号", "旅程阶段", "用户行为描述", "关键触点", "情感体验", "关键决策因素", "痛点标记", "机会点标记"];
const rows = form.value.journeyAnalysis.map((row, index) => [
index + 1,
stageTitle(row.stageId),
row.behaviorDescription,
row.keyTouchpoints,
row.emotionExperience,
row.decisionFactors,
"",
"",
]);
const csv = [header, ...rows].map((row) => row.map(escapeCsvValue).join(",")).join("\r\n");
const url = URL.createObjectURL(new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" }));
const link = document.createElement("a");
link.href = url;
link.download = "用户购买旅程图模板.csv";
link.click();
URL.revokeObjectURL(url);
}
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 {
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: #e8f7ff;
background: #06111d;
}
.training-core {
min-width: 0;
padding: 28px;
border: 1px solid rgba(0, 180, 255, 0.25);
border-radius: 36px;
background: rgba(8, 18, 28, 0.6);
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.22), inset 0 1px 0 rgba(169, 229, 255, 0.08);
backdrop-filter: blur(8px);
}
.task-header {
margin-bottom: 24px;
}
.task-badge {
display: inline-flex;
margin-bottom: 12px;
padding: 4px 14px;
border: 1px solid rgba(0, 170, 255, 0.5);
border-radius: 30px;
color: #d6f6ff;
background: rgba(0, 170, 255, 0.2);
font-size: 12px;
font-weight: 700;
}
.task-title {
margin: 0;
color: #ffffff;
font-size: 30px;
line-height: 1.3;
font-weight: 900;
}
.goal-bg,
.task-card,
.step-card {
margin-bottom: 28px;
padding: 18px 22px;
border: 1px solid rgba(82, 174, 226, 0.42);
border-radius: 26px;
background: rgba(0, 25, 42, 0.64);
}
.goal-bg {
p,
li {
color: #d7ecff;
font-size: 14px;
line-height: 1.8;
}
p,
ol {
margin: 0;
}
ol {
padding-left: 18px;
}
}
.subtitle-icon {
display: inline-flex;
align-items: center;
gap: 8px;
color: #eafaff;
font-size: 18px;
font-weight: 900;
}
.subtitle-icon--spaced {
margin-top: 16px;
}
.task-card {
margin-top: 0;
}
.workbench-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 18px;
margin-bottom: 22px;
padding-bottom: 18px;
border-bottom: 1px solid rgba(0, 180, 255, 0.2);
}
.section-eyebrow {
margin: 0;
color: #71dfff;
font-size: 12px;
font-weight: 800;
line-height: 1.4;
}
.workbench-head h3 {
margin: 4px 0 8px;
color: #ffffff;
font-size: 26px;
font-weight: 800;
line-height: 1.3;
}
.workbench-actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
margin-left: auto;
gap: 10px;
}
.workbench-description {
margin: 0;
color: #d7ecff;
font-size: 15px;
font-weight: 400;
line-height: 1.8;
}
.outline-action {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 40px;
padding: 0 18px;
border: 1px solid rgba(99, 217, 255, 0.55);
border-radius: 999px;
color: #e9fbff;
background: rgba(17, 126, 178, 0.36);
font-size: 14px;
font-weight: 800;
&:hover {
transform: translateY(-1px);
border-color: rgba(116, 235, 255, 0.85);
background: rgba(18, 146, 204, 0.5);
}
}
.step-indicator {
position: relative;
display: flex;
align-items: center;
gap: 0;
width: calc(100% + 24px);
margin: 0 -12px 32px;
padding: 18px 22px;
overflow-x: auto;
border: 1px solid rgba(0, 180, 255, 0.3);
border-radius: 18px;
background:
linear-gradient(90deg, rgba(6, 42, 61, 0.58), rgba(1, 18, 31, 0.42)),
rgba(0, 0, 0, 0.3);
box-shadow: inset 0 1px 0 rgba(145, 233, 255, 0.08);
scrollbar-width: thin;
}
.step-tab {
display: flex;
flex: 1 0 145px;
align-items: center;
min-width: 0;
min-height: 66px;
padding: 8px 10px;
border: 1px solid rgba(68, 141, 177, 0.35);
border-radius: 8px;
color: #9ab3d0;
background: rgba(3, 27, 43, 0.52);
box-shadow: inset 0 1px 0 rgba(135, 221, 255, 0.04);
font-family: inherit;
font-size: 14px;
font-weight: 800;
text-align: left;
cursor: pointer;
transition: transform 0.2s ease, color 0.2s ease, border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
border-color: rgba(89, 223, 255, 0.82);
color: #ffffff;
background: rgba(8, 92, 131, 0.52);
box-shadow: 0 7px 18px rgba(0, 0, 0, 0.24), inset 0 1px 0 rgba(152, 239, 255, 0.16);
.step-action {
color: #9bf4ff;
}
}
&:focus-visible {
outline: 2px solid rgba(92, 224, 255, 0.7);
outline-offset: 2px;
}
&.active {
border-color: rgba(89, 226, 255, 0.85);
color: #ffffff;
background: linear-gradient(115deg, rgba(4, 109, 155, 0.78), rgba(5, 62, 102, 0.64));
box-shadow: 0 0 18px rgba(24, 183, 235, 0.22), inset 0 1px 0 rgba(176, 246, 255, 0.2);
.step-marker {
border-color: #72e8ff;
color: #ffffff;
background: linear-gradient(135deg, #08b7e8, #1375d0);
box-shadow: 0 0 0 5px rgba(25, 179, 237, 0.14), 0 0 18px rgba(46, 208, 255, 0.58);
}
}
&.complete {
color: #dffbff;
.step-marker {
border-color: rgba(84, 230, 221, 0.85);
color: #062b37;
background: #6ef0e1;
box-shadow: 0 0 12px rgba(79, 231, 218, 0.35);
}
}
}
.step-marker {
display: inline-flex;
flex: 0 0 38px;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border: 1px solid rgba(101, 176, 208, 0.6);
border-radius: 50%;
color: #a6c4d9;
background: rgba(3, 25, 40, 0.8);
box-shadow: inset 0 0 12px rgba(38, 174, 233, 0.08);
font-size: 12px;
font-weight: 900;
letter-spacing: 0.04em;
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
}
.step-copy {
display: grid;
min-width: 0;
gap: 4px;
margin-left: 10px;
}
.step-name {
display: -webkit-box;
overflow: hidden;
color: inherit;
font-size: 14px;
font-weight: 800;
line-height: 1.25;
text-overflow: ellipsis;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.step-action {
color: #66b6d4;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.06em;
line-height: 1;
transition: color 0.2s ease;
}
.step-connector {
position: relative;
flex: 0 1 40px;
min-width: 18px;
height: 2px;
margin: 0 8px;
overflow: visible;
background: rgba(98, 169, 201, 0.34);
}
.step-connector::after {
position: absolute;
top: 50%;
right: -1px;
width: 6px;
height: 6px;
border-top: 1px solid #73b1ce;
border-right: 1px solid #73b1ce;
content: "";
transform: translateY(-50%) rotate(45deg);
}
.step-tab.complete + .step-connector {
background: linear-gradient(90deg, #6ef0e1, #1cb8e9);
box-shadow: 0 0 9px rgba(53, 218, 235, 0.46);
&::after {
border-color: #63e6f1;
}
}
.step-card {
margin-bottom: 0;
}
.step-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 18px;
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 1px solid rgba(0, 180, 255, 0.2);
h3 {
margin: 4px 0 0;
color: #ffffff;
font-size: 22px;
font-weight: 900;
line-height: 1.3;
}
}
.scenario-step-section + .scenario-step-section {
margin-top: 0;
}
.journey-head {
margin: 0;
padding-bottom: 18px;
border-bottom: 1px solid rgba(0, 180, 255, 0.2);
}
.journey-head h4 {
margin: 0;
color: #ffffff;
font-size: 22px;
font-weight: 900;
}
.journey-sort-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-bottom: 22px;
padding: 20px;
border: 1px solid rgba(91, 218, 255, 0.28);
border-radius: 18px;
background: linear-gradient(120deg, rgba(7, 94, 132, 0.26), rgba(2, 26, 43, 0.72));
h4 {
margin: 5px 0 8px;
color: #ffffff;
font-size: 21px;
line-height: 1.35;
}
p {
max-width: 700px;
margin: 0;
color: #b9d8e9;
font-size: 14px;
line-height: 1.75;
}
}
.journey-sort-kicker {
color: #69e3ff !important;
font-size: 12px !important;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.journey-check-action {
flex: 0 0 auto;
min-height: 42px;
padding: 0 18px;
border: 1px solid rgba(112, 235, 255, 0.74);
border-radius: 10px;
color: #ecfdff;
background: linear-gradient(135deg, #078ac2, #07558a);
box-shadow: 0 8px 20px rgba(0, 145, 203, 0.2);
font: inherit;
font-size: 14px;
font-weight: 800;
cursor: pointer;
&:hover:not(:disabled) {
transform: translateY(-1px);
background: linear-gradient(135deg, #0ba9df, #0870aa);
}
&:disabled {
cursor: not-allowed;
opacity: 0.64;
}
}
.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 rgba(63, 174, 222, 0.48);
border-radius: 14px;
color: #ecfbff;
background: linear-gradient(150deg, rgba(8, 74, 109, 0.82), rgba(3, 24, 42, 0.9));
box-shadow: inset 0 1px 0 rgba(201, 247, 255, 0.1), 0 12px 24px rgba(0, 0, 0, 0.16);
cursor: grab;
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, opacity 0.18s ease;
&:hover {
transform: translateY(-3px);
border-color: rgba(106, 235, 255, 0.94);
box-shadow: inset 0 1px 0 rgba(201, 247, 255, 0.16), 0 16px 30px rgba(0, 0, 0, 0.28), 0 0 20px rgba(15, 199, 242, 0.14);
}
&.dragging {
opacity: 0.45;
cursor: grabbing;
}
}
.journey-card-order {
color: #78e9ff;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.08em;
}
.journey-card-copy {
align-self: center;
strong,
span {
display: block;
}
strong {
margin-bottom: 8px;
color: #ffffff;
font-size: 15px;
line-height: 1.45;
}
span {
color: #b6d9e9;
font-size: 12px;
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 rgba(111, 220, 246, 0.45);
border-radius: 7px;
color: #dffbff;
background: rgba(4, 41, 65, 0.76);
font: inherit;
font-weight: 900;
cursor: pointer;
&:hover:not(:disabled) {
border-color: #72e8ff;
background: rgba(9, 119, 160, 0.9);
}
&:disabled {
cursor: not-allowed;
opacity: 0.28;
}
}
}
.journey-sort-arrow {
justify-self: center;
color: #42cfff;
font-size: 20px;
font-weight: 900;
}
.journey-check-message {
margin: 20px 0 0;
padding: 11px 14px;
border: 1px solid rgba(255, 136, 106, 0.7);
border-radius: 10px;
color: #ffd2c7;
background: rgba(127, 45, 30, 0.25);
font-size: 14px;
font-weight: 700;
&.success {
border-color: rgba(90, 238, 198, 0.65);
color: #d6fff3;
background: rgba(16, 122, 98, 0.25);
}
}
.journey-analysis-intro {
margin-bottom: 18px;
padding: 20px;
border-left: 3px solid #3bd8ff;
border-radius: 0 16px 16px 0;
background: linear-gradient(90deg, rgba(7, 95, 137, 0.28), rgba(4, 31, 49, 0.18));
h4 {
margin: 4px 0 8px;
color: #ffffff;
font-size: 21px;
}
p {
margin: 0;
color: #c3dfec;
font-size: 14px;
line-height: 1.8;
}
}
.journey-analysis-wrap {
overflow-x: auto;
border: 1px solid rgba(72, 182, 228, 0.46);
border-radius: 16px;
background: rgba(2, 20, 33, 0.72);
}
.journey-analysis-table {
width: 100%;
min-width: 1120px;
border-collapse: collapse;
table-layout: fixed;
th,
td {
border: 1px solid rgba(77, 167, 220, 0.28);
}
thead th {
padding: 13px 10px;
color: #effcff;
background: linear-gradient(90deg, rgba(8, 128, 178, 0.94), rgba(5, 94, 143, 0.9));
font-size: 14px;
font-weight: 900;
text-align: center;
}
tbody tr {
background: rgba(2, 17, 29, 0.48);
&:nth-child(even) {
background: rgba(9, 47, 67, 0.34);
}
}
td,
tbody th {
padding: 10px;
vertical-align: top;
}
th:nth-child(1) { width: 54px; }
th:nth-child(2) { width: 132px; }
th:nth-child(3), th:nth-child(4), th:nth-child(6) { width: 27%; }
th:nth-child(5) { width: 118px; }
textarea,
select {
width: 100%;
border: 1px solid transparent;
border-radius: 8px;
color: #eaf9ff;
background: transparent;
font: inherit;
font-size: 13px;
line-height: 1.65;
outline: none;
box-sizing: border-box;
&:focus {
border-color: rgba(92, 224, 255, 0.76);
background: rgba(8, 64, 92, 0.42);
box-shadow: 0 0 0 3px rgba(34, 207, 255, 0.1);
}
}
textarea {
min-height: 112px;
padding: 8px;
resize: vertical;
}
textarea::placeholder {
color: #7897a8;
}
select {
min-height: 38px;
margin-top: 30px;
padding: 0 9px;
border-color: rgba(99, 177, 211, 0.6);
background: rgba(6, 38, 58, 0.78);
}
}
.journey-analysis-index,
.journey-analysis-stage {
color: #dff7ff;
font-weight: 800;
text-align: center;
vertical-align: middle !important;
}
.journey-analysis-stage {
color: #9defff;
line-height: 1.65;
}
.journey-map-intro {
margin-bottom: 18px;
padding: 20px 22px;
border: 1px solid rgba(69, 202, 249, 0.36);
border-radius: 18px;
background: linear-gradient(112deg, rgba(9, 99, 145, 0.28), rgba(2, 22, 38, 0.48));
span {
color: #51d8ff;
font-size: 13px;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
}
h4 {
margin: 6px 0 8px;
color: #ffffff;
font-size: 22px;
}
p {
margin: 0;
color: #bed8e6;
font-size: 14px;
line-height: 1.8;
}
}
.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 rgba(78, 210, 255, 0.64);
border-radius: 20px;
color: #e9fbff;
background: radial-gradient(circle at 50% 32%, rgba(13, 119, 171, 0.22), rgba(2, 18, 31, 0.52) 62%);
strong {
font-size: 18px;
}
p {
margin: 0 0 8px;
color: #9abac9;
font-size: 13px;
}
}
.journey-map-upload-icon {
width: 52px;
height: 52px;
display: grid;
place-items: center;
border: 1px solid rgba(94, 222, 255, 0.62);
border-radius: 50%;
color: #71e5ff;
background: rgba(8, 100, 148, 0.3);
font-size: 25px;
}
.journey-map-preview {
width: min(100%, 760px);
max-height: 360px;
overflow: hidden;
border: 1px solid rgba(90, 214, 255, 0.45);
border-radius: 12px;
background: #03111c;
cursor: zoom-in;
:deep(img) {
object-fit: contain;
}
}
.journey-map-file-info {
max-width: 100%;
display: flex;
align-items: center;
gap: 8px;
color: #cbf5ff;
font-size: 14px;
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.journey-map-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
.journey-map-helper {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
margin-top: 20px;
padding: 18px 20px;
border: 1px solid rgba(61, 162, 210, 0.28);
border-radius: 14px;
color: #b9d4df;
background: rgba(3, 27, 43, 0.46);
h5,
p {
margin: 0;
font-size: 13px;
line-height: 1.8;
}
h5 {
color: #e5faff;
font-size: 14px;
}
.journey-map-prompt {
margin-top: 4px;
color: #80dff8;
}
.el-button {
flex: 0 0 auto;
}
}
.journey-optimization-intro {
margin-bottom: 18px;
padding: 20px 22px;
border-left: 3px solid #41d9ff;
border-radius: 0 16px 16px 0;
background: linear-gradient(90deg, rgba(7, 98, 143, 0.3), rgba(3, 27, 42, 0.12));
span {
color: #51d8ff;
font-size: 13px;
font-weight: 900;
letter-spacing: 0.08em;
}
h4 {
margin: 6px 0 8px;
color: #ffffff;
font-size: 21px;
}
p {
margin: 0;
color: #c4dfe9;
font-size: 14px;
line-height: 1.8;
}
}
.journey-optimization-wrap {
overflow-x: auto;
border: 1px solid rgba(72, 182, 228, 0.46);
border-radius: 16px;
background: rgba(2, 20, 33, 0.72);
}
.journey-optimization-table {
width: 100%;
min-width: 900px;
border-collapse: collapse;
table-layout: fixed;
th,
td {
border: 1px solid rgba(77, 167, 220, 0.28);
}
thead th {
padding: 14px 12px;
color: #f0fcff;
background: linear-gradient(90deg, rgba(8, 128, 178, 0.94), rgba(5, 94, 143, 0.9));
font-size: 14px;
font-weight: 900;
text-align: center;
}
thead th:first-child {
width: 170px;
}
tbody th {
padding: 18px 14px;
color: #a6ecff;
background: rgba(3, 36, 55, 0.58);
font-size: 14px;
font-weight: 800;
text-align: center;
}
td {
padding: 10px;
background: rgba(1, 17, 29, 0.45);
}
textarea {
width: 100%;
min-height: 92px;
padding: 10px 12px;
border: 1px solid transparent;
border-radius: 9px;
outline: none;
resize: vertical;
color: #ebfaff;
background: transparent;
font: inherit;
font-size: 13px;
line-height: 1.65;
box-sizing: border-box;
&:focus {
border-color: rgba(92, 224, 255, 0.72);
background: rgba(8, 64, 92, 0.42);
box-shadow: 0 0 0 3px rgba(34, 207, 255, 0.1);
}
}
textarea::placeholder {
color: #7696a8;
}
}
.journey-flow {
display: grid;
grid-template-columns: repeat(5, minmax(120px, 1fr));
align-items: center;
gap: 28px;
margin-top: 18px;
overflow-x: auto;
}
.phase-node {
position: relative;
min-height: 78px;
display: grid;
place-items: center;
padding: 14px 16px;
border: 1px solid rgba(0, 180, 255, 0.22);
border-radius: 14px;
color: #dff8ff;
background: rgba(1, 14, 26, 0.72);
font-size: 14px;
font-weight: 800;
text-align: center;
}
.phase-arrow {
display: none;
}
.phase-node:not(:last-child)::after {
content: "";
position: absolute;
top: 50%;
right: -29px;
width: 28px;
height: 2px;
background: #2bb8ff;
transform: translateY(-50%);
}
.phase-node:not(:last-child)::before {
content: "";
position: absolute;
top: 50%;
right: -31px;
width: 8px;
height: 8px;
border-top: 2px solid #2bb8ff;
border-right: 2px solid #2bb8ff;
transform: translateY(-50%) rotate(45deg);
}
.requirement {
margin: 20px 0 16px;
color: #d7ecff;
font-size: 15px;
font-weight: 400;
line-height: 1.8;
}
.scenario-list {
display: grid;
gap: 10px;
margin-bottom: 18px;
}
.scenario-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 220px;
gap: 12px;
align-items: center;
color: #d7ecff;
font-size: 14px;
line-height: 1.7;
}
.phase-select {
width: 100%;
min-height: 42px;
padding: 0 14px;
border: 1px solid rgba(45, 95, 126, 0.95);
border-radius: 14px;
color: #eef5ff;
background: #0f1a24;
font: inherit;
&:focus {
border-color: #5fe7ff;
background: rgba(10, 31, 46, 0.98);
box-shadow: 0 0 0 3px rgba(34, 207, 255, 0.14);
}
}
.journey-table-wrap {
overflow-x: auto;
border: 1px solid rgba(0, 180, 255, 0.22);
border-radius: 22px;
background: rgba(1, 14, 26, 0.72);
}
.journey-table {
width: 100%;
min-width: 980px;
border-collapse: collapse;
table-layout: fixed;
}
.journey-table th,
.journey-table td {
border: 1px solid rgba(77, 167, 220, 0.2);
}
.journey-table thead th {
height: 46px;
padding: 12px;
color: #eaffff;
background: rgba(8, 55, 82, 0.98);
font-weight: 900;
}
.journey-table tbody th {
width: 120px;
padding: 12px;
color: #eafaff;
background: rgba(1, 14, 26, 0.72);
font-weight: 900;
}
.row-head {
width: 120px;
}
.journey-table td {
height: 108px;
padding: 12px;
background: rgba(1, 14, 26, 0.48);
}
.table-input {
display: block;
width: 100%;
min-height: 110px;
padding: 12px 14px;
border: 1px solid rgba(45, 95, 126, 0.95);
border-radius: 14px;
outline: none;
resize: none;
color: #eef5ff;
background: #0f1a24;
font-family: inherit;
font-size: 13px;
line-height: 1.6;
box-sizing: border-box;
}
.table-input::placeholder {
color: #8299aa;
}
.table-input:focus {
border-color: #5fe7ff;
background: rgba(10, 31, 46, 0.98);
box-shadow: 0 0 0 3px rgba(34, 207, 255, 0.14);
}
.reference-map {
margin-top: 28px;
padding: 16px;
border: 1px solid rgba(0, 180, 255, 0.22);
border-radius: 22px;
background: rgba(1, 14, 26, 0.72);
}
.step-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 14px;
margin-top: 22px;
margin-bottom: 36px;
}
.nav-action {
min-width: 112px;
min-height: 40px;
padding: 0 18px;
border: 1px solid rgba(99, 217, 255, 0.55);
border-radius: 999px;
color: #e9fbff;
background: rgba(17, 126, 178, 0.36);
font-family: inherit;
font-size: 14px;
font-weight: 800;
cursor: pointer;
&:disabled {
cursor: not-allowed;
opacity: 0.45;
}
}
.nav-action--next {
border-color: rgba(92, 224, 255, 0.7);
color: #ffffff;
background: linear-gradient(95deg, #0b84bd, #096491);
}
.nav-action--save {
border-color: rgba(92, 224, 255, 0.7);
color: #ffffff;
background: linear-gradient(95deg, #0b84bd, #096491);
}
.reference-title {
margin-bottom: 14px;
color: #dff5ff;
font-size: 15px;
font-weight: 800;
}
.reference-grid {
display: grid;
grid-template-columns: 120px repeat(6, minmax(0, 1fr));
gap: 8px;
margin-top: 8px;
}
.reference-side,
.stage-chip,
.reference-cell,
.behavior-lane,
.emotion-chart {
border: 1px solid rgba(0, 180, 255, 0.22);
background: rgba(1, 14, 26, 0.48);
}
.reference-side {
display: grid;
place-items: center;
min-height: 54px;
color: #dff5ff;
font-weight: 900;
}
.stage-chip {
min-height: 34px;
display: grid;
place-items: center;
color: #eaffff;
background: rgba(8, 55, 82, 0.98);
font-weight: 900;
}
.reference-cell {
min-height: 58px;
padding: 10px;
color: #d7ecff;
font-size: 12px;
line-height: 1.55;
}
.reference-cell p {
margin: 0;
}
.reference-grid--behavior,
.reference-grid--emotion {
grid-template-columns: 120px minmax(0, 1fr);
}
.behavior-lane {
min-height: 130px;
display: flex;
align-items: center;
gap: 22px;
padding: 16px;
overflow-x: auto;
}
.behavior-node {
position: relative;
min-width: 82px;
color: #d9f1ff;
font-size: 12px;
text-align: center;
}
.behavior-node::before {
content: "";
display: block;
width: 30px;
height: 30px;
margin: 0 auto 8px;
border-radius: 50%;
background: linear-gradient(180deg, #38c8ef, #0b84bd);
box-shadow: 0 0 16px rgba(0, 174, 255, 0.24);
}
.behavior-node:not(:last-child)::after {
content: "";
position: absolute;
top: 15px;
left: calc(50% + 26px);
width: 56px;
height: 2px;
background: rgba(92, 224, 255, 0.5);
}
.emotion-chart {
position: relative;
min-height: 210px;
padding: 10px;
overflow: hidden;
}
.emotion-chart svg {
width: 100%;
height: 190px;
}
.emotion-points span {
position: absolute;
display: grid;
gap: 2px;
color: #f5fbff;
font-size: 18px;
text-align: center;
}
.emotion-points small {
color: #d7ecff;
font-size: 11px;
}
.training-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 52px;
margin-top: 36px;
}
.training-action {
min-width: 172px;
height: 64px;
padding: 0 28px;
border: 1px solid #2c83aa;
border-radius: 6px;
color: #dff7ff;
background: rgba(13, 64, 88, 0.62);
box-shadow: none;
font-size: 24px;
font-weight: 900;
line-height: 1;
letter-spacing: 0;
text-shadow: none;
}
.training-action:hover:not(.is-disabled) {
border-color: #46b5e7;
color: #ffffff;
background: rgba(18, 85, 116, 0.78);
}
@media (max-width: 1100px) {
.consumer-scenario-page {
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) {
.consumer-scenario-page {
padding: 14px 14px 18px;
}
.training-core {
padding: 18px;
border-radius: 24px;
}
.workbench-head,
.workbench-actions,
.step-title-row,
.step-actions,
.training-actions {
flex-direction: column;
align-items: stretch;
}
.workbench-actions :deep(.material-download),
.workbench-actions .outline-action {
width: 100%;
}
.step-indicator {
align-items: stretch;
flex-direction: column;
width: 100%;
margin-right: 0;
margin-left: 0;
overflow: visible;
border-radius: 20px;
}
.step-tab {
flex: 0 0 auto;
width: 100%;
}
.step-connector {
flex: 0 0 20px;
min-width: 0;
width: 2px;
height: 20px;
margin-left: 29px;
text-align: center;
writing-mode: vertical-rl;
}
.journey-flow,
.scenario-row {
grid-template-columns: 1fr;
}
.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);
}
.journey-map-helper {
align-items: stretch;
flex-direction: column;
}
.journey-map-helper .el-button {
width: 100%;
}
.nav-action,
.training-action {
width: 100%;
}
.phase-node::before,
.phase-node::after {
display: none;
}
.reference-grid,
.reference-grid--behavior,
.reference-grid--emotion {
grid-template-columns: 1fr;
}
}
.workbench-head--actions-only {
justify-content: flex-end;
margin-bottom: 18px;
padding-bottom: 0;
border-bottom: 0;
}
</style>