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/people.vue

2461 lines
87 KiB
Vue

<template>
<div class="target-profile-page student-training-shell">
<main class="training-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-export="exportOutcome" :on-reset="resetTraining" />
</section>
<section class="task-card">
<nav v-if="steps.length" class="step-indicator" aria-label="目标用户画像步骤">
<button v-for="(step, index) in steps" :key="step.no" type="button"
:class="['step-tab', { active: currentStep === step.no, complete: currentStep > step.no }]"
:aria-label="`第 ${step.no} 步:${step.name}`"
:aria-current="currentStep === step.no ? 'step' : undefined" :title="step.name"
@click="currentStep = step.no">
<span class="step-bar" aria-hidden="true"></span>
<span class="step-no" aria-hidden="true">{{ String(step.no).padStart(2, '0') }}</span>
<span class="step-name">{{ step.name }}</span>
</button>
</nav>
<section class="step-card target-profile-step-card">
<div v-if="activeStepName" class="step-title-row">
<div>
<p class="section-eyebrow">Step {{ String(currentStep).padStart(2, '0') }}</p>
<h3>{{ activeStepName }}</h3>
</div>
</div>
<section v-show="currentStep === 1" class="step-panel">
<p class="task-desc">
公司团队通过第三方数据公司采集了电商数据上购买相关产品的用户行为数据,包含用户 ID、最近购买天数(R)、购买次数(F)、消费金额(M)等字段。请完成 RFM
分析和用户分层后,上传分析结果 Excel 数据。
</p>
<div class="upload-panel">
<input ref="fileInputRef" type="file" class="file-input" accept=".xls,.xlsx"
@change="handleFileChange" />
<el-button class="upload-action" :loading="uploading" @click="triggerFileSelect">
<el-icon>
<Upload />
</el-icon>
上传 RFM 用户分层分析结果 Excel 数据
</el-button>
<div v-if="form.profileFileName" class="file-chip">
<el-icon>
<Document />
</el-icon>
<span>{{ form.profileFileName }}</span>
</div>
<el-button v-if="hasExcelPreview" class="preview-action"
@click="previewDialogVisible = true">
预览数据
</el-button>
</div>
<p class="upload-tip">仅支持 .xls、.xlsx 文件,上传后可预览首个工作表的表头和前 20 行数据。</p>
<div class="step-one-materials" aria-label="RFM 分析辅助资料">
<p>推荐工具及方法如下,自行选择:</p>
<p>1. 使用 Excel/WPS 完成 RFM 用户分层分析,可参考《实训操作说明》。</p>
<p>2. 下载 Excel 模板,填写案例数据并完成计算与可视化。</p>
<div class="step-one-materials__actions">
<el-button class="preview-action" @click="downloadRfmInstructions">下载实训操作说明</el-button>
<el-button class="upload-action" @click="downloadRfmTemplate">下载 Excel 模板</el-button>
</div>
</div>
<el-dialog v-model="previewDialogVisible" title="RFM 用户分层分析结果预览" width="min(960px, 92vw)"
append-to-body>
<div class="excel-preview-meta">
工作表:{{ form.profilePreview.sheetName || "Sheet1" }} 共 {{ form.profilePreview.totalRows
|| 0 }} 行、{{ form.profilePreview.totalColumns || 0 }} 列,仅展示前 {{
form.profilePreview.rows.length }} 行数据。
</div>
<div class="excel-preview-wrap">
<table class="excel-preview-table">
<thead>
<tr>
<th v-for="(column, index) in form.profilePreview.columns"
:key="`${column}-${index}`">{{ column || `列${index + 1}` }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in form.profilePreview.rows" :key="rowIndex">
<td v-for="(_, columnIndex) in form.profilePreview.columns"
:key="columnIndex">{{ row[columnIndex] }}</td>
</tr>
</tbody>
</table>
</div>
</el-dialog>
</section>
<section v-show="currentStep === 2" class="step-panel">
<p class="task-desc task-desc--compact">
基于步骤一的 RFM 分析结果,制作可视化图表,包括客户分层占比饼图、各客户分层平均消费对比柱状图、RFM 分数分布热力图。
</p>
<div class="rfm-chart-grid">
<article v-for="chart in rfmCharts" :key="chart.id" class="rfm-chart-card">
<div class="rfm-chart-card__head">
<span class="rfm-chart-card__index">0{{ chart.index }}</span>
<h4>{{ chart.title }}</h4>
</div>
<input :ref="(element) => setChartInputRef(chart.id, element)" type="file"
class="file-input" accept=".png,.jpg,.jpeg,.webp"
@change="(event) => handleChartFileChange(chart, event)" />
<button type="button" class="rfm-chart-upload" :disabled="uploadingChartId === chart.id"
@click="triggerChartFileSelect(chart.id)">
<el-icon>
<Upload />
</el-icon>
{{ uploadingChartId === chart.id ? "上传中…" : chart.buttonText }}
</button>
<div v-if="form.rfmCharts[chart.id]?.url" class="rfm-chart-file">
<img :src="form.rfmCharts[chart.id].url" :alt="`${chart.title}缩略图`"
@click="openChartPreview(chart)" />
<div>
<strong>{{ form.rfmCharts[chart.id].name }}</strong>
<button type="button" @click="openChartPreview(chart)">预览图片</button>
</div>
</div>
<p v-else class="rfm-chart-empty">上传 PNG、JPG 或 WEBP 格式的图表</p>
</article>
</div>
<p class="upload-tip">三类图表均需上传。图片上传后可点击缩略图预览,保存后再次进入页面仍可查看。</p>
<el-dialog v-model="chartPreviewVisible" :title="activeChartPreview.title"
width="min(960px, 92vw)" append-to-body>
<img class="rfm-chart-preview" :src="activeChartPreview.url"
:alt="activeChartPreview.title" />
</el-dialog>
</section>
<section v-show="currentStep === 3" class="step-panel">
<p class="task-desc task-desc--compact">请根据数据可视化结果,分析用户群体特点:</p>
<div class="rfm-insight-table-wrap">
<table class="rfm-insight-table">
<thead>
<tr>
<th>分析维度</th>
<th>分析要点</th>
<th>你的分析</th>
</tr>
</thead>
<tbody>
<tr v-for="insight in form.rfmInsights" :key="insight.dimension">
<th scope="row">{{ insight.dimension }}</th>
<td>{{ insight.keyPoint }}</td>
<td>
<el-input v-model="insight.analysis" class="rfm-insight-input"
type="textarea" :autosize="{ minRows: 2 }"
:placeholder="`请结合${insight.dimension}填写分析结论`" />
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section v-show="currentStep === 4" class="step-panel">
<p class="task-desc task-desc--compact">
开发团队在用户分层后,已访谈了 3 位购买过相关产品的重点用户,并撰写了访谈记录。请根据访谈记录,从行为特征、需求痛点、消费心理三个维度构建重点用户画像。
</p>
<div class="interview-tabs" aria-label="重点用户访谈记录">
<button v-for="(record, index) in form.keyUserRecords" :key="record.code" type="button"
:class="['interview-tab', { active: activeInterviewIndex === index }]"
@click="handleInterviewClick(index)">
{{ record.title }}({{ record.code }})
</button>
</div>
<el-dialog v-model="interviewDialogVisible" :title="`${currentInterviewTranscript.code} 访谈记录`"
width="min(820px, 92vw)" append-to-body>
<div class="interview-dialog-tabs" aria-label="切换访谈记录">
<button v-for="(record, index) in form.keyUserRecords" :key="record.code" type="button"
:class="{ active: activeInterviewIndex === index }"
@click="activeInterviewIndex = index">
{{ record.code }}
</button>
</div>
<div class="interview-dialog-body interview-dialog-body--panel">
<p class="interview-line">基本属性:{{ currentInterviewTranscript.basicAttributes }}</p>
<p class="interview-title">访谈内容:</p>
<div v-for="item in currentInterviewTranscript.content" :key="item.question"
class="interview-qa">
<p>Q:{{ item.question }}</p>
<p>A:{{ item.answer }}</p>
</div>
</div>
</el-dialog>
<div class="portrait-table-wrap">
<section v-for="record in form.keyUserRecords" :key="record.code" class="portrait-record">
<div class="portrait-user portrait-user--form">
<label>
<span>用户:</span>
<el-input v-model="record.user" class="portrait-input"
:placeholder="`例如:${record.code}`" />
</label>
<label>
<span>基础属性:</span>
<el-input v-model="record.basicAttributes"
class="portrait-input portrait-input--wide" placeholder="年龄、性别、职业、收入、城市等" />
</label>
<label>
<span>标签:</span>
<el-input v-model="record.tags" class="portrait-input portrait-input--wide"
placeholder="例如:高价值用户、品质偏好者" />
</label>
</div>
<div class="portrait-grid">
<div class="portrait-head">行为特征</div>
<div class="portrait-head">需求痛点</div>
<div class="portrait-head">消费心理</div>
<el-input v-model="record.behavior" class="portrait-cell" type="textarea"
:autosize="{ minRows: 2 }" placeholder="请结合访谈记录归纳行为特征" />
<el-input v-model="record.painPoints" class="portrait-cell" type="textarea"
:autosize="{ minRows: 2 }" placeholder="请结合访谈记录归纳需求痛点" />
<el-input v-model="record.psychology" class="portrait-cell" type="textarea"
:autosize="{ minRows: 2 }" placeholder="请结合访谈记录归纳消费心理" />
</div>
</section>
</div>
</section>
<section v-show="currentStep === 5" class="step-panel">
<p class="task-desc task-desc--compact">
结合 RFM 用户群画像和重点用户画像的分析结果,进一步细分市场,寻找产品开发机会。请评估各细分市场的吸引力,并从不同细分用户的行为特征和需求痛点中提炼产品机会。
</p>
<div class="market-segment-toolbar">
<div>
<p class="section-eyebrow">用户细分</p>
<h4>细分市场分析表</h4>
</div>
<div class="market-segment-toolbar__actions">
<el-button class="segment-action"
:disabled="form.marketSegments.length >= MAX_MARKET_SEGMENTS"
@click="addMarketSegment">
增加一行
</el-button>
<el-button class="segment-action segment-action--muted"
:disabled="form.marketSegments.length <= 1" @click="removeMarketSegment">
删除一行
</el-button>
</div>
</div>
<p class="market-segment-tip">可直接编辑表格内容,支持行扩展,最多 {{ MAX_MARKET_SEGMENTS }} 行。</p>
<div class="market-segment-table-wrap">
<table class="market-segment-table">
<thead>
<tr>
<th>细分市场</th>
<th>市场吸引力</th>
<th>需求痛点</th>
<th>行为特征</th>
<th>产品机会</th>
</tr>
</thead>
<tbody>
<tr v-for="(segment, index) in form.marketSegments" :key="index">
<td>
<el-input v-model="segment.market" class="market-segment-input"
placeholder="请输入细分市场" />
</td>
<td>
<el-radio-group v-model="segment.attractiveness"
class="market-attractiveness">
<el-radio label="高">高</el-radio>
<el-radio label="中">中</el-radio>
<el-radio label="低">低</el-radio>
</el-radio-group>
</td>
<td><el-input v-model="segment.painPoints" class="market-segment-textarea"
type="textarea" :autosize="{ minRows: 2 }" placeholder="结合用户画像填写需求痛点" />
</td>
<td><el-input v-model="segment.behaviorCharacteristics"
class="market-segment-textarea" type="textarea"
:autosize="{ minRows: 2 }" placeholder="填写该细分用户的行为特征" /></td>
<td><el-input v-model="segment.productOpportunity"
class="market-segment-textarea" type="textarea"
:autosize="{ minRows: 2 }" placeholder="提炼可切入的产品机会" /></td>
</tr>
</tbody>
</table>
</div>
<div class="market-selection-block">
<p class="section-eyebrow">市场选择</p>
<h4>结合前面的数据分析,请说明团队应该针对哪些用户,开发什么样的产品。</h4>
<el-input v-model="form.marketSelection" class="market-selection-textarea" type="textarea"
:autosize="{ minRows: 2 }" placeholder="请写明目标用户、核心需求和建议开发的产品方案" />
</div>
</section>
<div v-if="steps.length" class="step-actions">
<el-button class="step-action step-action--previous" :disabled="currentStep <= 1"
@click="currentStep -= 1">
<el-icon>
<ArrowLeft />
</el-icon>
上一步
</el-button>
<el-button class="step-action step-action--save" :loading="saving" @click="saveCurrentProgress">
<el-icon>
<CircleCheck />
</el-icon>
保存当前进度
</el-button>
<el-button class="step-action step-action--next" :disabled="currentStep >= steps.length"
@click="goNext">
下一步
<el-icon>
<ArrowRight />
</el-icon>
</el-button>
</div>
<div class="training-actions">
<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 />· 场景需求:家庭照明、睡眠改善、健康管理<br />· 决策阻碍:安装成本、效果可信度、售后保障"
assist-text="如果画像分析较散,可以先列出目标人群标签,再补充证据来源,最后总结最值得切入的重点用户群。" :progress-items="progressItems" />
</div>
4 months ago
</template>
<script setup>
import { ArrowLeft, ArrowRight, CircleCheck, Document, Download, Upload } from "@element-plus/icons-vue"
import * as XLSX from "xlsx"
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 { checkTargetUserProfileStepFive, checkTargetUserProfileStepFour, checkTargetUserProfileStepOne, checkTargetUserProfileStepThree, checkTargetUserProfileStepTwo, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer"
const TASK_KEY = "target-user-profile"
const RFM_INSTRUCTIONS_URL = "/file/training-materials/target-user-profile/8.实训操作说明.docx"
const RFM_TEMPLATE_URL = "/file/training-materials/target-user-profile/8.Excel计算与可视化模版.xlsx"
const RFM_CHARTS = [
{ id: "segment-share", index: 1, title: "客户分层占比饼图", buttonText: "上传客户分层占比饼图" },
{ id: "average-spend", index: 2, title: "客户分层平均消费对比柱状图", buttonText: "上传客户分层平均消费对比柱状图" },
{ id: "score-heatmap", index: 3, title: "RFM 分数分布热力图", buttonText: "上传 RFM 分布热力图" },
]
const RFM_INSIGHT_ROWS = [
{ dimension: "高价值用户占比", keyPoint: "重要价值客户占比是多少?是否足以支撑产品开发?" },
{ dimension: "用户活跃度", keyPoint: "R 分数分布反映的用户活跃情况如何?" },
{ dimension: "消费能力", keyPoint: "M 分数分布反映的用户消费能力如何?" },
{ dimension: "用户忠诚度", keyPoint: "F 分数分布反映的用户复购意愿如何?" },
{ dimension: "重点客户群特征", keyPoint: "哪些客户最值得关注?为什么?" },
]
const DEFAULT_FLOW_STEPS = ["RFM 用户分层分析", "RFM 数据可视化", "用户群体特点分析", "重点用户画像", "用户细分与市场选择"]
const MAX_MARKET_SEGMENTS = 6
const { proxy } = getCurrentInstance()
const taskConfig = ref(null)
const saving = ref(false)
const uploading = ref(false)
const fileInputRef = ref(null)
const previewDialogVisible = ref(false)
const chartFileInputRefs = ref({})
const uploadingChartId = ref("")
const chartPreviewVisible = ref(false)
const activeChartPreview = ref({ title: "图表预览", url: "" })
const currentStep = ref(1)
const briefOpen = ref(false)
const activeInterviewIndex = ref(0)
const interviewDialogVisible = ref(false)
const defaultRequirement = "目标用户画像用于把市场数据转化为具体用户认知,帮助判断产品应该优先服务哪类人群,以及围绕哪些场景设计卖点。"
const defaultBackground =
"以智能吸顶灯、助眠产品等产品为例,通过电商平台或百度指数等数据来源收集客户群画像信息,分析目标人群特征、需求场景和购买决策因素。"
const defaultGoals = [
"掌握通过平台数据识别目标用户群的方法。",
"能够从搜索结果中提炼用户属性、需求和行为特征。",
"能够形成面向产品定位和后续选品判断的用户画像结论。",
]
const interviewTranscripts = [
{
code: "U01",
basicAttributes: "李伟,29岁,男性,上海,互联网公司程序员,月收入约2.8万元",
content: [
{ question: "您平时的睡眠质量如何?通常因为什么失眠?", answer: "非常差。几乎每天都熬夜,不是因为想玩,是脑子停不下来,一直在想工作上的bug和方案。即使躺下,也觉得心神不宁,入睡特别困难。有时候心脏会有点不舒服,知道是压力大和缺觉搞的。" },
{ question: "您尝试过哪些方法来改善睡眠?", answer: "买过褪黑素,吃了一段时间感觉效果不明显,而且怕有依赖。也用过一些冥想App,听着白噪音确实有点用,但手机亮着屏幕放在旁边总觉得不舒服,而且容易被消息通知打断。" },
{ question: "如果有一款智能助眠灯,您最希望它有什么功能?", answer: "首先,光一定要柔和,能让我放松下来。其次,最好能无缝衔接我用的冥想App的内容,别让我再来回切换。还有就是,我挺想知道自己每晚到底睡了多久,醒了多少次,有个数据报告会让我觉得这钱花得值。如果能在我睡着后自动关灯、调低空调温度,那就太智能了。" },
{ question: "在购买这类产品时,您最看重什么?", answer: "效果和科技感。价格不是最敏感的因素,只要它真的能帮我解决问题,我愿意为专业和便捷付费。品牌最好有点调性,别显得太廉价。" },
],
},
{
code: "U02",
basicAttributes: "赵静,31岁,女性,杭州,中学教师,月收入约1.5万元",
content: [
{ question: "您目前的睡眠状况是怎样的?", answer: "碎片化,非常碎片化。宝宝现在一岁多,夜里还是会醒。从她出生后我就没睡过一个整觉。现在问题是,即使宝宝睡了,我自己也因为长期疲劳变得神经衰弱,很难深度睡眠,有点声音就醒。" },
{ question: "在您的睡前时光,通常有什么习惯或需求?", answer: "我需要一段属于自己的缓冲时间。会看会儿手机,但知道蓝光不好。也希望有一些温和的声音能盖掉家里的其他杂音,帮助我放松。另外,夜里起床照顾宝宝,需要一盏能瞬间提供柔和、不刺眼照明的小灯,现在开大灯太晃眼了。" },
{ question: "对于智能助眠灯,您有什么期待?", answer: "操作一定要简单!我可没时间研究复杂的APP。最好能语音控制,比如我说小眠,开夜灯,它就亮起最暗的光。灯光颜色最好能适合哺乳。内容上,我希望有一些针对成人的睡前放松引导,或者温和的轻音乐,最好也能播放一些给宝宝听的摇篮曲。" },
{ question: "购买决策时,哪些因素会打动您?", answer: "安全、实用、高性价比。材质必须安全无害,因为宝宝可能会碰到。功能要真正解决我的痛点,而不是花里胡哨。" },
],
},
{
code: "U03",
basicAttributes: "王悦,22岁,女性,广州,设计专业大学生,月生活费约3000元",
content: [
{ question: "您关注睡眠健康吗?", answer: "关注!我们寝室经常聊朋克养生,就是一边熬夜做设计作业,一边泡枸杞。我知道睡眠很重要,但有时候是功课多,有时候就是单纯不想睡,刷短视频停不下来。" },
{ question: "您的睡前仪式通常包括什么?", answer: "我很喜欢营造氛围感。会在宿舍点香薰蜡烛,但不太安全。也喜欢用手机放ASMR或者助眠视频,但同样需要开着屏幕。我觉得我的睡眠环境需要更有仪式感和科技感。" },
{ question: "一款什么样的助眠灯会吸引您购买?", answer: "颜值是第一位的!设计必须好看,有质感,能融入我的宿舍装饰风格。灯光效果要多,能切换不同颜色和模式,满足我学习、休息、自拍打光等不同场景。可以没有深入的睡眠监测,但一定要能语音控制,和我互动,比如问问天气、设定闹钟。最好还有一些有趣的、小众的白噪音,比如雨声、篝火声。" },
{ question: "在消费这类产品时,您是怎样的心理?", answer: "我愿意为情绪价值和社交价值买单。它不仅要好用,还要能成为我的一个社交货币,就是可以拍照分享到小红书的那种。价格不能太贵,四五百元我觉得是上限了,但如果它足够好看、好玩,我会非常心动。" },
],
},
]
const currentInterviewTranscript = computed(() => interviewTranscripts.find((item) => item.code === form.value.keyUserRecords[activeInterviewIndex.value]?.code) || interviewTranscripts[0])
function createKeyUserRecords() {
return [
{ title: "访谈记录一", code: "U01", user: "", basicAttributes: "", tags: "", behavior: "", painPoints: "", psychology: "" },
{ title: "访谈记录二", code: "U02", user: "", basicAttributes: "", tags: "", behavior: "", painPoints: "", psychology: "" },
{ title: "访谈记录三", code: "U03", user: "", basicAttributes: "", tags: "", behavior: "", painPoints: "", psychology: "" },
]
}
function createMarketSegments() {
return ["职场白领(25-35岁)", "精致妈妈(28-38岁)", "银发人群(50-65岁)", ""].map((market) => createEmptyMarketSegment(market))
}
function createEmptyMarketSegment(market = "") {
return { market, attractiveness: "", painPoints: "", behaviorCharacteristics: "", productOpportunity: "" }
}
function createForm() {
return {
profileFileName: "",
profileFileUrl: "",
profilePreview: createEmptyPreview(),
rfmCharts: createEmptyRfmCharts(),
rfmInsights: createEmptyRfmInsights(),
profileAnalysis: "",
keyUserProfile: "",
keyUserRecords: createKeyUserRecords(),
marketSegments: createMarketSegments(),
marketSelection: "",
}
}
function createEmptyRfmCharts() {
return RFM_CHARTS.reduce((charts, chart) => {
charts[chart.id] = { url: "", name: "" }
return charts
}, {})
}
function createEmptyRfmInsights() {
return RFM_INSIGHT_ROWS.map((item) => ({ ...item, analysis: "" }))
}
function createEmptyPreview() {
return { sheetName: "", columns: [], rows: [], totalRows: 0, totalColumns: 0 }
}
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 flowSteps = computed(() => {
const configuredSteps = parseTrainingArray(taskConfig.value?.steps, DEFAULT_FLOW_STEPS)
return configuredSteps.length >= DEFAULT_FLOW_STEPS.length ? configuredSteps : DEFAULT_FLOW_STEPS
})
const steps = computed(() =>
flowSteps.value.map((name, index) => ({ no: index + 1, name }))
)
const activeStepName = computed(() => steps.value.find((step) => step.no === currentStep.value)?.name || "")
const hasExcelPreview = computed(() => form.value.profilePreview.columns.length > 0)
const rfmCharts = computed(() => RFM_CHARTS)
const progressItems = computed(() => [
...steps.value.map((step) => ({
text: `${currentStep.value > step.no ? "已完成" : currentStep.value === step.no ? "进行中" : "待完成"}:${step.name}`,
status: currentStep.value > step.no ? "done" : currentStep.value === step.no ? "doing" : "",
})),
])
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) {
// 保持页面可填写,不因回显失败阻断使用。
}
}
function parseArray(value, fallback) {
if (Array.isArray(value)) {
return value.length ? value : fallback
}
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(/[;;|]/)
.map((item) => item.trim())
.filter(Boolean)
return list.length ? list : fallback
}
}
function normalizeKeyUserRecords(records, legacyText = "") {
const defaults = createKeyUserRecords()
if (Array.isArray(records)) {
return defaults.map((item, index) => ({
...item,
...(records[index] || {}),
}))
}
if (legacyText) {
defaults[1].behavior = legacyText
}
return defaults
}
function normalizeMarketSegments(rows, legacyRows = []) {
const defaults = createMarketSegments()
if (Array.isArray(rows) && rows.length) {
return rows.slice(0, MAX_MARKET_SEGMENTS).map((item, index) => ({
...(defaults[index] || createEmptyMarketSegment()),
...(item || {}),
}))
}
if (Array.isArray(legacyRows) && legacyRows.length) {
return legacyRows.slice(0, MAX_MARKET_SEGMENTS).map((item, index) => ({
...(defaults[index] || createEmptyMarketSegment()),
market: String(item?.market || defaults[index]?.market || ""),
productOpportunity: String(item?.productOpportunity || ""),
}))
}
return defaults
}
function applySavedAnswer(value) {
try {
const parsed = JSON.parse(value)
if (!parsed || typeof parsed !== "object") return
form.value = {
...createForm(),
profileFileName: parsed.profileFileName || parsed.file?.name || "",
profileFileUrl: parsed.profileFileUrl || parsed.file?.url || "",
profilePreview: normalizeExcelPreview(parsed.profilePreview),
rfmCharts: normalizeRfmCharts(parsed.rfmCharts),
rfmInsights: normalizeRfmInsights(parsed.rfmInsights),
profileAnalysis: parsed.profileAnalysis || "",
keyUserProfile: parsed.keyUserProfile || "",
keyUserRecords: normalizeKeyUserRecords(parsed.keyUserRecords, parsed.keyUserProfile),
marketSegments: normalizeMarketSegments(parsed.marketSegments, parsed.segmentationRows),
marketSelection: parsed.marketSelection || parsed.segmentationStrategy || "",
}
currentStep.value = Math.min(Math.max(Number(parsed.currentStep || 1), 1), steps.value.length || DEFAULT_FLOW_STEPS.length)
} catch (error) {
// 忽略历史异常数据。
}
}
function normalizeRfmCharts(charts) {
const defaults = createEmptyRfmCharts()
if (!charts || typeof charts !== "object") return defaults
return RFM_CHARTS.reduce((result, chart) => {
const value = charts[chart.id] || {}
result[chart.id] = { url: String(value.url || ""), name: String(value.name || "") }
return result
}, defaults)
}
function normalizeRfmInsights(insights) {
const savedInsights = Array.isArray(insights) ? insights : []
return RFM_INSIGHT_ROWS.map((item) => ({
...item,
analysis: String(savedInsights.find((saved) => saved?.dimension === item.dimension)?.analysis || ""),
}))
}
function normalizeExcelPreview(preview) {
if (!preview || typeof preview !== "object" || !Array.isArray(preview.columns) || !Array.isArray(preview.rows)) {
return createEmptyPreview()
}
return {
sheetName: String(preview.sheetName || ""),
columns: preview.columns.slice(0, 100).map((item) => String(item ?? "")),
rows: preview.rows.slice(0, 20).map((row) => (Array.isArray(row) ? row.slice(0, 100).map((item) => String(item ?? "")) : [])),
totalRows: Math.max(0, Number(preview.totalRows || 0)),
totalColumns: Math.max(0, Number(preview.totalColumns || 0)),
}
}
function triggerFileSelect() { fileInputRef.value?.click() }
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 downloadRfmInstructions() { downloadBuiltInFile(RFM_INSTRUCTIONS_URL, "8.实训操作说明.docx") }
function downloadRfmTemplate() { downloadBuiltInFile(RFM_TEMPLATE_URL, "8.Excel计算与可视化模版.xlsx") }
function setChartInputRef(chartId, element) {
if (element) chartFileInputRefs.value[chartId] = element
}
function triggerChartFileSelect(chartId) { chartFileInputRefs.value[chartId]?.click() }
function openChartPreview(chart) {
const file = form.value.rfmCharts[chart.id]
if (!file?.url) return
activeChartPreview.value = { title: chart.title, url: file.url }
chartPreviewVisible.value = true
}
function handleInterviewClick(index) {
activeInterviewIndex.value = index
const record = form.value.keyUserRecords[index]
if (interviewTranscripts.some((item) => item.code === record?.code)) {
interviewDialogVisible.value = true
}
}
async function handleFileChange(event) {
const file = event.target.files?.[0]
if (!file) return
const fileName = file.name.toLowerCase()
if (!/\.xlsx?$/.test(fileName)) {
proxy?.$modal?.msgError("请上传 .xls 或 .xlsx 格式的 Excel 文件")
event.target.value = ""
return
}
if (file.size > 20 * 1024 * 1024) {
proxy?.$modal?.msgError("Excel 文件不能超过 20MB")
event.target.value = ""
return
}
const data = new FormData()
data.append("file", file)
uploading.value = true
try {
const preview = await parseExcelPreview(file)
const res = await uploadStudentTrainingFile(data)
form.value.profileFileName = file.name
form.value.profileFileUrl = res?.url || res?.fileName || res?.data?.url || res?.data?.fileName || ""
form.value.profilePreview = preview
previewDialogVisible.value = true
proxy?.$modal?.msgSuccess("上传成功,已生成数据预览")
} catch (error) {
proxy?.$modal?.msgError(error?.message || "Excel 文件解析或上传失败,请检查文件后重试")
} finally {
uploading.value = false
event.target.value = ""
}
}
function addMarketSegment() {
if (form.value.marketSegments.length >= MAX_MARKET_SEGMENTS) return
form.value.marketSegments.push(createEmptyMarketSegment())
}
function removeMarketSegment() {
if (form.value.marketSegments.length <= 1) return
form.value.marketSegments.pop()
}
async function handleChartFileChange(chart, event) {
const file = event.target.files?.[0]
if (!file) return
if (!/\.(png|jpe?g|webp)$/i.test(file.name)) {
proxy?.$modal?.msgError("请上传 PNG、JPG 或 WEBP 格式的图表图片")
event.target.value = ""
return
}
if (file.size > 10 * 1024 * 1024) {
proxy?.$modal?.msgError("图表图片不能超过 10MB")
event.target.value = ""
return
}
const data = new FormData()
data.append("file", file)
uploadingChartId.value = chart.id
try {
const res = await uploadStudentTrainingFile(data)
const url = res?.url || res?.fileName || res?.data?.url || res?.data?.fileName || ""
if (!url) throw new Error("上传结果缺少文件地址")
form.value.rfmCharts[chart.id] = { url, name: file.name }
openChartPreview(chart)
proxy?.$modal?.msgSuccess(`${chart.title}上传成功`)
} catch (error) {
proxy?.$modal?.msgError(error?.message || "图表上传失败,请重试")
} finally {
uploadingChartId.value = ""
event.target.value = ""
}
}
async function parseExcelPreview(file) {
const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" })
const sheetName = workbook.SheetNames?.[0]
if (!sheetName || !workbook.Sheets?.[sheetName]) {
throw new Error("Excel 文件中未找到可预览的工作表")
}
const rawRows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { header: 1, defval: "", raw: false })
const rows = rawRows.filter((row) => Array.isArray(row) && row.some((cell) => String(cell ?? "").trim()))
if (rows.length < 2) throw new Error("Excel 至少需要包含一行表头和一行数据")
const columns = rows[0].map((cell) => String(cell ?? ""))
if (!columns.length) throw new Error("Excel 表头不能为空")
const dataRows = rows.slice(1)
return {
sheetName,
columns,
rows: dataRows.slice(0, 20).map((row) => columns.map((_, index) => String(row[index] ?? ""))),
totalRows: dataRows.length,
totalColumns: columns.length,
}
}
function buildAnswerPayload(status) {
const saveAction = status === "SUBMITTED" ? "SUBMIT" : status === "RESET" ? "RESET" : "SAVE"
return {
saveAction,
currentStep: currentStep.value,
step1Answer: JSON.stringify({
taskKey: TASK_KEY,
taskName: taskTitle.value,
flowSteps: flowSteps.value,
currentStep: currentStep.value,
profileFileName: form.value.profileFileName,
profileFileUrl: form.value.profileFileUrl,
profilePreview: form.value.profilePreview,
rfmCharts: form.value.rfmCharts,
rfmInsights: form.value.rfmInsights,
file: { name: form.value.profileFileName, url: form.value.profileFileUrl },
profileAnalysis: form.value.profileAnalysis,
keyUserProfile: form.value.keyUserProfile,
keyUserRecords: form.value.keyUserRecords,
marketSegments: form.value.marketSegments,
marketSelection: form.value.marketSelection,
}),
}
}
async function persist(status = "IN_PROGRESS") {
saving.value = true
try {
if (status !== "RESET" && (currentStep.value === 1 || status === "SUBMITTED")) {
await checkTargetUserProfileStepOne({ excelUrl: form.value.profileFileUrl, excelName: form.value.profileFileName, previewRowCount: form.value.profilePreview.rows.length, previewColumnCount: form.value.profilePreview.columns.length })
}
if (status !== "RESET" && (currentStep.value === 2 || status === "SUBMITTED")) {
await checkTargetUserProfileStepTwo({ charts: RFM_CHARTS.map((chart) => ({ type: chart.id, url: form.value.rfmCharts[chart.id]?.url || "", name: form.value.rfmCharts[chart.id]?.name || "" })) })
}
if (status !== "RESET" && (currentStep.value === 3 || status === "SUBMITTED")) {
await checkTargetUserProfileStepThree({ insights: form.value.rfmInsights })
}
if (status !== "RESET" && (currentStep.value === 4 || status === "SUBMITTED")) {
await checkTargetUserProfileStepFour({ records: form.value.keyUserRecords })
}
if (status !== "RESET" && (currentStep.value === 5 || status === "SUBMITTED")) {
await checkTargetUserProfileStepFive({ segments: form.value.marketSegments, marketSelection: form.value.marketSelection })
}
await saveStudentTrainingAnswer(TASK_KEY, buildAnswerPayload(status))
proxy?.$modal?.msgSuccess(status === "SUBMITTED" ? "提交成功" : "保存成功")
} finally {
saving.value = false
}
}
function saveCurrentProgress() { return persist("IN_PROGRESS") }
async function goNext() { await persist("IN_PROGRESS"); currentStep.value += 1 }
function submitTask() { return persist("SUBMITTED") }
async function resetTraining() { form.value = createForm(); currentStep.value = 1; await persist("RESET") }
function exportOutcome() {
const keyUserRecordsText = form.value.keyUserRecords.map((record) =>
`${record.title}(${record.code})\n用户:${record.user || ""}\n基础属性:${record.basicAttributes || ""}\n标签:${record.tags || ""}\n行为特征:\n${record.behavior || ""}\n需求痛点:\n${record.painPoints || ""}\n消费心理:\n${record.psychology || ""}`
).join("\n\n")
const segmentationText = form.value.marketSegments.map((row) =>
`${row.market || ""}\n市场吸引力:${row.attractiveness || ""}\n需求痛点:\n${row.painPoints || ""}\n行为特征:\n${row.behaviorCharacteristics || ""}\n产品机会:\n${row.productOpportunity || ""}`
).join("\n\n")
const rfmInsightsText = form.value.rfmInsights.map((item) => `${item.dimension}:\n${item.analysis || ""}`).join("\n\n")
const rfmChartsText = RFM_CHARTS.map((chart) => `${chart.title}:${form.value.rfmCharts[chart.id]?.url || "未上传"}`).join("\n")
const content = `目标用户画像\n\n任务流程:${flowSteps.value.join(" > ")}\n\nRFM 分层分析 Excel:${form.value.profileFileName || "未上传"}\n文件地址:${form.value.profileFileUrl || ""}\n\nRFM 可视化图表:\n${rfmChartsText}\n\nRFM 用户群体特点分析:\n${rfmInsightsText}\n\n重点用户画像:\n${keyUserRecordsText}\n\n用户细分与市场选择:\n${segmentationText}\n\n市场选择结论:\n${form.value.marketSelection || ""}`
const blob = new Blob([content], { type: "text/plain;charset=utf-8" })
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = `${taskTitle.value || "目标用户画像"}-实训结果.txt`
link.click()
URL.revokeObjectURL(url)
}
4 months ago
</script>
<style lang="scss" scoped>
/* ===== 页面级令牌(教育蓝极简主题) ===== */
.target-profile-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-fs-display: 17px;
--student-fs-h1: 15px;
--student-fs-h2: 14px;
--student-fs-h3: 13px;
--student-fs-body: 13px;
--student-fs-body-sm: 12px;
--student-fs-caption: 11px;
--student-fs-button: 13px;
--student-fs-button-lg: 13px;
--student-fs-data: 14px;
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);
}
/* 主列:扁平化,全局 .student-training-shell 提供栅格 */
.training-core {
display: grid;
gap: 10px;
align-content: start;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.task-action-strip {
margin: 0;
padding: 10px 16px;
}
.task-action-strip :deep(.training-task-toolbar) {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
/* 任务头:白卡 + 左侧教育蓝竖条 + 12px 圆角 */
.task-header {
position: relative;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 18px 20px 18px 24px;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: var(--student-card-bg);
overflow: hidden;
margin-bottom: 0;
&::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;
}
.task-title {
margin: 0;
color: var(--student-text);
font-size: 17px;
line-height: 1.35;
font-weight: 700;
}
.task-brief {
margin: 0;
color: var(--student-text-secondary);
font-size: 12px;
line-height: 1.6;
}
.task-brief-text {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.task-brief-toggle {
padding: 0;
border: 0;
color: var(--student-accent-deep);
background: transparent;
font: inherit;
font-weight: 700;
cursor: pointer;
margin-left: 4px;
&:hover {
text-decoration: underline;
}
}
.task-brief-more {
display: grid;
gap: 6px;
margin-top: 8px;
b {
color: var(--student-text);
}
ul {
display: grid;
gap: 4px;
margin: 0;
padding-left: 18px;
}
p {
margin: 0;
}
}
/* 套娃层扁平化 */
.task-card {
border: 0;
background: transparent;
padding: 0;
margin: 0;
}
/* workbench-head:扁平化 */
.workbench-head {
padding: 0;
margin-bottom: 0;
border-bottom: 0;
}
.workbench-head--actions-only {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
.workbench-actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
margin-left: auto;
gap: 10px;
}
/* section 小标签:灰色宽字距引导文字 */
.section-eyebrow {
display: inline-flex;
align-items: center;
margin: 0;
padding: 0;
color: var(--student-text-secondary);
font-size: 11px;
font-weight: 800;
line-height: 1.3;
letter-spacing: 0.12em;
}
/* 次按钮基类:白底 + 墨黑描边 */
.outline-action {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
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: 13px;
font-weight: 700;
cursor: pointer;
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease;
&:hover:not(:disabled) {
transform: translateY(-1px);
border-color: var(--student-accent);
background: var(--student-accent-soft);
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.12);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
/* step-card:白色 16px 圆角卡片 */
.step-card {
margin-bottom: 0;
padding: 16px;
border: 1px solid var(--student-panel-border);
border-radius: 16px;
background: var(--student-card-bg);
}
.step-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 10px;
h3 {
margin: 2px 0 0;
color: var(--student-text);
font-size: 15px;
line-height: 1.35;
font-weight: 700;
}
}
.step-panel {
animation: fade 0.2s ease;
}
@keyframes fade {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.task-desc {
margin: 0 0 18px;
color: #374151;
font-size: 13px;
line-height: 1.6;
}
.task-desc--compact {
margin-bottom: 14px;
}
/* ============ Step 1 上传区域 ============ */
.upload-panel {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 14px;
margin: 12px 0;
}
.file-input {
display: none;
}
.upload-action {
min-height: 40px;
padding: 0 18px;
border: 1px solid var(--student-accent);
border-radius: 10px;
color: #ffffff;
background: var(--student-accent);
font-size: 13px;
font-weight: 700;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.28);
transition: transform 0.16s ease, background 0.16s ease, box-shadow 0.16s ease;
&:hover:not(:disabled) {
transform: translateY(-1px);
background: var(--student-accent-deep);
border-color: var(--student-accent-deep);
box-shadow: 0 5px 14px rgba(29, 78, 216, 0.32);
}
}
.file-chip {
display: inline-flex;
align-items: center;
gap: 8px;
max-width: min(520px, 100%);
padding: 6px 12px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
color: var(--student-text-secondary);
background: var(--student-panel-bg);
font-size: 12px;
font-weight: 600;
span {
overflow-wrap: anywhere;
}
}
.preview-action {
min-height: 40px;
padding: 0 18px;
border: 1px solid rgba(37, 99, 235, 0.35);
border-radius: 10px;
color: var(--student-accent);
background: #ffffff;
font-size: 13px;
font-weight: 700;
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease;
&:hover:not(:disabled) {
transform: translateY(-1px);
border-color: var(--student-accent);
background: var(--student-accent-soft);
}
}
.upload-tip {
margin: 4px 0 8px;
color: var(--student-text-secondary);
font-size: 12px;
line-height: 1.6;
}
.step-one-materials {
margin-top: 12px;
padding: 12px 14px;
border: 1px solid var(--student-panel-border);
border-left: 3px solid var(--student-accent-light);
border-radius: 0 12px 12px 0;
color: #374151;
background: var(--student-panel-bg);
font-size: 13px;
line-height: 1.65;
p {
margin: 0 0 4px;
}
}
.step-one-materials__actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
}
/* ============ Excel 预览 ============ */
.excel-preview-meta {
margin-bottom: 12px;
color: var(--student-text-secondary);
font-size: 12px;
line-height: 1.6;
}
.excel-preview-wrap {
max-height: 480px;
overflow: auto;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
}
.excel-preview-table {
width: 100%;
min-width: 640px;
border-collapse: collapse;
color: #374151;
font-size: 12px;
th,
td {
min-width: 112px;
padding: 9px 12px;
border-right: 1px solid var(--student-panel-border);
border-bottom: 1px solid var(--student-panel-border);
text-align: left;
vertical-align: top;
white-space: pre-wrap;
word-break: break-word;
}
th {
position: sticky;
top: 0;
z-index: 1;
color: var(--student-text);
background: var(--student-panel-bg);
font-weight: 800;
}
tr:last-child td {
border-bottom: 0;
}
th:last-child,
td:last-child {
border-right: 0;
}
}
/* ============ Step 2 RFM 图表 ============ */
.rfm-chart-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin: 12px 0;
}
.rfm-chart-card {
display: flex;
min-height: 200px;
flex-direction: column;
padding: 14px;
border: 1px solid var(--student-panel-border);
border-radius: 14px;
background: #ffffff;
}
.rfm-chart-card__head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: auto;
h4 {
margin: 0;
color: var(--student-text);
font-size: 13px;
line-height: 1.45;
font-weight: 700;
}
}
.rfm-chart-card__index {
display: grid;
width: 28px;
height: 28px;
flex: 0 0 auto;
place-items: center;
border-radius: 6px;
color: var(--student-text-secondary);
background: var(--student-accent-soft);
font-size: 11px;
font-weight: 700;
}
.rfm-chart-upload {
display: inline-flex;
min-height: 38px;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 12px;
padding: 0 14px;
border: 1px solid var(--student-accent);
border-radius: 10px;
color: #ffffff;
background: var(--student-accent);
cursor: pointer;
font-size: 13px;
font-weight: 700;
transition: transform 0.16s ease, background 0.16s ease, box-shadow 0.16s ease;
&:hover:not(:disabled) {
transform: translateY(-1px);
background: var(--student-accent-deep);
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.28);
}
&:disabled {
cursor: wait;
opacity: 0.5;
}
}
.rfm-chart-empty {
margin: 12px 0 0;
color: var(--student-text-secondary);
font-size: 12px;
line-height: 1.5;
}
.rfm-chart-file {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
padding: 8px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: var(--student-panel-bg);
img {
width: 48px;
height: 38px;
flex: 0 0 auto;
border-radius: 6px;
cursor: zoom-in;
object-fit: cover;
}
strong {
display: block;
overflow: hidden;
color: var(--student-text);
font-size: 12px;
line-height: 1.35;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
button {
margin-top: 4px;
padding: 0;
border: 0;
color: var(--student-accent-light);
background: transparent;
cursor: pointer;
font-size: 11px;
font-weight: 600;
&:hover {
color: var(--student-accent);
}
}
}
.rfm-chart-preview {
display: block;
width: 100%;
max-height: 72vh;
border-radius: 8px;
object-fit: contain;
}
/* ============ Step 3 RFM 分析表 ============ */
.rfm-insight-table-wrap {
overflow-x: auto;
border: 1px solid var(--student-panel-border);
border-radius: 14px;
}
.rfm-insight-table {
width: 100%;
min-width: 780px;
border-collapse: collapse;
table-layout: fixed;
th,
td {
padding: 12px 14px;
border-right: 1px solid var(--student-panel-border);
border-bottom: 1px solid var(--student-panel-border);
color: #374151;
text-align: left;
vertical-align: top;
line-height: 1.6;
font-size: 13px;
}
thead th {
color: var(--student-text);
background: var(--student-panel-bg);
text-align: center;
font-weight: 800;
}
tbody th {
width: 20%;
color: var(--student-text);
background: var(--student-panel-bg);
text-align: center;
font-weight: 700;
}
tbody td:nth-child(2) {
width: 30%;
color: var(--student-text-secondary);
}
tbody td:last-child {
width: 50%;
}
tr:last-child th,
tr:last-child td {
border-bottom: 0;
}
th:last-child,
td:last-child {
border-right: 0;
}
}
/* rfm-insight-input 是 el-input type="textarea",覆盖内部样式 */
.rfm-insight-input {
width: 100%;
:deep(.el-textarea__inner) {
min-height: 72px !important;
padding: 8px 10px;
border: 1px solid var(--student-panel-border);
border-radius: 8px;
color: var(--student-text);
background: #ffffff;
font-size: 13px;
line-height: 1.6;
resize: vertical;
transition: border-color 0.16s ease, box-shadow 0.16s ease;
&::placeholder {
color: #9ca3af;
}
&:focus {
border-color: var(--student-accent-light);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
}
/* ============ Step 4 访谈 & 画像 ============ */
.interview-tabs {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 14px;
}
.interview-tab {
min-height: 36px;
padding: 0 16px;
border: 1px solid rgba(37, 99, 235, 0.35);
border-radius: 10px;
color: var(--student-accent);
background: #ffffff;
font-family: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease, color 0.16s ease;
&:hover {
transform: translateY(-1px);
border-color: var(--student-accent);
background: var(--student-accent-soft);
}
&.active {
border-color: var(--student-accent);
color: #ffffff;
background: var(--student-accent);
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.22);
}
}
.interview-dialog-tabs {
display: flex;
gap: 8px;
padding: 0 20px 14px;
border-bottom: 1px solid var(--student-panel-border, #e5e7eb);
button {
min-width: 52px;
height: 30px;
border: 1px solid var(--student-panel-border, #e5e7eb);
border-radius: 6px;
color: var(--student-text-secondary, #6b7280);
background: #ffffff;
cursor: pointer;
font-weight: 700;
font-size: 12px;
&.active {
border-color: #2563eb;
color: #ffffff;
background: #2563eb;
}
}
}
.interview-dialog-body--panel {
max-height: 58vh;
padding: 20px;
color: var(--student-text, #1f2937);
font-size: 13px;
line-height: 1.65;
}
.interview-line,
.interview-title,
.interview-qa p {
margin: 0;
}
.interview-title {
margin-top: 14px;
color: var(--student-text, #1f2937);
font-weight: 800;
}
.interview-qa {
margin-top: 10px;
}
/* 画像表格 */
.portrait-table-wrap {
overflow-x: auto;
border: 1px solid var(--student-panel-border);
border-radius: 14px;
background: #ffffff;
}
.portrait-record {
min-width: 720px;
&+& {
border-top: 1px solid var(--student-panel-border);
}
}
.portrait-user {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px;
align-items: center;
padding: 12px 14px;
border-bottom: 1px solid var(--student-panel-border);
label {
display: contents;
margin: 0;
padding: 0;
}
span {
color: var(--student-text-secondary);
font-size: 12px;
font-weight: 700;
}
}
.portrait-input {
width: 100%;
:deep(.el-input__wrapper) {
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: #ffffff;
box-shadow: none;
padding: 0 10px;
&.is-focus {
border-color: var(--student-accent-light);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
:deep(.el-input__inner) {
color: var(--student-text);
font-size: 13px;
}
}
.portrait-input--wide {
min-width: 200px;
}
.portrait-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.portrait-head {
display: flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 10px;
border-bottom: 1px solid var(--student-panel-border);
border-left: 1px solid var(--student-panel-border);
color: var(--student-text);
background: var(--student-panel-bg);
font-size: 13px;
font-weight: 800;
&:first-child {
border-left: 0;
}
}
/* portrait-cell 是 el-input type="textarea" */
.portrait-cell {
border-left: 1px solid var(--student-panel-border);
&:first-of-type {
border-left: 0;
}
:deep(.el-textarea__inner) {
min-height: 90px !important;
padding: 8px 10px;
border: 0;
border-radius: 0;
color: var(--student-text);
background: #ffffff;
font-size: 13px;
line-height: 1.6;
resize: vertical;
box-shadow: none;
&:focus {
box-shadow: none;
}
}
:deep(.el-textarea__wrapper),
:deep(.el-textarea) {
width: 100%;
}
}
/* ============ Step 5 市场细分 ============ */
.market-segment-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-bottom: 10px;
h4 {
margin: 0;
color: var(--student-text);
font-size: 14px;
font-weight: 700;
}
}
.market-segment-toolbar__actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.market-segment-toolbar__actions :deep(.segment-action) {
min-height: 36px;
padding: 0 14px;
border: 1px solid rgba(37, 99, 235, 0.35);
border-radius: 8px;
color: var(--student-accent);
background: #ffffff;
font-size: 12px;
font-weight: 700;
&:hover:not(.is-disabled) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
}
&.segment-action--muted {
color: var(--student-text-secondary);
border-color: var(--student-panel-border);
&:hover:not(.is-disabled) {
color: var(--student-accent);
border-color: rgba(37, 99, 235, 0.35);
}
}
}
.market-segment-tip {
margin: 0 0 10px;
color: var(--student-text-secondary);
font-size: 12px;
}
.market-segment-table-wrap {
overflow-x: auto;
border: 1px solid var(--student-panel-border);
border-radius: 14px;
}
.market-segment-table {
width: 100%;
min-width: 1080px;
border-collapse: collapse;
table-layout: fixed;
th,
td {
padding: 10px;
border-right: 1px solid var(--student-panel-border);
border-bottom: 1px solid var(--student-panel-border);
vertical-align: top;
font-size: 13px;
}
th {
color: var(--student-text);
background: var(--student-panel-bg);
font-weight: 800;
}
td:nth-child(1) {
width: 18%;
}
td:nth-child(2) {
width: 15%;
}
td:nth-child(n + 3) {
width: 22.33%;
}
tr:last-child td {
border-bottom: 0;
}
th:last-child,
td:last-child {
border-right: 0;
}
}
/* market-segment-input 是 el-input */
.market-segment-input {
width: 100%;
:deep(.el-input__wrapper) {
border: 1px solid var(--student-panel-border);
border-radius: 8px;
background: #ffffff;
box-shadow: none;
padding: 0 10px;
&.is-focus {
border-color: var(--student-accent-light);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
:deep(.el-input__inner) {
color: var(--student-text);
font-weight: 600;
text-align: center;
}
}
/* market-segment-textarea 是 el-input type="textarea" */
.market-segment-textarea {
width: 100%;
:deep(.el-textarea__inner) {
min-height: 72px !important;
padding: 8px 10px;
border: 1px solid var(--student-panel-border);
border-radius: 8px;
color: var(--student-text);
background: #ffffff;
font-size: 13px;
line-height: 1.6;
resize: vertical;
box-shadow: none;
&::placeholder {
color: #9ca3af;
}
&:focus {
border-color: var(--student-accent-light);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
}
.market-attractiveness {
display: flex;
justify-content: center;
gap: 8px;
:deep(.el-radio) {
margin-right: 0;
color: #374151;
}
}
/* 全页 el-radio 选中墨黑 */
.target-profile-page :deep(.el-radio__input.is-checked .el-radio__inner) {
border-color: var(--student-accent);
background: var(--student-accent);
}
.target-profile-page :deep(.el-radio__input.is-checked + .el-radio__label) {
color: var(--student-accent);
}
.target-profile-page :deep(.el-radio__input .el-radio__inner) {
border-color: #9ca3af;
}
.market-selection-block {
margin-top: 18px;
h4 {
margin: 0 0 12px;
color: var(--student-text);
font-size: 14px;
font-weight: 700;
line-height: 1.6;
}
}
.market-selection-textarea {
width: 100%;
:deep(.el-textarea__inner) {
min-height: 120px !important;
padding: 10px 12px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
color: var(--student-text);
background: #ffffff;
font-size: 13px;
line-height: 1.6;
resize: vertical;
box-shadow: none;
&::placeholder {
color: #9ca3af;
}
&:focus {
border-color: var(--student-accent-light);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
}
/* ============ 底部步骤导航 ============ */
.step-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
margin-top: 18px;
}
.step-actions :deep(.step-action) {
min-height: 40px;
padding: 0 18px;
border: 1px solid rgba(37, 99, 235, 0.35);
border-radius: 10px;
color: var(--student-accent);
background: #ffffff;
font-size: 13px;
font-weight: 700;
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease, color 0.16s ease;
&:hover:not(.is-disabled) {
transform: translateY(-1px);
border-color: var(--student-accent);
background: var(--student-accent-soft);
color: var(--student-accent);
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.12);
}
}
.step-actions :deep(.step-action--save),
.step-actions :deep(.step-action--next) {
border-color: var(--student-accent);
color: #ffffff;
background: var(--student-accent);
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.28);
&:hover:not(.is-disabled) {
transform: translateY(-1px);
background: var(--student-accent-deep);
border-color: var(--student-accent-deep);
box-shadow: 0 5px 14px rgba(29, 78, 216, 0.32);
color: #ffffff;
}
}
.step-actions :deep(.step-action.is-disabled) {
opacity: 0.5;
cursor: not-allowed;
}
/* ============ 底部提交/重新实训 ============ */
.training-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 20px;
margin-top: 24px;
}
.training-actions :deep(.training-action) {
min-width: 160px;
height: 36px;
min-height: 36px;
padding: 0 14px;
border: 1px solid rgba(37, 99, 235, 0.35);
border-radius: 8px;
color: var(--student-accent);
background: #ffffff;
font-size: 13px;
font-weight: 700;
line-height: 1;
text-shadow: none;
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease;
&:hover:not(.is-disabled) {
transform: translateY(-1px);
border-color: var(--student-accent);
background: var(--student-accent-soft);
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.12);
}
}
.training-actions :deep(.training-action--submit) {
border-color: var(--student-accent);
color: #ffffff;
background: var(--student-accent);
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.28);
&:hover:not(.is-disabled) {
transform: translateY(-1px);
background: var(--student-accent-deep);
border-color: var(--student-accent-deep);
box-shadow: 0 5px 14px rgba(29, 78, 216, 0.32);
}
}
.training-actions :deep(.training-action.is-disabled) {
opacity: 0.5;
cursor: not-allowed;
}
/* ============ 响应式 ============ */
@media (max-width: 1100px) {
.rfm-chart-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.market-segment-toolbar {
align-items: flex-start;
flex-direction: column;
}
}
@media (max-width: 900px) {
.workbench-head--actions-only,
.workbench-actions,
.upload-panel,
.step-title-row,
.step-actions,
.training-actions {
flex-direction: column;
align-items: stretch;
}
.workbench-actions :deep(.material-download),
.workbench-actions .outline-action,
.upload-action,
.preview-action,
.step-actions :deep(.step-action),
.training-actions :deep(.training-action) {
width: 100%;
}
}
@media (max-width: 760px) {
.rfm-chart-grid {
grid-template-columns: 1fr;
}
.market-segment-toolbar__actions {
width: 100%;
}
.interview-tabs {
flex-direction: column;
gap: 12px;
}
.interview-tab {
width: 100%;
}
.portrait-grid {
grid-template-columns: 1fr;
}
}
</style>
<!-- ==========================================================
* Non-scoped:画布背景 + 步骤导航按钮风(全局 student-training.scss
* 带 !important 锁定浅蓝,必须在页面非 scoped 块中覆盖)
* ========================================================== -->
<style lang="scss">
/* ===== 步骤导航:分段连接胶囊(教育蓝主题) ===== */
#app .studentLayout section.app-main:has(.target-profile-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-tab {
flex: 1 1 0 !important;
max-width: 240px !important;
min-width: 0 !important;
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
height: auto !important;
min-height: 40px !important;
margin: 0 !important;
padding: 8px 14px !important;
border: 1px solid #e5e7eb !important;
border-right: none !important;
border-radius: 0 !important;
background: #ffffff !important;
box-shadow: none !important;
color: #6b7280 !important;
font-size: 13px !important;
font-weight: 600 !important;
line-height: 1.3 !important;
letter-spacing: 0 !important;
white-space: normal !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-bar {
display: none !important;
}
.step-no {
position: static !important;
flex: 0 0 auto !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
min-width: 22px !important;
height: 22px !important;
padding: 0 5px !important;
border-radius: 6px !important;
background: #f3f4f6 !important;
color: #6b7280 !important;
font-family: var(--student-font-data), ui-monospace, "SF Mono", Consolas, monospace !important;
font-size: 12px !important;
font-weight: 700 !important;
line-height: 1 !important;
transition: all 0.16s ease !important;
&::after {
display: none !important;
}
}
.step-name {
color: inherit !important;
}
&:hover:not(.active):not(.complete) {
border-color: #2563eb !important;
background: #eff6ff !important;
color: #2563eb !important;
z-index: 1 !important;
.step-no {
background: #2563eb !important;
color: #ffffff !important;
}
}
&.complete:not(.active) {
border-color: #e5e7eb !important;
color: #374151 !important;
.step-no {
background: rgba(43, 164, 113, 0.12) !important;
color: #16915f !important;
}
&:hover {
border-color: #2ba471 !important;
background: #f0fdf6 !important;
z-index: 1 !important;
.step-no {
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-no {
background: #ffffff !important;
color: #2563eb !important;
}
&:hover {
background: #1d4ed8 !important;
border-color: #1d4ed8 !important;
}
}
}
}
@media (max-width: 900px) {
#app .studentLayout section.app-main:has(.target-profile-page) .step-indicator {
justify-content: flex-start !important;
overflow-x: auto !important;
}
#app .studentLayout section.app-main:has(.target-profile-page) .step-indicator .step-tab {
flex: 0 0 140px !important;
max-width: none !important;
}
}
/* ============================================================
* 顶部工具栏:TrainingMaterialButton 资料按钮 → 教育蓝
* ============================================================ */
.target-profile-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: 13px !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(.target-profile-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: 13px !important;
font-weight: 600 !important;
line-height: 1 !important;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease !important;
}
#app .studentLayout section.app-main:has(.target-profile-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;
}
/* ============================================================
* 上传 / 预览按钮(el-button,覆盖全局墨黑实底规则)
* ============================================================ */
#app .studentLayout section.app-main:has(.target-profile-page) .el-button.upload-action {
border: 1px solid #2563eb !important;
color: #ffffff !important;
background: #2563eb !important;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.28) !important;
&:hover:not(.is-disabled) {
border-color: #1d4ed8 !important;
background: #1d4ed8 !important;
box-shadow: 0 5px 14px rgba(29, 78, 216, 0.32) !important;
}
}
#app .studentLayout section.app-main:has(.target-profile-page) .el-button.preview-action {
border: 1px solid rgba(37, 99, 235, 0.35) !important;
color: #2563eb !important;
background: #ffffff !important;
box-shadow: none !important;
&:hover:not(.is-disabled) {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12) !important;
}
}
/* ============================================================
* 细分市场行操作按钮(segment-action)
* ============================================================ */
#app .studentLayout section.app-main:has(.target-profile-page) .market-segment-toolbar__actions .el-button.segment-action {
border: 1px solid rgba(37, 99, 235, 0.35) !important;
color: #2563eb !important;
background: #ffffff !important;
box-shadow: none !important;
&:hover:not(.is-disabled) {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
}
&.segment-action--muted {
border-color: #e5e7eb !important;
color: #6b7280 !important;
background: #ffffff !important;
&:hover:not(.is-disabled) {
border-color: rgba(37, 99, 235, 0.35) !important;
color: #2563eb !important;
background: #eff6ff !important;
}
}
}
/* ============================================================
* 底部步骤按钮:step-action
* ============================================================ */
#app .studentLayout section.app-main:has(.target-profile-page) .step-actions .el-button.step-action {
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;
box-shadow: none !important;
&:hover:not(.is-disabled) {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.12) !important;
}
}
#app .studentLayout section.app-main:has(.target-profile-page) .step-actions .el-button.step-action--save {
border-color: #e5e7eb !important;
color: #374151 !important;
background: #f3f4f6 !important;
&:hover:not(.is-disabled) {
border-color: #d1d5db !important;
color: #2563eb !important;
background: #e5e7eb !important;
box-shadow: none !important;
}
}
#app .studentLayout section.app-main:has(.target-profile-page) .step-actions .el-button.step-action--next {
border-color: #2563eb !important;
color: #ffffff !important;
background: #2563eb !important;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.28) !important;
&:hover:not(.is-disabled) {
border-color: #1d4ed8 !important;
color: #ffffff !important;
background: #1d4ed8 !important;
box-shadow: 0 5px 14px rgba(29, 78, 216, 0.32) !important;
}
}
/* ============================================================
* 底部提交/清空:training-actions
* ============================================================ */
#app .studentLayout section.app-main:has(.target-profile-page) .training-actions .el-button.training-action {
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;
box-shadow: none !important;
}
#app .studentLayout section.app-main:has(.target-profile-page) .training-actions .el-button.training-action:hover:not(.is-disabled) {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.12) !important;
}
#app .studentLayout section.app-main:has(.target-profile-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;
&:hover:not(.is-disabled) {
border-color: #1d4ed8 !important;
background: #1d4ed8 !important;
box-shadow: 0 5px 14px rgba(29, 78, 216, 0.32) !important;
}
}
#app .studentLayout section.app-main:has(.target-profile-page) .training-actions .el-button.training-action--reset {
border-color: #e5e7eb !important;
color: #374151 !important;
background: #f3f4f6 !important;
&:hover:not(.is-disabled) {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
}
4 months ago
}
</style>