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-behavior.vue

2605 lines
98 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-behavior-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-reset="resetTraining" :on-export="exportOutcome" />
</section>
<section class="task-card">
<nav v-if="flowSteps.length" class="step-indicator" aria-label="消费者需求与行为步骤">
<button v-for="(step, index) in flowSteps" :key="step" type="button"
:class="['step-tab', { active: form.activeStep === index, complete: form.activeStep > index }]"
:aria-label="`第 ${index + 1} 步:${step}`"
:aria-current="form.activeStep === index ? 'step' : undefined" :title="step"
@click="setActiveStep(index)">
<span class="step-bar" aria-hidden="true"></span>
<span class="step-no" aria-hidden="true">{{ String(index + 1).padStart(2, '0') }}</span>
<span class="step-name">{{ step }}</span>
</button>
</nav>
<input ref="fileInputRef" type="file" class="file-input" :accept="uploadAccept"
@change="handleFileChange" />
<input ref="questionnaireImportInputRef" type="file" class="file-input" accept=".docx"
@change="handleQuestionnaireImport" />
<section class="step-card consumer-behavior-step-card">
<div v-if="activeStepName" class="step-title-row">
<div>
<p class="section-eyebrow">Step {{ String(form.activeStep + 1).padStart(2, '0') }}</p>
<h3>{{ activeStepName }}</h3>
</div>
</div>
<section v-if="form.activeStep === 0" class="step-panel">
<div class="hypothesis-head">
<p class="task-desc hypothesis-desc">
基于实训 8 的用户画像结论,明确本次问卷调查的目标人群与核心假设,填写下表。
</p>
<div class="hypothesis-actions">
<el-button class="hypothesis-action" @click="addTargetHypothesis">增加一行</el-button>
<el-button class="hypothesis-action hypothesis-action--quiet"
:disabled="form.targetHypotheses.length <= 1"
@click="removeTargetHypothesis">删除一行</el-button>
</div>
<el-button class="training-eight-action" @click="viewTargetUserProfile">查看实训8</el-button>
</div>
<div class="target-hypothesis-table-wrap">
<table class="target-hypothesis-table">
<thead>
<tr>
<th>目标人群</th>
<th>实训8关键发现</th>
<th>本次问卷需验证的核心假设</th>
</tr>
</thead>
<tbody>
<tr v-for="(hypothesis, index) in form.targetHypotheses" :key="index">
<td><el-input v-model="hypothesis.targetGroup" class="hypothesis-input"
placeholder="例如:职场白领(25-35岁)" /></td>
<td><el-input v-model="hypothesis.keyFinding" type="textarea"
:autosize="{ minRows: 2 }" class="hypothesis-textarea"
placeholder="例如:高压工作、入睡困难、看重效率" /></td>
<td><el-input v-model="hypothesis.coreHypothesis" type="textarea"
:autosize="{ minRows: 2 }" class="hypothesis-textarea"
placeholder="例如:是否更关注白噪声质量与智能场景联动?对价格的敏感度如何?" /></td>
</tr>
</tbody>
</table>
</div>
<p class="hypothesis-tip">可编辑表格内容;请基于实训 8 的结论明确每类目标人群的问卷验证方向。</p>
</section>
<section v-else-if="form.activeStep === 1" class="step-panel">
<div class="kano-head">
<p class="task-desc kano-desc">
在正式设计问卷之前,需要理解消费者需求的层次结构。不同类型的需求对购买决策的影响方式截然不同。结合案例资料,通过 KANO 模型进行用户需求分类,填写下表。
</p>
<div class="kano-actions">
<el-button class="hypothesis-action"
:disabled="form.kanoNeeds.length >= KANO_NEED_LIMIT"
@click="addKanoNeed">增加一行</el-button>
<el-button class="hypothesis-action hypothesis-action--quiet"
:disabled="form.kanoNeeds.length <= 1" @click="removeKanoNeed">删除一行</el-button>
</div>
</div>
<div class="kano-table-wrap">
<table class="kano-table">
<thead>
<tr>
<th>编号</th>
<th>功能</th>
<th>对应KANO类型</th>
<th>对应功能方向</th>
</tr>
</thead>
<tbody>
<tr v-for="(need, index) in form.kanoNeeds" :key="index">
<td class="kano-index">{{ index + 1 }}</td>
<td><el-input v-model="need.feature" type="textarea" :autosize="{ minRows: 2 }"
class="hypothesis-textarea kano-textarea" placeholder="示例:灯光亮度可无级调节"
aria-label="功能" /></td>
<td class="kano-select-cell">
<el-select v-model="need.kanoType" class="kano-select" placeholder="请选择类型"
aria-label="对应KANO类型">
<el-option v-for="option in kanoTypeOptions" :key="option"
:label="option" :value="option" />
</el-select>
</td>
<td><el-input v-model="need.featureDirection" type="textarea"
:autosize="{ minRows: 2 }" class="hypothesis-textarea kano-textarea"
placeholder="示例:照明系统" aria-label="对应功能方向" /></td>
</tr>
</tbody>
</table>
</div>
<p class="hypothesis-tip">功能与功能方向均可填写;KANO 类型请从下拉菜单选择。最多可填写 {{ KANO_NEED_LIMIT }} 行。</p>
</section>
<section v-else-if="form.activeStep === 2" class="step-panel">
<div class="questionnaire-design-head">
<p class="task-desc questionnaire-design-desc">
结合前面的实训分析,设计一份完整的消费者需求调研问卷,包含以下部分:基本信息(如:年龄、性别、月收入、城市级别等)、购买行为(产品购买经历、购买渠道偏好、信息获取渠道)、需求偏好(Likert 五级量表对步骤二提到的功能进行需求度评价)、开放建议(用户自由表达对案例产品的期望与顾虑)。
</p>
<el-button class="questionnaire-import-action" :loading="questionnaireImporting"
@click="triggerQuestionnaireImport">
<el-icon>
<Upload />
</el-icon>
导入问卷(Word 文档)
</el-button>
</div>
<div v-if="form.questionnaireImport?.name" class="questionnaire-imported-file">
<el-icon>
<Document />
</el-icon>
<span>{{ form.questionnaireImport.name }}</span>
<button type="button" @click="showQuestionnairePreview = true">预览问卷</button>
</div>
<div class="questionnaire-editor-launcher">
<div class="questionnaire-editor-launcher__copy">
<span class="section-eyebrow">Questionnaire Studio</span>
<h4>在线编写消费者需求调研问卷</h4>
<p>通过分区、题型与选项设置,快速完成可执行的问卷结构。</p>
</div>
<div class="questionnaire-editor-launcher__actions">
<el-button class="questionnaire-editor-launcher__action"
@click="questionnaireEditorVisible = true">进入问卷编辑器</el-button>
<el-button class="questionnaire-editor-launcher__action"
:disabled="!hasEditableQuestionnaire" @click="openQuestionnairePreview">预览问卷</el-button>
<el-button class="questionnaire-editor-launcher__action"
:disabled="!hasEditableQuestionnaire" @click="downloadQuestionnaireWord">下载问卷(Word)</el-button>
</div>
</div>
<p class="questionnaire-editor-summary">
已编辑 {{ form.questionnaireQuestions.length }} 题;{{
form.questionnaireImport?.name ? "已导入 1 份 Word 问卷,可随时预览。" : "也可下载规范模板,在 Word 中编好题目后导入。"
}}
</p>
<div class="questionnaire-template-guide">
<div>
<h5>推荐工具及方法如下,自行选择:</h5>
<p>1. 使用本系统问卷编辑器,自行编辑问卷。</p>
<p>2. 下载规范导入模板,按模板格式在 Word 中编好题目后导入,系统自动识别为题目。</p>
<p>3. 编辑或导入完成后,可在线预览问卷并下载 Word 文件。</p>
</div>
<el-button class="questionnaire-template-action" @click="downloadQuestionnaireImportTemplate">
<el-icon>
<Download />
</el-icon>
下载导入模板
</el-button>
</div>
<el-dialog v-model="questionnaireEditorVisible" title="消费者需求调研问卷编辑器" width="min(1120px, 94vw)"
class="questionnaire-editor-dialog" append-to-body>
<div class="questionnaire-editor-dialog__intro">
<p>请覆盖基本信息、购买行为、需求偏好和开放建议四部分;需求偏好至少设置一题 Likert 五级量表。</p>
<el-button class="questionnaire-editor-add"
@click="addQuestionnaireQuestion">新增题目</el-button>
</div>
<div class="questionnaire-question-list">
<article v-for="(question, index) in form.questionnaireQuestions" :key="question.id"
class="questionnaire-question-card">
<span class="questionnaire-question-card__number">{{ String(index + 1).padStart(2,
"0") }}</span>
<div class="questionnaire-question-card__fields">
<el-select v-model="question.section" aria-label="问卷分区">
<el-option v-for="section in questionnaireSections" :key="section.value"
:label="section.label" :value="section.value" />
</el-select>
<el-select v-model="question.type" aria-label="题目类型">
<el-option v-for="type in questionnaireQuestionTypes" :key="type.value"
:label="type.label" :value="type.value" />
</el-select>
<el-input v-model="question.title"
:placeholder="questionPlaceholder(question.section)" aria-label="题目内容" />
<el-input v-if="question.type === 'SINGLE' || question.type === 'MULTIPLE'"
v-model="question.options" placeholder="选项以中文逗号或换行分隔" aria-label="题目选项" />
<p v-else-if="question.type === 'LIKERT'" class="likert-tip">
量表固定为:非常不重要、较不重要、一般、较重要、非常重要</p>
</div>
<button type="button" class="questionnaire-question-card__remove"
:disabled="form.questionnaireQuestions.length <= 4"
@click="removeQuestionnaireQuestion(index)">删除</button>
</article>
</div>
</el-dialog>
<el-dialog v-model="showQuestionnairePreview" title="导入问卷预览" width="min(900px, 94vw)"
class="questionnaire-preview-dialog" append-to-body>
<div v-if="form.questionnaireImport?.previewContent" class="questionnaire-word-preview">{{
form.questionnaireImport.previewContent }}</div>
<p v-else class="questionnaire-preview-empty">暂未解析到可预览内容。</p>
</el-dialog>
<el-dialog v-model="showQuestionnaireComposePreview" title="问卷预览" width="min(860px, 94vw)"
class="questionnaire-compose-dialog" append-to-body>
<div class="questionnaire-compose-preview">
<h3 class="questionnaire-compose-preview__title">消费者需求调研问卷</h3>
<template v-for="section in questionnaireSections" :key="section.value">
<div v-if="questionsBySection(section.value).length"
class="questionnaire-compose-preview__section">
<h4>{{ section.label }}</h4>
<ol>
<li v-for="question in questionsBySection(section.value)" :key="question.id">
<p class="question-title">{{ question.title }}<span
class="question-type">({{ questionTypeLabel(question.type)
}})</span></p>
<ul v-if="questionOptions(question).length" class="question-options">
<li v-for="(option, optIndex) in questionOptions(question)"
:key="optIndex">{{ optionLetter(optIndex) }}. {{ option }}</li>
</ul>
<p v-else-if="question.type === 'TEXT'" class="question-open-line">________________________</p>
</li>
</ol>
</div>
</template>
<p v-if="!hasEditableQuestionnaire" class="questionnaire-compose-preview__empty">暂无可预览的题目,请先编辑或导入问卷。</p>
</div>
<template #footer>
<el-button @click="showQuestionnaireComposePreview = false">关闭</el-button>
<el-button type="primary" :disabled="!hasEditableQuestionnaire"
@click="downloadQuestionnaireWord">下载问卷(Word)</el-button>
</template>
</el-dialog>
</section>
<section v-else class="step-panel">
<p class="task-desc survey-report-desc">
将步骤三设计的调查问卷导入问卷星等在线问卷调查平台,开展问卷调查。基于收集到的数据,填写问卷调查报告。
</p>
<div class="survey-report-actions">
<el-button :disabled="form.surveyReportRows.length >= 12" @click="addSurveyReportRow">
<el-icon>
<Plus />
</el-icon>
增加一行
</el-button>
<el-button :disabled="form.surveyReportRows.length <= 1" @click="removeSurveyReportRow">
<el-icon>
<Minus />
</el-icon>
删除一行
</el-button>
</div>
<div class="survey-report-table-wrap">
<table class="survey-report-table">
<thead>
<tr>
<th>报告模块标题</th>
<th>内容</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in form.surveyReportRows" :key="index">
<td>
<el-input v-model="row.title" class="survey-report-title-input"
:placeholder="surveyReportTitlePlaceholder(index)"
:aria-label="`第${index + 1}个报告模块标题`" />
</td>
<td>
<el-input v-model="row.content" type="textarea" :autosize="{ minRows: 3 }"
class="survey-report-content-input"
:placeholder="surveyReportContentPlaceholder(index)"
:aria-label="`第${index + 1}个报告模块内容`" />
</td>
</tr>
</tbody>
</table>
</div>
</section>
<div class="step-navigation">
<div class="step-nav-left">
<button v-if="form.activeStep > 0" type="button" class="btn-nav btn-ghost"
:disabled="saving" @click="goPrev">
<el-icon>
<ArrowLeft />
</el-icon>
上一步
</button>
<button type="button" class="btn-nav btn-save" :disabled="saving"
@click="saveCurrentProgress">
<el-icon>
<CircleCheck />
</el-icon>
保存
</button>
</div>
<div class="step-nav-right">
<span class="step-progress">Step {{ String(form.activeStep + 1).padStart(2, "0") }} / {{
String(flowSteps.length).padStart(2, "0") }}</span>
<button v-if="form.activeStep < flowSteps.length - 1" type="button"
class="btn-nav btn-primary" :disabled="saving" @click="goNext">
下一步
<el-icon>
<ArrowRight />
</el-icon>
</button>
</div>
</div>
</section>
</section>
</main>
<TrainingAiSidebar study-tip="当前步骤建议:先确认问卷能覆盖用户基本信息、购买行为、决策因素和未购买顾虑,再用数据分析支撑结论。" reference-title="消费者行为分析参考"
reference-text="· 需求类型:功能需求、价格需求、服务需求、隐私安全需求<br />· 决策因素:价格、品牌、功能、质量、便捷性、售后<br />· 分析方法:分群对比、渠道分析、购买场景分析、未购买原因归因"
assist-text="报告中尽量把数据结论写成“现象 + 人群差异 + 产品启示”的结构,避免只罗列比例。" :progress-items="progressItems" />
</div>
</template>
<script setup>
import { computed, getCurrentInstance, nextTick, onMounted, ref } from "vue"
import { useRouter } from "vue-router"
import { ArrowLeft, ArrowRight, CircleCheck, Document, Download, Minus, Plus, Upload } from "@element-plus/icons-vue"
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue"
import TrainingTaskToolbar from "@/views/components/TrainingTaskToolbar.vue"
import { getTrainingTaskByKey } from "@/api/trainingTask"
import { parseTrainingArray } from "@/views/training/taskKeyMap"
import { checkConsumerBehaviorStepOne, checkConsumerBehaviorStepTwo, checkConsumerBehaviorStepThree, checkConsumerBehaviorStepFour, getStudentTrainingAnswer, previewConsumerBehaviorQuestionnaireWord, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer"
import JSZip from "jszip"
const TASK_KEY = "consumer-behavior"
const KANO_NEED_LIMIT = 20
let questionnaireQuestionSequence = 0
const { proxy } = getCurrentInstance()
const router = useRouter()
const defaultRequirement = "消费者需求与行为分析用于识别目标用户的购买动机、决策因素、渠道偏好和使用场景,为智能产品功能设计与市场切入提供依据。"
const defaultBackground =
"SX是一家专注于智能健康生活产品的科技公司。公司产品开发团队经过市场分析,发现光线及噪音对睡眠具有较大影响,市场上针对助眠产品主要集中在保健品及药物,较少产品是通过优化睡眠环境来达成助眠效果,因此,初步判断开发一款智能声光伴眠吸顶灯具有市场机会。\n产品开发前,团队进行了目标用户画像,进行了初步的用户分析,现在需要通过问卷调查,收集足够多的数据,分析用户购买智能产品的决策因素,为后续的产品关键功能开发提供市场需求数据支撑与决策依据。"
const defaultGoals = [
"掌握消费者需求类型划分及特征。",
"熟悉消费者行为理论与决策机制。",
"挖掘影响消费者需求及行为的关键因素。",
]
const questionnaireTemplate = `<h4>智能产品购买决策因素调查问卷</h4>
<p><strong>(一)基本信息</strong></p>
<p>1.您的性别: A. 男 B. 女</p>
<p>2.您的年龄: A. 18岁及以下 B. 19-25岁 C. 26-35岁 D. 36-45岁 E. 46岁及以上</p>
<p>3.您的月收入: A. 3000元及以下 B. 3001-5000元 C. 5001-8000元 D. 8001-12000元 E. 12001元及以上</p>
<p>4.您居住的城市级别: A. 一线城市 B. 新一线城市 C. 二线城市 D. 三线及以下城市</p>
<p><strong>(二)购买行为与决策因素</strong></p>
<p>5.您是否购买过智能产品? A. 是 B. 否(若选B,跳转至第12题)</p>
<p>6.您购买过的智能产品类型可多选: A. 智能手机 B. 智能家电(如扫地机器人、智能冰箱等) C. 智能穿戴设备(如智能手表、手环等) D. 智能玩具 E. 智能健身设备(如智能健身镜) F. 其他(请注明____)</p>
<p>7.在购买智能产品时,您优先考虑的因素可多选: A. 价格 B. 品牌知名度 C. 功能实用性 D. 产品质量与稳定性 E. 操作便捷性 F. 数据安全与隐私保护 G. 外观设计 H. 售后服务 I. 生态兼容性(如能否与其他智能设备联动) J. 其他(请注明____)</p>
<p>8.请您根据实际感受,评价以下因素对您购买智能产品的重要程度,请在对应选项中打“√”</p>
<table>
<thead>
<tr>
<th>因素</th>
<th>非常不重要</th>
<th>不重要</th>
<th>一般</th>
<th>重要</th>
<th>非常重要</th>
</tr>
</thead>
<tbody>
<tr><td>价格</td><td></td><td></td><td></td><td></td><td></td></tr>
<tr><td>品牌知名度</td><td></td><td></td><td></td><td></td><td></td></tr>
<tr><td>功能实用性</td><td></td><td></td><td></td><td></td><td></td></tr>
<tr><td>产品质量与稳定性</td><td></td><td></td><td></td><td></td><td></td></tr>
<tr><td>操作便捷性</td><td></td><td></td><td></td><td></td><td></td></tr>
<tr><td>数据安全与隐私保护</td><td></td><td></td><td></td><td></td><td></td></tr>
<tr><td>外观设计</td><td></td><td></td><td></td><td></td><td></td></tr>
<tr><td>售后服务</td><td></td><td></td><td></td><td></td><td></td></tr>
<tr><td>生态兼容性</td><td></td><td></td><td></td><td></td><td></td></tr>
</tbody>
</table>
<p>9.您是否会因为智能产品具备独特创新功能而愿意支付更高价格? A. 一定会 B. 可能会 C. 不确定 D. 可能不会 E. 一定不会</p>
<p>10.您获取智能产品信息的主要渠道可多选: A. 电商平台(如淘宝、京东等) B. 社交媒体(如抖音、小红书等) C. 朋友、家人推荐 D. 线下实体店体验 E. 科技媒体评测 F. 其他(请注明____)</p>
<p>11.您购买智能产品的主要场景是: A. 自己使用 B. 赠送家人 C. 赠送朋友 D. 商务礼品 E. 其他(请注明____)</p>
<p><strong>(三)未购买情况与顾虑</strong></p>
<p>12.您未购买智能产品的主要原因可多选: A. 价格过高 B. 对智能产品功能不了解 C. 担心产品质量与稳定性 D. 担心数据安全与隐私泄露 E. 觉得操作复杂,难以上手 F. 没有合适的产品满足需求 G. 其他(请注明____)</p>
<p>13.如果您计划购买智能产品,您最期待的功能是: A. 更强大的智能化功能 B. 更便捷的操作方式 C. 更好的数据安全与隐私保护 D. 更丰富的生态兼容性 E. 其他(请注明____)</p>
<p><strong>(四)开放性问题</strong></p>
<p>14.对于智能产品的发展,您有哪些建议或期望?</p>`
const surveyStats = `(一)基本信息
1.性别:男235人(47%),女265人(53%)
2.年龄:18岁及以下30人(6%),19-25岁120人(24%),26-35岁180人(36%),36-45岁100人(20%),46岁及以上70人(14%)
3.月收入:3000元及以下80人(16%),3001-5000元120人(24%),5001-8000元150人(30%),8001-12000元100人(20%),12001元及以上50人(10%)
4.城市级别:一线城市100人(20%),新一线城市150人(30%),二线城市120人(24%),三线及以下城市130人(26%)
(二)购买行为与决策因素
5.是否购买过智能产品:是420人(84%),否80人(16%)
6.购买过的智能产品类型:智能手机380人(76%),智能家电220人(44%),智能穿戴设备180人(36%),智能玩具60人(12%),智能健身设备40人(8%),其他20人(4%)
7.优先考虑的因素:价格250人(50%),品牌知名度180人(36%),功能实用性320人(64%),产品质量与稳定性280人(56%),操作便捷性220人(44%),数据安全与隐私保护150人(30%),外观设计120人(24%),售后服务100人(20%),生态兼容性80人(16%),其他30人(6%)
8.各因素影响程度统计:价格65%,品牌知名度50%,功能实用性80%,产品质量与稳定性75%,操作便捷性60%,数据安全与隐私保护45%,外观设计35%,售后服务40%,生态兼容性30%
9.是否为创新功能支付更高价格:一定会80人(16%),可能会220人(44%),不确定100人(20%),可能不会70人(14%),一定不会30人(6%)
10.获取信息渠道:电商平台300人(60%),社交媒体250人(50%),朋友、家人推荐200人(40%),线下实体店体验150人(30%),科技媒体评测120人(24%),其他30人(6%)
11.购买场景:自己使用350人(70%),赠送家人80人(16%),赠送朋友40人(8%),商务礼品20人(4%),其他10人(2%)
(三)未购买情况与顾虑
12.未购买原因:价格过高35人(43.75%),对功能不了解20人(25%),担心质量与稳定性15人(18.75%),担心数据安全与隐私泄露8人(10%),觉得操作复杂5人(6.25%),没有合适产品3人(3.75%),其他2人(2.5%)
13.期待的功能:更强大智能化功能25人(31.25%),更便捷操作方式20人(25%),更好数据安全与隐私保护15人(18.75%),更丰富生态兼容性12人(15%),其他8人(10%)
(四)开放性问题
部分用户建议:希望智能产品价格更加亲民;简化操作流程,适合各年龄段人群;加强数据安全保护;丰富产品功能,满足多样化需求;提升售后服务质量等。`
const visualGroups = [
{
title: "1.基本信息",
items: [
{ key: "gender", label: "性别" },
{ key: "age", label: "年龄" },
{ key: "income", label: "月收入" },
{ key: "city", label: "城市级别" },
],
},
{
title: "2.购买行为与决策因素",
items: [
{ key: "hasPurchased", label: "是否购买过智能产品" },
{ key: "productTypes", label: "购买过的智能产品类型" },
{ key: "priorityFactors", label: "优先考虑的因素" },
{ key: "factorImportance", label: "各因素影响程度统计" },
{ key: "innovationPremium", label: "是否为创新功能支付更高价格" },
{ key: "infoChannels", label: "获取信息渠道" },
{ key: "purchaseScenario", label: "购买场景" },
],
},
{
title: "3.未购买情况与顾虑",
items: [
{ key: "nonPurchaseReasons", label: "未购买原因" },
{ key: "expectedFunctions", label: "期待的功能" },
],
},
]
const taskConfig = ref(null)
const saving = ref(false)
const uploading = ref(false)
const questionnaireImporting = ref(false)
const fileInputRef = ref(null)
const questionnaireImportInputRef = ref(null)
const questionnaireEditorRef = ref(null)
const uploadContext = ref({ type: "", key: "" })
const questionnaireEditorVisible = ref(false)
const showQuestionnairePreview = ref(false)
const uploadAccept = computed(() => {
if (uploadContext.value.type === "questionnaire") return ".pdf"
if (uploadContext.value.type === "dataExcel") return ".xls,.xlsx"
if (uploadContext.value.type === "chart") return ".png,.jpg,.jpeg,.webp"
return ".png,.jpg,.jpeg,.webp,.pdf,.doc,.docx"
})
function createForm() {
return {
activeStep: 0,
targetHypotheses: createTargetHypotheses(),
kanoNeeds: createKanoNeeds(),
questionnaireQuestions: createQuestionnaireQuestions(),
questionnaireImport: null,
surveyReportRows: createSurveyReportRows(),
questionnaireText: questionnaireTemplate,
questionnaireFileName: "",
questionnaireFileUrl: "",
dataExcelFileName: "",
dataExcelFileUrl: "",
chartFiles: {},
}
}
function createTargetHypotheses() {
return [
{ targetGroup: "职场白领(25-35岁)", keyFinding: "", coreHypothesis: "" },
{ targetGroup: "精致妈妈(28-38岁)", keyFinding: "", coreHypothesis: "" },
{ targetGroup: "银发人群(50-65岁)", keyFinding: "", coreHypothesis: "" },
]
}
function createEmptyTargetHypothesis() {
return { targetGroup: "", keyFinding: "", coreHypothesis: "" }
}
const kanoTypeOptions = ["基本型", "期望型", "兴奋型", "无差异型", "反向型"]
const questionnaireSections = [
{ value: "基本信息", label: "基本信息" },
{ value: "购买行为", label: "购买行为" },
{ value: "需求偏好", label: "需求偏好" },
{ value: "开放建议", label: "开放建议" },
]
const questionnaireQuestionTypes = [
{ value: "SINGLE", label: "单选题" },
{ value: "MULTIPLE", label: "多选题" },
{ value: "LIKERT", label: "Likert 五级量表" },
{ value: "TEXT", label: "开放题" },
]
const LIKERT_SCALE_TEXT = "非常不重要、较不重要、一般、较重要、非常重要"
const showQuestionnaireComposePreview = ref(false)
const hasEditableQuestionnaire = computed(() => form.value.questionnaireQuestions.some((item) => String(item?.title || "").trim()))
function questionTypeLabel(type) { return questionnaireQuestionTypes.find((item) => item.value === type)?.label || "单选题" }
function questionOptions(question) {
if (question.type === "LIKERT") return LIKERT_SCALE_TEXT.split("、")
if (question.type === "TEXT") return []
return String(question.options || "").split(/[,,、\n]/).map((item) => item.trim()).filter(Boolean)
}
function questionsBySection(section) { return form.value.questionnaireQuestions.filter((item) => item.section === section && String(item?.title || "").trim()) }
function optionLetter(index) { return String.fromCharCode(65 + index) }
function openQuestionnairePreview() { showQuestionnaireComposePreview.value = true }
// ===== 规范模板导入:从 Word 解析文本中按模板格式识别题目 =====
function parseQuestionnaireContent(content) {
const sectionNames = questionnaireSections.map((item) => item.value)
const typeKeywords = [["多选", "MULTIPLE"], ["量表", "LIKERT"], ["开放", "TEXT"], ["单选", "SINGLE"]]
const questions = []
let section = "基本信息"
const lines = String(content || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
for (const line of lines) {
const sectionMatch = line.match(/^【(.+?)】$/)
if (sectionMatch) {
const name = sectionNames.find((item) => sectionMatch[1].includes(item))
if (name) section = name
continue
}
const questionMatch = line.match(/^(\d+)\s*[.、.]\s*(.+)$/)
if (questionMatch) {
let title = questionMatch[2].trim()
let type = "SINGLE"
const typeMatch = title.match(/[((]([^))]{1,8})[))]\s*$/)
if (typeMatch) {
const hit = typeKeywords.find(([keyword]) => typeMatch[1].includes(keyword))
if (hit) {
type = hit[1]
title = title.slice(0, typeMatch.index).trim()
}
}
questions.push({ id: `question-${Date.now()}-${questions.length + 1}`, section, type, title, options: type === "LIKERT" ? LIKERT_SCALE_TEXT : "" })
continue
}
const optionMatch = line.match(/^([A-Z])\s*[.、.]\s*(.+)$/)
if (optionMatch && questions.length) {
const target = questions[questions.length - 1]
if (target.type === "SINGLE" || target.type === "MULTIPLE") {
target.options = target.options ? `${target.options},${optionMatch[2].trim()}` : optionMatch[2].trim()
}
}
}
return questions.filter((item) => item.title)
}
// ===== 问卷 Word 生成(最小可用 .docx,兼容 Word 与后端 POI 解析) =====
function escapeXmlValue(value) { return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;") }
async function buildDocxBlob(paragraphs) {
const body = paragraphs.map((item) => {
const props = `${item.align === "center" ? '<w:jc w:val="center"/>' : ""}`
const runProps = `${item.bold ? "<w:b/>" : ""}<w:sz w:val="${item.size || 24}"/>`
return `<w:p><w:pPr>${props}</w:pPr><w:r><w:rPr>${runProps}</w:rPr><w:t xml:space="preserve">${escapeXmlValue(item.text)}</w:t></w:r></w:p>`
}).join("")
const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${body}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/></w:sectPr></w:body></w:document>`
const contentTypes = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>`
const rels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`
const zip = new JSZip()
zip.file("[Content_Types].xml", contentTypes)
zip.folder("_rels").file(".rels", rels)
zip.folder("word").file("document.xml", documentXml)
return zip.generateAsync({ type: "blob", mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" })
}
function downloadBlob(blob, fileName) {
const link = document.createElement("a")
link.href = URL.createObjectURL(blob)
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
setTimeout(() => URL.revokeObjectURL(link.href), 2000)
}
function questionnaireDocxParagraphs() {
const paragraphs = [{ text: "消费者需求调研问卷", bold: true, size: 32, align: "center" }]
questionnaireSections.forEach((section) => {
const items = questionsBySection(section.value)
if (!items.length) return
paragraphs.push({ text: section.label, bold: true, size: 28 })
items.forEach((item) => {
paragraphs.push({ text: `${items.indexOf(item) + 1}. ${item.title}(${questionTypeLabel(item.type)})`, size: 24 })
if (["SINGLE", "MULTIPLE"].includes(item.type)) {
questionOptions(item).forEach((option, optIndex) => paragraphs.push({ text: `${optionLetter(optIndex)}. ${option}`, size: 24 }))
} else if (item.type === "LIKERT") {
paragraphs.push({ text: questionOptions(item).map((option, optIndex) => `${optionLetter(optIndex)}. ${option}`).join(" "), size: 24 })
} else {
paragraphs.push({ text: "________________________", size: 24 })
}
})
})
return paragraphs
}
async function downloadQuestionnaireWord() {
if (!hasEditableQuestionnaire.value) return
downloadBlob(await buildDocxBlob(questionnaireDocxParagraphs()), "消费者需求调研问卷.docx")
proxy?.$modal?.msgSuccess("问卷已导出为 Word 文件")
}
async function downloadQuestionnaireImportTemplate() {
const paragraphs = [
{ text: "消费者需求调研问卷 · 导入模板", bold: true, size: 32, align: "center" },
{ text: "填写说明:", bold: true, size: 24 },
{ text: "1. 分组行用【】标注,仅支持:基本信息、购买行为、需求偏好、开放建议。", size: 21 },
{ text: "2. 题目行以数字加“.”开头,题干末尾用括号标注题型:(单选)(多选)(量表)(开放题),不填默认单选。", size: 21 },
{ text: "3. 选项行以“A.”“B.”开头,每行一个选项;(量表)题型无需选项,系统固定五级量表。", size: 21 },
{ text: "4. 编辑完成后,通过“导入问卷(Word 文档)”上传本文件,系统自动识别题目并可在线预览、下载。", size: 21 },
{ text: "【基本信息】", bold: true, size: 24 },
{ text: "1. 您的年龄段是?(单选)", size: 24 },
{ text: "A. 18-25岁", size: 24 }, { text: "B. 26-35岁", size: 24 }, { text: "C. 36-45岁", size: 24 }, { text: "D. 46岁及以上", size: 24 },
{ text: "2. 您所在的城市属于?(单选)", size: 24 },
{ text: "A. 一线城市", size: 24 }, { text: "B. 新一线或二线城市", size: 24 }, { text: "C. 三线及以下城市", size: 24 },
{ text: "【购买行为】", bold: true, size: 24 },
{ text: "3. 您是否购买过本案例产品?(单选)", size: 24 },
{ text: "A. 购买过", size: 24 }, { text: "B. 未购买过", size: 24 },
{ text: "【需求偏好】", bold: true, size: 24 },
{ text: "4. 请对步骤二提到的功能进行需求度评价(量表)", size: 24 },
{ text: "【开放建议】", bold: true, size: 24 },
{ text: "5. 您对本案例产品的期望与顾虑?(开放题)", size: 24 },
]
downloadBlob(await buildDocxBlob(paragraphs), "消费者需求调研问卷-导入模板.docx")
proxy?.$modal?.msgSuccess("导入模板已下载,按格式编辑后可直接导入")
}
function createKanoNeeds() {
return Array.from({ length: 3 }, createEmptyKanoNeed)
}
function createEmptyKanoNeed() {
return { feature: "", kanoType: "", featureDirection: "" }
}
function createQuestionnaireQuestions() {
return [
createQuestionnaireQuestion("基本信息", "SINGLE"),
createQuestionnaireQuestion("购买行为", "SINGLE"),
createQuestionnaireQuestion("需求偏好", "LIKERT"),
createQuestionnaireQuestion("开放建议", "TEXT"),
]
}
function createQuestionnaireQuestion(section = "基本信息", type = "SINGLE") {
questionnaireQuestionSequence += 1
return { id: `question-${Date.now()}-${questionnaireQuestionSequence}`, section, type, title: "", options: "" }
}
function createSurveyReportRows() {
return Array.from({ length: 5 }, () => ({ title: "", content: "" }))
}
const briefOpen = ref(true)
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(() => parseTrainingArray(taskConfig.value?.steps, []).slice(0, 4))
const activeStepName = computed(() => flowSteps.value[form.value.activeStep] || "")
const progressItems = computed(() => [
{ text: hasQuestionnaireDesign() ? "已完成:问卷设计" : "待完成:问卷设计", status: hasQuestionnaireDesign() ? "done" : "" },
{ text: form.value.dataExcelFileName ? "已完成:数据整理上传" : "待完成:整理并上传Excel", status: form.value.dataExcelFileName ? "done" : "" },
{ text: Object.keys(form.value.chartFiles).length ? "进行中:数据可视化截图" : "待完成:数据可视化截图", status: Object.keys(form.value.chartFiles).length ? "done" : "" },
{ text: hasSurveyReport() ? "已完成:问卷调研报告" : "待完成:问卷调研报告", status: hasSurveyReport() ? "done" : "" },
])
onMounted(async () => {
await loadTaskConfig()
await loadSavedAnswer()
await nextTick()
renderQuestionnaireEditor()
})
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(/[;;|,,\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 = {
...createForm(),
...parsed,
activeStep: Number.isInteger(parsed.activeStep) ? parsed.activeStep : 0,
questionnaireText: normalizeQuestionnaireContent(parsed.questionnaireText || questionnaireTemplate),
targetHypotheses: normalizeTargetHypotheses(parsed.targetHypotheses),
kanoNeeds: normalizeKanoNeeds(parsed.kanoNeeds),
questionnaireQuestions: normalizeQuestionnaireQuestions(parsed.questionnaireQuestions),
questionnaireImport: normalizeQuestionnaireImport(parsed.questionnaireImport),
surveyReportRows: normalizeSurveyReportRows(parsed.surveyReportRows),
chartFiles: parsed.chartFiles || {},
}
nextTick(renderQuestionnaireEditor)
} catch (error) {
// 忽略历史异常数据。
}
}
function normalizeTargetHypotheses(rows) {
if (!Array.isArray(rows) || !rows.length) return createTargetHypotheses()
return rows.map((row) => ({
targetGroup: String(row?.targetGroup || ""),
keyFinding: String(row?.keyFinding || ""),
coreHypothesis: String(row?.coreHypothesis || ""),
}))
}
function normalizeKanoNeeds(rows) {
if (!Array.isArray(rows) || !rows.length) return createKanoNeeds()
return rows.slice(0, KANO_NEED_LIMIT).map((row) => ({
feature: String(row?.feature || ""),
kanoType: kanoTypeOptions.includes(row?.kanoType) ? row.kanoType : "",
featureDirection: String(row?.featureDirection || ""),
}))
}
function normalizeQuestionnaireQuestions(rows) {
if (!Array.isArray(rows) || !rows.length) return createQuestionnaireQuestions()
return rows.map((row, index) => ({
id: String(row?.id || `question-restored-${index}`),
section: questionnaireSections.some((item) => item.value === row?.section) ? row.section : "基本信息",
type: questionnaireQuestionTypes.some((item) => item.value === row?.type) ? row.type : "SINGLE",
title: String(row?.title || ""),
options: String(row?.options || ""),
}))
}
function normalizeQuestionnaireImport(value) {
if (!value || typeof value !== "object" || !value.name) return null
return {
name: String(value.name),
url: String(value.url || ""),
previewContent: String(value.previewContent || ""),
}
}
function normalizeSurveyReportRows(rows) {
if (!Array.isArray(rows) || !rows.length) return createSurveyReportRows()
return Array.from({ length: 5 }, (_, index) => ({
title: String(rows[index]?.title || ""),
content: String(rows[index]?.content || ""),
}))
}
function syncQuestionnaireText() {
if (!questionnaireEditorRef.value) return
form.value.questionnaireText = questionnaireEditorRef.value.innerHTML
}
function renderQuestionnaireEditor() {
if (!questionnaireEditorRef.value) return
if (questionnaireEditorRef.value.innerHTML !== form.value.questionnaireText) {
questionnaireEditorRef.value.innerHTML = form.value.questionnaireText
}
}
function normalizeQuestionnaireContent(value) {
const content = String(value || "").trim()
if (!content) return questionnaireTemplate
return /<\/?[a-z][\s\S]*>/i.test(content) ? content : textToHtml(content)
}
function textToHtml(value) {
return String(value)
.split(/\n{2,}/)
.map((block) => `<p>${escapeHtml(block).replace(/\n/g, "<br>")}</p>`)
.join("")
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
}
function questionnaireTextForExport() {
syncQuestionnaireText()
const wrapper = document.createElement("div")
wrapper.innerHTML = form.value.questionnaireText || ""
return wrapper.innerText.trim()
}
function triggerFileSelect(type, key = "") {
uploadContext.value = { type, key }
fileInputRef.value?.click()
}
function setActiveStep(index) {
form.value.activeStep = index
if (index === 0) {
nextTick(renderQuestionnaireEditor)
}
}
function addTargetHypothesis() {
form.value.targetHypotheses.push(createEmptyTargetHypothesis())
}
function removeTargetHypothesis() {
if (form.value.targetHypotheses.length <= 1) return
form.value.targetHypotheses.pop()
}
function addKanoNeed() {
if (form.value.kanoNeeds.length >= KANO_NEED_LIMIT) {
proxy?.$modal?.msgWarning(`最多可填写 ${KANO_NEED_LIMIT} 行`)
return
}
form.value.kanoNeeds.push(createEmptyKanoNeed())
}
function removeKanoNeed() {
if (form.value.kanoNeeds.length <= 1) return
form.value.kanoNeeds.pop()
}
function questionPlaceholder(section) {
const placeholders = {
基本信息: "例如:您的年龄处于以下哪个区间?",
购买行为: "例如:您通常通过什么渠道购买智能助眠产品?",
需求偏好: "例如:白噪声可定时关闭对您有多重要?",
开放建议: "例如:您希望智能助眠产品增加哪些功能?",
}
return placeholders[section] || "请输入题目内容"
}
function addQuestionnaireQuestion() {
form.value.questionnaireQuestions.push(createQuestionnaireQuestion())
}
function removeQuestionnaireQuestion(index) {
if (form.value.questionnaireQuestions.length <= 4) return
form.value.questionnaireQuestions.splice(index, 1)
}
function triggerQuestionnaireImport() {
questionnaireImportInputRef.value?.click()
}
function viewTargetUserProfile() {
router.push({ path: "/demand/people" })
}
async function handleFileChange(event) {
const file = event.target.files?.[0]
if (!file) return
const data = new FormData()
data.append("file", file)
uploading.value = true
try {
const res = await uploadStudentTrainingFile(data)
const url = res?.url || res?.fileName || res?.data?.url || res?.data?.fileName || ""
const { type, key } = uploadContext.value
if (type === "questionnaire") {
form.value.questionnaireFileName = file.name
form.value.questionnaireFileUrl = url
} else if (type === "dataExcel") {
form.value.dataExcelFileName = file.name
form.value.dataExcelFileUrl = url
} else if (type === "chart" && key) {
form.value.chartFiles = {
...form.value.chartFiles,
[key]: { name: file.name, url },
}
}
proxy?.$modal?.msgSuccess("上传成功")
} finally {
uploading.value = false
event.target.value = ""
}
}
async function handleQuestionnaireImport(event) {
const file = event.target.files?.[0]
if (!file) return
if (!/\.docx$/i.test(file.name)) {
proxy?.$modal?.msgWarning("请导入 .docx 格式的 Word 问卷,以便在线预览")
event.target.value = ""
return
}
const data = new FormData()
data.append("file", file)
questionnaireImporting.value = true
try {
const previewRes = await previewConsumerBehaviorQuestionnaireWord(data)
const uploadRes = await uploadStudentTrainingFile(data)
const url = uploadRes?.url || uploadRes?.fileName || uploadRes?.data?.url || uploadRes?.data?.fileName || ""
const parsed = parseQuestionnaireContent(previewRes?.data?.content)
if (parsed.length) {
while (parsed.length < 4) parsed.push(createQuestionnaireQuestion())
form.value.questionnaireQuestions = parsed
}
form.value.questionnaireImport = {
name: file.name,
url,
previewContent: String(previewRes?.data?.content || ""),
}
if (parsed.length) {
showQuestionnaireComposePreview.value = true
proxy?.$modal?.msgSuccess(`已按规范模板识别 ${parsed.length} 道题目,可在线预览与下载`)
} else {
showQuestionnairePreview.value = true
proxy?.$modal?.msgSuccess("问卷导入成功(未识别到模板题目,已按普通 Word 问卷导入)")
}
} finally {
questionnaireImporting.value = false
event.target.value = ""
}
}
function goPrev() {
if (form.value.activeStep > 0) setActiveStep(form.value.activeStep - 1)
}
async function goNext() {
if (form.value.activeStep <= 2) await persist("IN_PROGRESS")
if (form.value.activeStep < flowSteps.value.length - 1) setActiveStep(form.value.activeStep + 1)
}
function hasSurveyReport() {
const filled = form.value.surveyReportRows.filter((item) => String(item?.title || "").trim() || String(item?.content || "").trim())
return filled.length > 0 && filled.every((item) => String(item?.title || "").trim() && String(item?.content || "").trim())
}
function addSurveyReportRow() {
if (form.value.surveyReportRows.length >= 12) return
form.value.surveyReportRows.push({ title: "", content: "" })
}
function removeSurveyReportRow() {
if (form.value.surveyReportRows.length <= 1) return
form.value.surveyReportRows.pop()
}
function hasQuestionnaireDesign() {
return Boolean(form.value.questionnaireImport?.name)
|| form.value.questionnaireQuestions.some((item) => String(item?.title || "").trim())
}
function buildAnswerPayload(status) {
syncQuestionnaireText()
const saveAction = status === "SUBMITTED" ? "SUBMIT" : status === "RESET" ? "RESET" : "SAVE"
return {
saveAction,
currentStep: form.value.activeStep + 1,
step1Answer: JSON.stringify({
...form.value,
taskKey: TASK_KEY,
taskId: taskConfig.value?.id || "",
taskName: taskTitle.value,
flowSteps: flowSteps.value,
updatedAt: new Date().toISOString(),
}),
}
}
async function persist(status = "IN_PROGRESS") {
saving.value = true
try {
if (status !== "RESET" && (form.value.activeStep === 0 || status === "SUBMITTED")) {
await checkConsumerBehaviorStepOne({ hypotheses: form.value.targetHypotheses })
}
if (status !== "RESET" && (form.value.activeStep === 1 || status === "SUBMITTED")) {
await checkConsumerBehaviorStepTwo({ needs: form.value.kanoNeeds })
}
if (status !== "RESET" && (form.value.activeStep === 2 || status === "SUBMITTED")) {
await checkConsumerBehaviorStepThree({
questions: form.value.questionnaireQuestions,
importedDocumentUrl: form.value.questionnaireImport?.url || "",
})
}
if (status !== "RESET" && (form.value.activeStep === 3 || status === "SUBMITTED")) {
await checkConsumerBehaviorStepFour({ rows: form.value.surveyReportRows.filter((item) => String(item?.title || "").trim() || String(item?.content || "").trim()) })
}
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 nextTick()
renderQuestionnaireEditor()
await persist("RESET")
}
function exportOutcome() {
syncQuestionnaireText()
const lines = [
`任务:${taskTitle.value}`,
"",
`当前步骤:${flowSteps.value[form.value.activeStep] || ""}`,
"",
"问卷调查目标人群与核心假设:",
...form.value.targetHypotheses.map((item, index) => `${index + 1}. 目标人群:${item.targetGroup || ""}\n实训8关键发现:${item.keyFinding || ""}\n核心假设:${item.coreHypothesis || ""}`),
"",
"KANO 用户需求分类:",
...form.value.kanoNeeds.map((item, index) => `${index + 1}. 功能:${item.feature || ""}\nKANO类型:${item.kanoType || ""}\n功能方向:${item.featureDirection || ""}`),
"",
"消费者需求调研问卷:",
form.value.questionnaireImport?.name ? `导入问卷:${form.value.questionnaireImport.name}` : "",
...form.value.questionnaireQuestions.map((item, index) => `${index + 1}. [${item.section}] ${item.title || ""}(${questionnaireQuestionTypes.find((type) => type.value === item.type)?.label || ""})`),
"",
"问卷设计:",
questionnaireTextForExport(),
"",
"问卷PDF文件:",
form.value.questionnaireFileName || "未上传",
"",
"数据收集文件:",
form.value.dataExcelFileName || "未上传",
"",
"问卷调研报告:",
...form.value.surveyReportRows.map((item, index) => `${index + 1}. ${item.title || "未命名模块"}\n${item.content || ""}`),
]
const blob = new Blob([lines.join("\n")], { 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)
}
function surveyReportTitlePlaceholder(index) {
return ["例如:调研背景与目的", "例如:样本与调研方法", "例如:用户购买决策因素", "例如:不同群体需求差异", "例如:产品建议与结论"][index] || "请输入报告模块标题"
}
function surveyReportContentPlaceholder(index) {
return ["说明本次问卷调研的背景、目标用户与调研目的。", "说明样本数量、调研时间、调研渠道与数据来源。", "结合问卷数据,分析影响用户购买决策的核心因素。", "比较不同人群在需求、价格、渠道或购买场景上的差异。", "总结调研结论,并提出产品功能、定价或营销建议。"][index] || "请输入报告内容"
}
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)
}
</script>
<style scoped lang="scss">
/*
* 页面级令牌(教育蓝极简主题):纯白画布 + 教育蓝主操作 + 中性灰层次。
*/
.consumer-behavior-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;
/* Element Plus 主色:教育蓝 */
--el-color-primary: #2563eb;
--el-color-primary-light-3: #60a5fa;
--el-color-primary-light-5: #93c5fd;
--el-color-primary-light-7: #bfdbfe;
--el-color-primary-light-8: #dbeafe;
--el-color-primary-light-9: #eff6ff;
--el-color-primary-dark-2: #1d4ed8;
--el-input-hover-border-color: #3b82f6;
--el-input-focus-border-color: #2563eb;
--el-input-focus-ring-color: rgba(59, 130, 246, 0.15);
--el-textarea-hover-border-color: #3b82f6;
--el-textarea-focus-border-color: #2563eb;
/* 字号梯度统一继承 .student-training-shell,页面不再重复声明 */
position: relative;
z-index: 2;
display: grid;
grid-template-columns: minmax(720px, 1fr) 340px;
gap: 24px;
width: 100%;
max-width: 100%;
min-height: calc(100vh - 106px);
margin: 0;
padding: 24px 24px 32px;
color: var(--student-text);
background: #ffffff;
line-height: 1.55;
}
.training-core {
min-width: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
}
.task-header {
position: relative;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin: 0;
padding: 18px 20px 18px 24px;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: var(--student-card-bg);
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 4px;
background: var(--student-accent);
}
}
.task-head-main {
display: grid;
justify-items: start;
gap: 8px;
min-width: 0;
}
.task-title {
margin: 0;
color: var(--student-text);
font-size: var(--student-fs-display);
line-height: 1.35;
font-weight: 700;
}
.task-brief {
margin: 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
line-height: 1.6;
}
.task-brief-text {
display: block;
}
.task-brief-toggle {
padding: 0;
border: 0;
color: var(--student-accent-deep);
background: transparent;
font: inherit;
font-weight: 700;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.task-brief-more {
display: grid;
gap: 6px;
margin-top: 8px;
b {
color: var(--student-text);
}
ul {
display: grid;
gap: 4px;
margin: 0;
padding-left: 18px;
}
p {
margin: 0;
}
}
.task-card {
margin-bottom: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.workbench-actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
/* ============================================================
* 操作工具栏横条:内联布局,去掉悬浮和边框
* ============================================================ */
.task-action-strip {
margin: 0;
padding: 10px 16px;
:deep(.training-task-toolbar) {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
}
.section-eyebrow {
margin: 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-caption);
font-weight: 800;
line-height: 1.4;
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: var(--student-fs-button);
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);
}
}
.step-card {
margin-bottom: 0;
padding: 12px;
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: 18px;
margin-bottom: 10px;
h3 {
margin: 2px 0 0;
color: var(--student-text);
font-size: var(--student-fs-h2);
font-weight: 700;
line-height: 1.35;
}
}
.task-desc {
margin: 0 0 14px;
color: #374151;
font-size: var(--student-fs-body);
line-height: 1.6;
}
.hypothesis-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px 18px;
margin-bottom: 12px;
}
.hypothesis-desc,
.kano-desc,
.questionnaire-design-desc {
margin: 0;
color: #374151;
font-size: var(--student-fs-body);
font-weight: 600;
line-height: 1.55;
}
.training-eight-action {
grid-column: 2;
grid-row: 1;
justify-self: end;
min-width: 124px;
height: 36px;
border: 1px solid rgba(37, 99, 235, 0.35);
border-radius: 10px;
color: var(--student-accent);
background: #ffffff;
font-size: var(--student-fs-button);
font-weight: 700;
cursor: pointer;
}
.hypothesis-actions,
.kano-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
.hypothesis-actions {
grid-column: 1 / -1;
grid-row: 2;
}
:deep(.hypothesis-action),
:deep(.training-eight-action) {
min-width: 120px;
height: 36px;
padding: 0 14px;
border: 1px solid rgba(37, 99, 235, 0.35);
border-radius: 10px;
color: var(--student-accent);
background: #ffffff;
font-size: var(--student-fs-button);
font-weight: 700;
}
:deep(.hypothesis-action:hover:not(.is-disabled)),
:deep(.training-eight-action:hover:not(.is-disabled)) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
color: var(--student-accent);
}
:deep(.hypothesis-action--quiet) {
border-color: var(--student-panel-border);
color: var(--student-text-secondary);
background: #ffffff;
}
:deep(.hypothesis-action--quiet:hover:not(.is-disabled)) {
border-color: var(--student-text-secondary);
background: #eff6ff;
color: var(--student-text-secondary);
}
.survey-report-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
margin: 0 0 10px;
}
.target-hypothesis-table-wrap,
.kano-table-wrap,
.survey-report-table-wrap {
overflow-x: auto;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: #ffffff;
}
.target-hypothesis-table,
.kano-table,
.survey-report-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
th,
td {
padding: 10px 12px;
border-right: 1px solid var(--student-panel-border);
border-bottom: 1px solid var(--student-panel-border);
vertical-align: top;
text-align: left;
background: transparent;
}
th:last-child,
td:last-child {
border-right: 0;
}
tr:last-child td {
border-bottom: 0;
}
th {
color: var(--student-text);
background: #eff6ff;
font-size: var(--student-fs-body);
font-weight: 800;
text-align: left;
}
}
.target-hypothesis-table {
min-width: 920px;
th:nth-child(1) {
width: 28%;
}
th:nth-child(2) {
width: 28%;
}
th:nth-child(3) {
width: 44%;
}
}
.kano-table {
min-width: 900px;
th:nth-child(1) {
width: 9%;
}
th:nth-child(2) {
width: 42%;
}
th:nth-child(3) {
width: 25%;
}
th:nth-child(4) {
width: 24%;
}
th {
position: sticky;
top: 0;
z-index: 1;
}
}
.kano-index {
color: var(--student-accent-light);
font-weight: 700;
text-align: center;
}
.survey-report-table {
min-width: 760px;
th:first-child,
td:first-child {
width: 22%;
}
}
.hypothesis-input,
.hypothesis-textarea {
width: 100%;
}
.hypothesis-input :deep(.el-input__wrapper) {
min-height: 36px;
border: 1px solid #d6dae0;
border-radius: 10px;
background: #ffffff;
box-shadow: none;
}
.hypothesis-input :deep(.el-input__wrapper.is-focus) {
border-color: var(--student-accent-light);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
.hypothesis-input :deep(.el-input__inner),
.hypothesis-textarea :deep(.el-textarea__inner) {
color: var(--student-text);
font-size: var(--student-fs-body);
}
.hypothesis-textarea :deep(.el-textarea__inner) {
min-height: 72px !important;
border: 1px solid #d6dae0;
border-radius: 10px;
padding: 8px 12px;
background: #ffffff;
box-sizing: border-box;
font-family: inherit;
font-size: var(--student-fs-body);
line-height: 1.6;
resize: vertical;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
&::placeholder {
color: #9ca3af;
}
&:focus {
border-color: var(--student-accent-light);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
}
.kano-textarea :deep(.el-textarea__inner) {
min-height: 44px !important;
}
.kano-select {
width: min(100%, 190px);
}
.kano-select :deep(.el-select__wrapper) {
min-height: 36px;
border: 1px solid #d6dae0;
border-radius: 10px;
background: #ffffff;
box-shadow: none;
}
.kano-select :deep(.el-select__placeholder) {
color: #9ca3af;
}
.kano-select-cell {
text-align: center;
}
.hypothesis-tip {
margin: 10px 0 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
}
.kano-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px 18px;
margin-bottom: 12px;
}
.questionnaire-design-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px 18px;
margin-bottom: 12px;
}
.questionnaire-import-action,
.questionnaire-editor-launcher__action,
.questionnaire-template-action,
.questionnaire-imported-file {
display: inline-flex;
align-items: center;
gap: 10px;
max-width: 100%;
min-height: 36px;
padding: 0 12px;
margin-bottom: 12px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
color: #374151;
background: #fafbfc;
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
button {
flex: 0 0 auto;
border: 0;
color: var(--student-accent);
background: transparent;
cursor: pointer;
font-weight: 600;
font-size: var(--student-fs-body-sm);
&:hover {
text-decoration: underline;
}
}
}
.questionnaire-editor-launcher {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
min-height: auto;
padding: 16px 20px;
margin-bottom: 10px;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: #fafbfc;
}
.questionnaire-editor-launcher__copy {
display: grid;
gap: 4px;
h4 {
margin: 0;
color: var(--student-text);
font-size: var(--student-fs-h1);
font-weight: 700;
}
p {
margin: 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
line-height: 1.55;
}
}
.questionnaire-editor-launcher__actions {
display: flex;
align-items: center;
gap: 10px;
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
}
.questionnaire-editor-launcher__action {
flex: 0 0 auto;
}
.questionnaire-editor-summary {
margin: 0 0 12px;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
}
.questionnaire-template-guide {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
margin-top: 10px;
padding: 12px 16px;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
color: #374151;
background: #fafbfc;
h5,
p {
margin: 0 0 4px;
line-height: 1.6;
}
h5 {
color: var(--student-text);
font-size: var(--student-fs-body);
font-weight: 700;
}
}
:deep(.questionnaire-editor-dialog .el-dialog),
:deep(.questionnaire-preview-dialog .el-dialog) {
border-radius: 12px;
color: var(--student-text);
background: #ffffff;
}
/* ===== 结构化问卷预览弹窗 ===== */
.questionnaire-compose-preview {
max-height: 60vh;
padding: 18px 26px;
overflow-y: auto;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: #ffffff;
&__title {
margin: 0 0 16px;
color: var(--student-text);
font-size: 20px;
font-weight: 800;
text-align: center;
}
&__section {
margin: 0 0 14px;
h4 {
margin: 0 0 8px;
padding-left: 8px;
border-left: 3px solid var(--student-accent);
color: var(--student-text);
font-size: var(--student-fs-body);
font-weight: 700;
}
ol {
margin: 0;
padding-left: 22px;
}
li {
margin: 8px 0;
}
.question-title {
margin: 0;
color: var(--student-text);
font-size: var(--student-fs-body);
font-weight: 600;
}
.question-type {
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
font-weight: 400;
}
.question-options {
margin: 6px 0 0;
padding: 0;
list-style: none;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
li {
margin: 4px 0;
}
}
.question-open-line {
margin: 6px 0 0;
color: var(--student-text-secondary);
letter-spacing: 2px;
}
}
&__empty {
margin: 0;
color: var(--student-text-secondary);
text-align: center;
}
}
:deep(.questionnaire-preview-dialog .el-dialog) {
border-radius: 12px;
color: var(--student-text);
background: #ffffff;
}
:deep(.questionnaire-editor-dialog .el-dialog__title),
:deep(.questionnaire-preview-dialog .el-dialog__title) {
color: var(--student-text);
font-weight: 700;
}
.questionnaire-editor-dialog__intro {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
p {
margin: 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-body-sm);
}
}
.questionnaire-editor-add {
border: 1px solid rgba(37, 99, 235, 0.35);
border-radius: 10px;
color: var(--student-accent);
background: #ffffff;
font-weight: 700;
}
.questionnaire-question-list {
display: grid;
gap: 10px;
max-height: min(58vh, 700px);
padding-right: 5px;
overflow: auto;
}
.questionnaire-question-card {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;
gap: 10px;
align-items: start;
padding: 12px;
border: 1px solid var(--student-panel-border);
border-radius: 12px;
background: #ffffff;
}
.questionnaire-question-card__number {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 8px;
color: var(--student-text-secondary);
background: var(--student-accent-soft);
font-size: var(--student-fs-body-sm);
font-weight: 700;
}
.questionnaire-question-card__fields {
display: grid;
grid-template-columns: 160px 160px minmax(0, 1fr);
gap: 8px;
:deep(.el-select__wrapper),
:deep(.el-input__wrapper) {
min-height: 34px;
border: 1px solid #d6dae0;
box-shadow: none;
background: #ffffff;
border-radius: 8px;
}
:deep(.el-input__inner) {
color: var(--student-text);
}
}
.likert-tip {
grid-column: 1 / -1;
margin: 4px 0 0;
color: var(--student-text-secondary);
font-size: var(--student-fs-caption);
}
.questionnaire-question-card__remove {
padding: 6px 10px;
border: 1px solid var(--student-panel-border);
border-radius: 8px;
color: var(--student-text-secondary);
background: #ffffff;
cursor: pointer;
font-size: var(--student-fs-body-sm);
font-weight: 600;
&:hover:not(:disabled) {
border-color: var(--student-accent);
color: var(--student-accent);
}
&:disabled {
cursor: not-allowed;
opacity: 0.45;
}
}
.questionnaire-word-preview {
max-height: min(62vh, 760px);
padding: 16px;
overflow: auto;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
color: var(--student-text);
background: #fafbfc;
white-space: pre-wrap;
line-height: 1.75;
}
.questionnaire-preview-empty {
color: var(--student-text-secondary);
}
.survey-report-desc {
max-width: 1050px;
margin-bottom: 12px;
}
.survey-report-title-input,
.survey-report-content-input {
width: 100%;
}
.survey-report-title-input :deep(.el-input__wrapper) {
min-height: 36px;
border: 1px solid #d6dae0;
border-radius: 10px;
background: #ffffff;
box-shadow: none;
}
.survey-report-title-input :deep(.el-input__inner) {
color: var(--student-text);
font-size: var(--student-fs-body);
font-weight: 600;
}
.survey-report-content-input :deep(.el-textarea__inner) {
min-height: 72px !important;
border: 1px solid #d6dae0;
border-radius: 10px;
padding: 8px 12px;
background: #ffffff;
box-sizing: border-box;
font-family: inherit;
font-size: var(--student-fs-body);
line-height: 1.6;
resize: vertical;
transition: border-color 0.18s ease, box-shadow 0.18s ease;
&::placeholder {
color: #9ca3af;
}
&:focus {
border-color: var(--student-accent-light);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
}
.file-input {
display: none;
}
/* ============ 底部步骤导航 ============ */
.step-navigation {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 16px;
padding: 12px 16px;
border: 1px solid var(--student-panel-border);
border-radius: 10px;
background: #ffffff;
}
.step-nav-left,
.step-nav-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
}
.step-nav-right {
margin-left: auto;
}
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid var(--student-accent);
border-radius: 8px;
color: #ffffff;
background: var(--student-accent);
font-family: inherit;
font-size: var(--student-fs-button);
font-weight: 600;
box-shadow: 0 1px 2px rgba(37, 99, 235, 0.2), 0 2px 8px rgba(37, 99, 235, 0.15);
cursor: pointer;
transition: all 0.16s ease;
white-space: nowrap;
.el-icon {
font-size: 18px;
}
&:hover:not(:disabled) {
border-color: var(--student-accent-deep);
background: var(--student-accent-deep);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(29, 78, 216, 0.3), 0 4px 12px rgba(29, 78, 216, 0.22);
}
&:active:not(:disabled) {
transform: translateY(0);
}
}
.btn-ghost {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid rgba(37, 99, 235, 0.35);
border-radius: 8px;
color: var(--student-accent);
background: #ffffff;
font-family: inherit;
font-size: var(--student-fs-button);
font-weight: 600;
cursor: pointer;
transition: all 0.16s ease;
white-space: nowrap;
.el-icon {
font-size: 18px;
}
&:hover:not(:disabled) {
border-color: var(--student-accent);
background: var(--student-accent-soft);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12);
}
}
.btn-save {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
height: 36px;
padding: 0 14px;
border: 1px solid #e5e7eb;
border-radius: 8px;
color: #4b5563;
background: #f9fafb;
font-family: inherit;
font-size: var(--student-fs-button);
font-weight: 600;
cursor: pointer;
transition: all 0.16s ease;
white-space: nowrap;
.el-icon {
font-size: 18px;
}
&:hover:not(:disabled) {
border-color: #d1d5db;
background: #f3f4f6;
}
}
.btn-primary:disabled,
.btn-ghost:disabled,
.btn-save:disabled,
.btn-nav:disabled {
cursor: not-allowed;
opacity: 0.5;
transform: none;
box-shadow: none;
}
.step-progress {
color: var(--student-text-secondary);
font-family: var(--student-font-data);
font-size: var(--student-fs-caption);
font-weight: 700;
letter-spacing: 0.05em;
white-space: nowrap;
}
.training-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 14px;
margin-top: 14px;
}
@media (max-width: 1200px) {
.consumer-behavior-page {
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) {
.consumer-behavior-page {
padding: 14px 14px 18px;
}
.workbench-actions,
.step-title-row,
.step-navigation,
.training-actions {
flex-direction: column;
align-items: stretch;
}
.step-nav-left,
.step-nav-right,
.btn-nav {
width: 100%;
}
.hypothesis-head {
grid-template-columns: 1fr;
}
.training-eight-action {
grid-column: auto;
grid-row: auto;
justify-self: stretch;
}
.hypothesis-actions,
.kano-actions {
justify-content: stretch;
}
.hypothesis-actions :deep(.hypothesis-action),
.kano-actions :deep(.hypothesis-action) {
flex: 1;
}
.kano-head,
.questionnaire-design-head,
.questionnaire-editor-launcher,
.questionnaire-template-guide {
align-items: stretch;
flex-direction: column;
}
.questionnaire-question-card {
grid-template-columns: 32px minmax(0, 1fr);
}
.questionnaire-question-card__fields {
grid-template-columns: 1fr;
}
.questionnaire-question-card__remove {
grid-column: 2;
justify-self: end;
}
.questionnaire-editor-dialog__intro {
flex-direction: column;
align-items: stretch;
}
}
@media (max-width: 760px) {
.consumer-behavior-page {
padding: 10px 10px 14px;
}
}
</style>
<style lang="scss">
/* === 步骤导航:分段连接胶囊(SKILL.md 规格) === */
#app .studentLayout section.app-main:has(.consumer-behavior-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 {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
gap: 8px !important;
min-width: 0 !important;
height: auto !important;
min-height: 40px !important;
margin: 0 !important;
padding: 8px 14px !important;
border: 1px solid #e5e7eb !important;
border-right: none !important;
border-radius: 0 !important;
background: #ffffff !important;
box-shadow: none !important;
color: #6b7280 !important;
font-size: var(--student-fs-body) !important;
font-weight: 600 !important;
line-height: 1.3 !important;
white-space: nowrap !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: var(--student-fs-body-sm) !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(.consumer-behavior-page) .step-indicator {
justify-content: flex-start !important;
overflow-x: auto !important;
}
#app .studentLayout section.app-main:has(.consumer-behavior-page) .step-indicator .step-tab {
flex: 0 0 140px !important;
max-width: none !important;
}
}
/* === 全页单选教育蓝 === */
#app .studentLayout section.app-main:has(.consumer-behavior-page) .el-radio__input.is-checked .el-radio__inner {
border-color: #2563eb !important;
background: #2563eb !important;
}
#app .studentLayout section.app-main:has(.consumer-behavior-page) .el-radio__input.is-checked .el-radio__inner::after {
background: #ffffff !important;
}
/* === training-action === */
#app .studentLayout section.app-main:has(.consumer-behavior-page) .training-actions .el-button.training-action,
#app .studentLayout section.app-main:has(.consumer-behavior-page) .training-actions button.training-action {
min-width: 140px !important;
min-height: 34px !important;
height: 34px !important;
padding: 0 14px !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
border-radius: 8px !important;
color: var(--student-accent) !important;
background: #ffffff !important;
font-size: var(--student-fs-body) !important;
font-weight: 700 !important;
line-height: 1 !important;
letter-spacing: 0 !important;
text-shadow: none !important;
box-shadow: none !important;
}
#app .studentLayout section.app-main:has(.consumer-behavior-page) .training-actions .el-button.training-action--submit,
#app .studentLayout section.app-main:has(.consumer-behavior-page) .training-actions button.training-action--submit {
border-color: var(--student-accent) !important;
color: #ffffff !important;
background: var(--student-accent) !important;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.28) !important;
}
#app .studentLayout section.app-main:has(.consumer-behavior-page) .training-actions .el-button.training-action:hover:not(.is-disabled),
#app .studentLayout section.app-main:has(.consumer-behavior-page) .training-actions button.training-action:hover:not(.is-disabled) {
transform: translateY(-1px) !important;
border-color: var(--student-accent) !important;
background: var(--student-accent-soft) !important;
color: var(--student-accent) !important;
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.12) !important;
}
#app .studentLayout section.app-main:has(.consumer-behavior-page) .training-actions .el-button.training-action--submit:hover:not(.is-disabled),
#app .studentLayout section.app-main:has(.consumer-behavior-page) .training-actions button.training-action--submit:hover:not(.is-disabled) {
border-color: var(--student-accent-deep) !important;
background: var(--student-accent-deep) !important;
color: #ffffff !important;
box-shadow: 0 5px 14px rgba(29, 78, 216, 0.32) !important;
}
/* === nav-action (step-navigation):原生 button,样式见 scoped 块 === */
/* ============================================================
* 顶部工具栏:TrainingMaterialButton 资料按钮 → 教育蓝
* ============================================================ */
.consumer-behavior-page .workbench-actions .material-button.el-button {
height: 34px !important;
min-height: 34px !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
border-radius: 8px !important;
color: #2563eb !important;
background: #ffffff !important;
font-size: var(--student-fs-body) !important;
font-weight: 600 !important;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease !important;
&:hover {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
box-shadow: 0 2px 6px rgba(37, 99, 235, 0.12) !important;
}
&.is-plain {
border-color: #e5e7eb !important;
color: #9ca3af !important;
&:hover {
border-color: #e5e7eb !important;
color: #9ca3af !important;
background: #ffffff !important;
box-shadow: none !important;
}
}
}
/* ============================================================
* 顶部导出按钮(原生 <button.outline-action>)
* ============================================================ */
#app .studentLayout section.app-main:has(.consumer-behavior-page) .workbench-actions>button.outline-action {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 6px !important;
height: 34px !important;
min-height: 34px !important;
padding: 0 14px !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
border-radius: 8px !important;
color: #2563eb !important;
background: #ffffff !important;
font-size: var(--student-fs-body) !important;
font-weight: 600 !important;
line-height: 1 !important;
transition: border-color 0.16s ease, background 0.16s ease, color 0.16s ease, box-shadow 0.16s ease !important;
}
#app .studentLayout section.app-main:has(.consumer-behavior-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:假设行操作 / 查看实训8
* ============================================================ */
#app .studentLayout section.app-main:has(.consumer-behavior-page) .el-button.hypothesis-action,
#app .studentLayout section.app-main:has(.consumer-behavior-page) .el-button.training-eight-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;
}
}
#app .studentLayout section.app-main:has(.consumer-behavior-page) .el-button.hypothesis-action--quiet {
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;
}
}
/* ============================================================
* 内容区次级 el-button:问卷导入 / 编辑器入口 / 模板下载
* ============================================================ */
#app .studentLayout section.app-main:has(.consumer-behavior-page) .el-button.questionnaire-import-action,
#app .studentLayout section.app-main:has(.consumer-behavior-page) .el-button.questionnaire-editor-launcher__action,
#app .studentLayout section.app-main:has(.consumer-behavior-page) .el-button.questionnaire-template-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;
}
}
/* ============================================================
* teleport 弹窗(append-to-body)内「新增题目」按钮:
* 以 body.student-light-mode 默认按钮选择器 +1 类特异性覆盖
* ============================================================ */
body.student-light-mode .el-button:not(.is-text):not(.is-link):not(.el-button--primary):not(.el-button--success):not(.el-button--danger):not(.el-button--warning):not(.task-toolbar-button):not(.material-button).questionnaire-editor-add {
min-height: 36px !important;
border: 1px solid rgba(37, 99, 235, 0.35) !important;
border-radius: 10px !important;
color: #2563eb !important;
background: #ffffff !important;
font-weight: 700 !important;
box-shadow: none !important;
&:hover {
border-color: #2563eb !important;
color: #2563eb !important;
background: #eff6ff !important;
}
}
</style>