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

1324 lines
34 KiB
Vue

<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-head">
<h4>用户购买旅程</h4>
<div class="journey-flow" aria-label="">
<template v-for="(phase, index) in phases" :key="phase.key">
<div class="phase-node">{{ phase.label }}</div>
<div v-if="index < phases.length - 1" class="phase-arrow" aria-hidden="true"></div>
</template>
</div>
</div>
</section>
<section v-show="isSectionVisible(2)" class="scenario-step-section">
<p class="requirement">
需求说明上述几项内容让学生拖动到相应框中进行排序完成用户旅程地图填充
</p>
<div class="scenario-list">
<label v-for="item in scenarioStatements" :key="item.key" class="scenario-row">
<span>{{ item.text }}</span>
<select v-model="form.classifications[item.key]" class="phase-select">
<option v-for="phase in phases" :key="phase.key" :value="phase.key">{{ phase.label }}</option>
</select>
</label>
</div>
</section>
<section v-show="isSectionVisible(3)" class="scenario-step-section">
<div class="journey-table-wrap">
<table class="journey-table">
<thead>
<tr>
<th class="row-head">阶段</th>
<th v-for="phase in phases" :key="phase.key">{{ phase.label }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in journeyRows" :key="row.key">
<th>{{ row.label }}</th>
<td v-for="phase in phases" :key="phase.key">
<textarea
v-model="form.matrix[row.key][phase.key]"
:placeholder="row.placeholder"
class="table-input"
></textarea>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section v-show="isSectionVisible(4)" class="scenario-step-section">
<section class="reference-map">
<div class="reference-title">示例APP 用户体验地图</div>
<div class="reference-grid reference-grid--stage">
<div class="reference-side">阶段</div>
<div v-for="phase in referencePhases" :key="phase.name" class="stage-chip">{{ phase.name }}</div>
</div>
<div class="reference-grid">
<div class="reference-side">用户目标</div>
<div v-for="phase in referencePhases" :key="phase.name" class="reference-cell">
<p v-for="goal in phase.goals" :key="goal">{{ goal }}</p>
</div>
</div>
<div class="reference-grid reference-grid--behavior">
<div class="reference-side">用户行为</div>
<div class="behavior-lane">
<div v-for="item in behaviorNodes" :key="item" class="behavior-node">
<span>{{ item }}</span>
</div>
</div>
</div>
<div class="reference-grid">
<div class="reference-side">接触点</div>
<div v-for="phase in referencePhases" :key="phase.name" class="reference-cell">
<p>{{ phase.touchpoint }}</p>
</div>
</div>
<div class="reference-grid reference-grid--emotion">
<div class="reference-side">情绪曲线</div>
<div class="emotion-chart">
<svg viewBox="0 0 1000 210" role="img" aria-label="线">
<defs>
<linearGradient id="emotionFill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#28c7ef" stop-opacity="0.28" />
<stop offset="100%" stop-color="#28c7ef" stop-opacity="0.04" />
</linearGradient>
</defs>
<path d="M20 160 C80 140 100 74 160 92 S250 170 300 126 S390 60 440 126 S520 174 570 118 S650 64 710 110 S790 174 850 140 S930 120 980 96 L980 200 L20 200 Z" fill="url(#emotionFill)" />
<path d="M20 160 C80 140 100 74 160 92 S250 170 300 126 S390 60 440 126 S520 174 570 118 S650 64 710 110 S790 174 850 140 S930 120 980 96" fill="none" stroke="#28c7ef" stroke-width="5" />
</svg>
<div class="emotion-points">
<span v-for="point in emotionPoints" :key="point.label" :style="{ left: point.left, top: point.top }">
{{ point.icon }}
<small>{{ point.label }}</small>
</span>
</div>
</div>
</div>
<div class="reference-grid">
<div class="reference-side">痛点/机会点</div>
<div v-for="phase in referencePhases" :key="phase.name" class="reference-cell">
<p>{{ phase.pain }}</p>
</div>
</div>
</section>
</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 } from "@element-plus/icons-vue";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
import { getTrainingTaskByKey } from "@/api/trainingTask";
import { getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
const TASK_KEY = "consumer-scenario";
const { proxy } = getCurrentInstance();
const defaultRequirement =
"消费场景解构用于把抽象需求落到具体时间、地点、人物、任务和情绪中,找到用户真正发生问题的瞬间,从而发现产品机会和功能设计切入点。";
const defaultBackground =
"很多智能产品失败并不是功能少,而是没有进入用户真实生活场景。通过拆解场景,可以识别触发因素、使用阻碍、情绪变化和可被产品改善的关键节点。";
const defaultGoals = [
"能够描述目标用户的典型消费场景。",
"识别场景中的触发点、痛点和阻碍因素。",
"将场景洞察转化为产品机会。",
];
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);
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 createForm() {
return {
activeStep: 1,
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 progressItems = computed(() => [
{ text: isClassifiedCorrectly.value ? "已完成:旅程阶段判断" : "进行中:旅程阶段判断", status: isClassifiedCorrectly.value ? "done" : "doing" },
{ text: hasMatrixContent.value ? "进行中:用户旅程地图填写" : "待完成:用户旅程地图填写", status: hasMatrixContent.value ? "doing" : "" },
{ text: "待完成:提交实训成果", status: "" },
]);
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())
)
);
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),
classifications: {
...createClassifications(),
...(parsed.classifications || {}),
},
matrix: normalizeMatrix(parsed.matrix),
};
} catch (error) {
// Ignore malformed historical answers.
}
}
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;
}
function setCurrentStep(step) {
if (!hasConfiguredSteps.value) return;
form.value.activeStep = normalizeActiveStep(step);
}
function isSectionVisible(section) {
if (!hasConfiguredSteps.value) return true;
return Math.min(form.value.activeStep, 4) === section;
}
function goPreviousStep() {
setCurrentStep(form.value.activeStep - 1);
}
function goNextStep() {
setCurrentStep(form.value.activeStep + 1);
}
function buildOutcome() {
return {
taskKey: TASK_KEY,
taskId: taskConfig.value?.id || "",
taskName: taskTitle.value,
currentStep: form.value.activeStep,
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;
saving.value = true;
try {
await saveStudentTrainingAnswer(TASK_KEY, buildAnswerPayload(status));
proxy?.$modal?.msgSuccess(status === "SUBMITTED" ? "提交成功" : "保存成功");
} finally {
saving.value = false;
}
}
function saveCurrentProgress() {
return persist("IN_PROGRESS");
}
function submitTask() {
return persist("SUBMITTED");
}
async function resetTraining() {
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-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;
}
.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>