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

1298 lines
40 KiB
Vue

<template>
<div class="consumer-behavior-page student-training-shell">
<main class="training-core">
<section class="task-header">
<div class="task-badge">市场洞察与需求分析</div>
<h1 class="task-title">{{ taskTitle }}</h1>
</section>
<TrainingTaskBrief
:background="taskBackground"
:goals="taskGoals"
:requirements="taskRequirement"
:show-material="false"
/>
<section class="task-card">
<div class="workbench-head">
<div>
<p class="section-eyebrow">Training Task</p>
<h3>任务实训</h3>
<p v-if="taskRequirement" class="workbench-description">{{ taskRequirement }}</p>
</div>
<div class="workbench-actions">
<TrainingMaterialButton :material-name="taskConfig?.materialName" :material-url="taskConfig?.materialUrl" />
<button type="button" class="outline-action" @click="exportOutcome">
<el-icon><Download /></el-icon>
导出实训成果
</button>
</div>
</div>
<nav v-if="flowSteps.length" class="step-indicator" aria-label="消费者需求与行为步骤">
<template v-for="(step, index) in flowSteps" :key="step">
<button
type="button"
class="step-tab"
:class="{ active: form.activeStep === index }"
@click="setActiveStep(index)"
>
{{ step }}
</button>
</template>
</nav>
<input
ref="fileInputRef"
type="file"
class="file-input"
:accept="uploadAccept"
@change="handleFileChange"
/>
<section class="step-card consumer-behavior-step-card">
<section v-if="form.activeStep === 0" class="step-panel">
<div class="questionnaire-head">
<p class="task-desc questionnaire-desc">
借助AI,生成智能产品购买决策因素调研问卷,提示词参考:设计问卷,用于分析用户购买智能产品的决策因素。问卷完成后复制到下述编辑框,并上传问卷pdf文档。
</p>
<el-button class="upload-action questionnaire-upload" :loading="uploading" @click="triggerFileSelect('questionnaire')">
<el-icon><Upload /></el-icon>
上传问卷
</el-button>
</div>
<div v-if="form.questionnaireFileName" class="file-chip questionnaire-file">
<el-icon><Document /></el-icon>
<span>{{ form.questionnaireFileName }}</span>
</div>
<div class="field-block questionnaire-field">
<span>示例:</span>
<div
ref="questionnaireEditorRef"
class="large-textarea questionnaire-textarea"
contenteditable="true"
role="textbox"
aria-multiline="true"
data-placeholder="请将借助AI设计完成的问卷内容复制到这里"
@input="syncQuestionnaireText"
@blur="syncQuestionnaireText"
></div>
</div>
</section>
<section v-else-if="form.activeStep === 1" class="step-panel">
<div class="panel-title">
<span>Step 02</span>
<h4>{{ flowSteps[1] }}</h4>
</div>
<p class="task-desc">假如本次调研共回收有效问卷 500 份,以下是各题目的数据统计结果:</p>
<div class="data-board">
<pre>{{ surveyStats }}</pre>
</div>
<p class="task-desc">请把数据整理到 Excel 中,并上传 Excel 文件。</p>
<div class="upload-row">
<el-button class="upload-action" :loading="uploading" @click="triggerFileSelect('dataExcel')">
<el-icon><Upload /></el-icon>
上传Excel文件
</el-button>
<div v-if="form.dataExcelFileName" class="file-chip">
<el-icon><Document /></el-icon>
<span>{{ form.dataExcelFileName }}</span>
</div>
</div>
</section>
<section v-else-if="form.activeStep === 2" class="step-panel">
<div class="panel-title">
<span>Step 03</span>
<h4>{{ flowSteps[2] }}</h4>
</div>
<p class="task-desc">
请根据上一步骤整理的Excel文件,利用Excel进行数据可视化,上传Excel可视化操作截图。
</p>
<div class="visual-group" v-for="group in visualGroups" :key="group.title">
<h5>{{ group.title }}</h5>
<div class="visual-grid">
<div v-for="item in group.items" :key="item.key" class="visual-card">
<span>{{ item.label }}</span>
<el-button class="mini-upload" :loading="uploading" @click="triggerFileSelect('chart', item.key)">
上传Excel图形截图
</el-button>
<small v-if="form.chartFiles[item.key]?.name">{{ form.chartFiles[item.key].name }}</small>
</div>
</div>
</div>
<h5 class="analysis-heading">
4.结合开放性问题建议以及上述数据可视化结果,进行用户购买智能产品决策因素分析,给出分析报告,注意结合不同类别调研数据进行综合性交叉分析。
</h5>
<ReportFields v-model="form.report" />
</section>
<section v-else class="step-panel">
<div class="panel-title">
<span>Step 04</span>
<h4>{{ flowSteps[3] }}</h4>
</div>
<p class="task-desc">
请根据问卷设计、数据收集与数据可视化结果,整理形成最终分析报告。
</p>
<ReportFields v-model="form.report" />
</section>
<div class="step-actions">
<el-button class="nav-action" :disabled="form.activeStep === 0" @click="goPrev">上一步</el-button>
<el-button class="nav-action nav-action--next" :disabled="form.activeStep === flowSteps.length - 1" @click="goNext">
下一步
</el-button>
</div>
<div class="training-actions">
<el-button class="training-action training-action--save" :loading="saving" @click="saveCurrentProgress">
保存当前进度
</el-button>
<el-button class="training-action training-action--submit" :loading="saving" @click="submitTask">
提交任务
</el-button>
<el-button class="training-action training-action--reset" @click="resetTraining">
重新实训
</el-button>
</div>
</section>
</section>
</main>
<TrainingAiSidebar
study-tip="当前步骤建议:先确认问卷能覆盖用户基本信息、购买行为、决策因素和未购买顾虑,再用数据分析支撑结论。"
reference-title="消费者行为分析参考"
reference-text="· 需求类型:功能需求、价格需求、服务需求、隐私安全需求<br />· 决策因素:价格、品牌、功能、质量、便捷性、售后<br />· 分析方法:分群对比、渠道分析、购买场景分析、未购买原因归因"
assist-text="报告中尽量把数据结论写成“现象 + 人群差异 + 产品启示”的结构,避免只罗列比例。"
:progress-items="progressItems"
/>
</div>
</template>
<script setup>
import { computed, defineComponent, getCurrentInstance, h, nextTick, onMounted, ref } from "vue";
import { Document, Download, Upload } from "@element-plus/icons-vue";
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
import { getTrainingTaskByKey } from "@/api/trainingTask";
import { getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
const TASK_KEY = "consumer-behavior";
const { proxy } = getCurrentInstance();
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 reportPlaceholders = {
coreDecisionFactors:
"从调研数据可以看出,功能实用性和产品质量与稳定性是用户购买智能产品时最为关注的因素,选择“重要”和“非常重要”的人数占比分别达到80%和75%。这表明用户在购买智能产品时,更加注重产品能否切实解决实际问题,以及产品在长期使用过程中的可靠性。\n\n价格也是用户重点考虑的因素之一,有65%的用户认为价格重要或非常重要。不同收入群体对价格的敏感度有所差异,中低收入群体在购买智能产品时,价格仍是主要的制约因素,而高收入群体则更愿意为高品质、高性能的产品支付更高价格。",
groupDifferences:
"1. 年龄差异:19-25岁的年轻群体更注重产品的外观设计和生态兼容性,他们追求个性化和时尚感,同时希望智能产品能够与其他设备实现联动。\n2. 收入差异:高收入群体对品牌知名度和数据安全与隐私保护的关注度更高;低收入群体则主要聚焦于价格和功能实用性。\n3. 城市级别差异:一线城市和新一线城市用户对生态兼容性和创新功能的接受度更高,三线及以下城市用户更关注价格和产品质量。",
nonPurchaseOpportunity:
"未购买智能产品的用户中,价格过高是最主要的原因,占比达到43.75%。这反映出当前部分智能产品的价格超出了一些用户的承受范围,市场上存在对高性价比智能产品的需求。\n\n此外,对智能产品功能不了解也是未购买的重要原因之一。企业需要加强市场教育,通过产品介绍视频、使用教程和线下体验,降低用户认知门槛。同时,数据安全与隐私保护也可以作为产品差异化卖点。",
channelScenario:
"电商平台和社交媒体是用户获取智能产品信息的主要渠道,分别有60%和50%的用户选择。企业应加大在电商平台和社交媒体上的内容投放,通过评测、种草、场景演示等方式提高产品曝光。\n\n购买场景方面,自己使用是最主要场景,占比70%。但赠送家人、朋友也占据一定比例,企业可以针对礼品场景设计套装和包装,提高产品附加值。",
};
const emptyReport = {
coreDecisionFactors: "",
groupDifferences: "",
nonPurchaseOpportunity: "",
channelScenario: "",
};
const reportSampleMarkers = {
coreDecisionFactors: ["从调研数据可以看出", "价格也是用户重点考虑的因素之一"],
groupDifferences: ["年龄差异", "收入差异", "城市级别差异"],
nonPurchaseOpportunity: ["未购买智能产品的用户中", "价格过高是最主要的原因"],
channelScenario: ["电商平台和社交媒体是用户获取智能产品信息的主要渠道", "购买场景方面"],
};
function isDefaultReportSample(key, value) {
const currentValue = String(value || "");
return (
currentValue === reportPlaceholders[key] ||
reportSampleMarkers[key]?.some((marker) => currentValue.includes(marker))
);
}
const ReportFields = defineComponent({
name: "ReportFields",
props: {
modelValue: {
type: Object,
required: true,
},
},
emits: ["update:modelValue"],
setup(props, { emit }) {
const update = (key, value) => {
emit("update:modelValue", {
...props.modelValue,
[key]: value,
});
};
const fields = [
["coreDecisionFactors", "核心决策因素", reportPlaceholders.coreDecisionFactors],
["groupDifferences", "不同群体决策因素差异", reportPlaceholders.groupDifferences],
["nonPurchaseOpportunity", "未购买用户的顾虑与市场机会", reportPlaceholders.nonPurchaseOpportunity],
["channelScenario", "信息获取渠道与购买场景分析", reportPlaceholders.channelScenario],
];
return () =>
h(
"div",
{ class: "report-fields" },
fields.map(([key, label, placeholder]) =>
h("label", { class: "field-block", key }, [
h("span", label),
h("textarea", {
class: "large-textarea",
value: isDefaultReportSample(key, props.modelValue[key]) ? "" : props.modelValue[key],
placeholder,
onInput: (event) => update(key, event.target.value),
}),
])
)
);
},
});
const taskConfig = ref(null);
const saving = ref(false);
const uploading = ref(false);
const fileInputRef = ref(null);
const questionnaireEditorRef = ref(null);
const uploadContext = ref({ type: "", key: "" });
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,
questionnaireText: questionnaireTemplate,
questionnaireFileName: "",
questionnaireFileUrl: "",
dataExcelFileName: "",
dataExcelFileUrl: "",
chartFiles: {},
report: { ...emptyReport },
};
}
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(() => parseArray(taskConfig.value?.steps, []).slice(0, 4));
const progressItems = computed(() => [
{ text: form.value.questionnaireFileName ? "已完成:问卷上传" : "待完成:问卷设计与上传", status: form.value.questionnaireFileName ? "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: hasReportContent() ? "进行中:分析报告" : "待完成:分析报告", status: hasReportContent() ? "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),
chartFiles: parsed.chartFiles || {},
report: normalizeReport(parsed.report),
};
nextTick(renderQuestionnaireEditor);
} catch (error) {
// 忽略历史异常数据。
}
}
function normalizeReport(value = {}) {
return Object.keys(emptyReport).reduce((report, key) => {
const currentValue = value?.[key] || "";
report[key] = isDefaultReportSample(key, currentValue) ? "" : currentValue;
return report;
}, { ...emptyReport });
}
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);
}
}
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 = "";
}
}
function goPrev() {
if (form.value.activeStep > 0) setActiveStep(form.value.activeStep - 1);
}
function goNext() {
if (form.value.activeStep < flowSteps.value.length - 1) setActiveStep(form.value.activeStep + 1);
}
function hasReportContent() {
return Object.values(form.value.report || {}).some((value) => String(value || "").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 {
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] || ""}`,
"",
"问卷设计:",
questionnaireTextForExport(),
"",
"问卷PDF文件:",
form.value.questionnaireFileName || "未上传",
"",
"数据收集文件:",
form.value.dataExcelFileName || "未上传",
"",
"分析报告:",
`核心决策因素:\n${form.value.report.coreDecisionFactors || ""}`,
`不同群体决策因素差异:\n${form.value.report.groupDifferences || ""}`,
`未购买用户的顾虑与市场机会:\n${form.value.report.nonPurchaseOpportunity || ""}`,
`信息获取渠道与购买场景分析:\n${form.value.report.channelScenario || ""}`,
];
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);
}
</script>
<style scoped lang="scss">
.consumer-behavior-page {
position: relative;
z-index: 2;
display: grid;
grid-template-columns: minmax(720px, 1fr) 340px;
gap: 24px;
width: 100%;
max-width: 100%;
min-height: calc(100vh - 106px);
margin: 0;
padding: 0 0 32px;
color: #e8f7ff;
background: transparent;
}
.training-core {
min-width: 0;
padding: 28px;
border: 1px solid rgba(0, 180, 255, 0.25);
border-radius: 36px;
background: rgba(8, 18, 28, 0.6);
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.22), inset 0 1px 0 rgba(169, 229, 255, 0.08);
backdrop-filter: blur(8px);
}
.task-header {
margin-bottom: 24px;
}
.task-badge {
display: inline-flex;
margin-bottom: 12px;
padding: 4px 14px;
border: 1px solid rgba(0, 170, 255, 0.5);
border-radius: 30px;
color: #d6f6ff;
background: rgba(0, 170, 255, 0.2);
font-size: 12px;
font-weight: 700;
}
.task-title {
margin: 0;
color: transparent;
background: linear-gradient(135deg, #ffffff, #8bcbff);
-webkit-background-clip: text;
background-clip: text;
font-size: 30px;
line-height: 1.3;
font-weight: 900;
}
.goal-bg,
.task-card,
.step-card {
margin-bottom: 28px;
padding: 18px 22px;
border: 1px solid rgba(82, 174, 226, 0.42);
border-radius: 26px;
background: rgba(0, 25, 42, 0.64);
}
.goal-bg {
p,
li {
color: #d7ecff;
font-size: 14px;
line-height: 1.8;
}
p,
ol {
margin: 0;
}
ol {
padding-left: 18px;
}
}
.subtitle-icon {
display: inline-flex;
align-items: center;
gap: 8px;
color: #eafaff;
font-size: 18px;
font-weight: 900;
}
.subtitle-icon--spaced {
margin-top: 16px;
}
.task-card {
margin-top: 0;
}
.workbench-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 18px;
margin-bottom: 22px;
padding-bottom: 18px;
border-bottom: 1px solid rgba(0, 180, 255, 0.2);
}
.section-eyebrow {
margin: 0;
color: #71dfff;
font-size: 12px;
font-weight: 800;
line-height: 1.4;
}
.workbench-head h3 {
margin: 4px 0 8px;
color: #ffffff;
font-size: 26px !important;
font-weight: 800 !important;
line-height: 1.3 !important;
}
.workbench-actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.workbench-description {
margin: 0;
color: #d7ecff;
font-size: 15px;
font-weight: 400;
line-height: 1.8;
}
.outline-action {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 40px;
padding: 0 18px;
border: 1px solid rgba(99, 217, 255, 0.55);
border-radius: 999px;
color: #e9fbff;
background: rgba(17, 126, 178, 0.36);
font-size: 14px;
font-weight: 800;
&:hover {
transform: translateY(-1px);
border-color: rgba(116, 235, 255, 0.85);
background: rgba(18, 146, 204, 0.5);
}
}
.step-indicator {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
margin-bottom: 32px;
padding: 6px;
border: 1px solid rgba(0, 180, 255, 0.3);
border-radius: 60px;
background: rgba(0, 0, 0, 0.3);
}
.step-tab {
display: inline-flex;
flex: 1 1 0;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 0;
min-height: 46px;
padding: 0 14px;
border: 1px solid transparent;
border-radius: 40px;
color: #9ab3d0;
background: transparent;
font-size: 14px;
font-weight: 800;
text-align: center;
transition: color 0.2s, background 0.2s, border-color 0.2s, box-shadow 0.2s;
}
.step-tab.active {
border-color: rgba(0, 170, 255, 0.55);
color: #ffffff;
background: linear-gradient(95deg, #0a6b9e, #0a5c86);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.step-panel {
min-width: 0;
}
.step-card {
margin-bottom: 0;
}
.panel-title {
padding-bottom: 14px;
margin-bottom: 18px;
border-bottom: 1px solid rgba(51, 169, 217, 0.38);
}
.panel-title span {
color: #6eeaff;
font-weight: 900;
}
.panel-title h4 {
margin: 8px 0 0;
color: #f8fcff;
font-size: 26px;
line-height: 1.2;
}
.task-desc {
margin: 14px 0 18px;
color: #c7d8e6;
font-size: 15px;
line-height: 1.8;
}
.questionnaire-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-bottom: 12px;
}
.questionnaire-desc {
flex: 1;
margin: 0;
}
.questionnaire-upload {
min-width: 0;
}
.questionnaire-file {
margin-bottom: 10px;
}
.questionnaire-field {
margin-top: 0;
}
.large-textarea.questionnaire-textarea {
height: 920px;
min-height: 920px;
white-space: pre-wrap;
overflow: auto;
}
.questionnaire-textarea[contenteditable="true"] {
resize: none;
white-space: normal;
}
.questionnaire-textarea[contenteditable="true"]:empty::before {
color: #8fa8b8;
content: attr(data-placeholder);
}
.questionnaire-textarea :deep(h4) {
margin: 0 0 26px;
color: #f4fbff;
font-size: 18px;
font-weight: 900;
}
.questionnaire-textarea :deep(p) {
margin: 0 0 10px;
}
.questionnaire-textarea :deep(strong) {
display: inline-block;
margin-top: 16px;
color: #f4fbff;
font-size: 16px;
}
.questionnaire-textarea :deep(table) {
width: min(820px, 100%);
margin: 10px 0 24px;
border-collapse: collapse;
color: #e7f5ff;
background: rgba(1, 14, 26, 0.72);
font-size: 14px;
table-layout: fixed;
}
.questionnaire-textarea :deep(th),
.questionnaire-textarea :deep(td) {
height: 38px;
padding: 6px 10px;
border: 1px solid rgba(0, 180, 255, 0.22);
text-align: left;
vertical-align: middle;
}
.questionnaire-textarea :deep(th) {
color: #dff5ff;
background: rgba(8, 55, 82, 0.98);
font-weight: 900;
}
.questionnaire-textarea :deep(th:first-child),
.questionnaire-textarea :deep(td:first-child) {
width: 180px;
font-weight: 800;
}
.field-block {
display: block;
margin-top: 18px;
}
.field-block > span {
display: block;
margin-bottom: 10px;
color: #f1f8ff;
font-size: 15px;
font-weight: 900;
}
.large-textarea {
width: 100%;
min-height: 180px;
padding: 16px 18px;
border: 1px solid rgba(45, 95, 126, 0.95);
border-radius: 14px;
outline: none;
resize: vertical;
color: #f4fbff;
background: #0f1a24;
font-size: 14px;
line-height: 1.7;
transition: box-shadow 0.16s ease, border-color 0.16s ease;
}
.large-textarea::placeholder {
color: #8fa8b8;
opacity: 0.9;
}
.large-textarea:focus {
border-color: rgba(102, 235, 255, 0.9);
box-shadow: inset 0 0 0 1px rgba(102, 235, 255, 0.42), 0 0 20px rgba(0, 159, 220, 0.16);
}
.file-input {
display: none;
}
.upload-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-top: 18px;
}
.upload-action,
.mini-upload {
font-weight: 800;
}
.file-chip {
display: inline-flex;
align-items: center;
gap: 8px;
max-width: 100%;
padding: 8px 12px;
border: 1px solid rgba(95, 232, 255, 0.38);
border-radius: 999px;
color: #9fe8ff;
background: rgba(10, 68, 95, 0.42);
font-size: 13px;
font-weight: 700;
}
.data-board {
padding: 16px;
border: 1px solid rgba(0, 180, 255, 0.22);
border-radius: 22px;
background: rgba(1, 14, 26, 0.72);
}
.data-board pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: #d3e5f3;
font-family: inherit;
font-size: 14px;
line-height: 1.78;
}
.visual-group {
margin-top: 22px;
}
.visual-group h5 {
margin: 0 0 12px;
color: #f1f8ff;
font-size: 16px;
}
.analysis-heading {
margin: 28px 0 16px;
color: #f1f8ff;
font-size: 16px;
font-weight: 900;
line-height: 1.8;
}
:deep(.report-fields) {
display: grid;
gap: 18px;
}
:deep(.report-fields .field-block) {
display: block;
width: 100%;
margin-top: 0;
}
:deep(.report-fields .field-block > span) {
display: block;
margin-bottom: 8px;
color: #f1f8ff;
font-size: 15px;
font-weight: 900;
}
:deep(.report-fields .large-textarea) {
display: block;
width: 100%;
min-height: 132px;
padding: 14px 16px;
border: 1px solid rgba(45, 95, 126, 0.95);
border-radius: 14px;
outline: none;
resize: vertical;
color: #f4fbff;
background: #0f1a24;
font-family: inherit;
font-size: 14px;
line-height: 1.7;
box-sizing: border-box;
}
:deep(.report-fields .large-textarea:placeholder-shown) {
color: #cbd4dc;
}
:deep(.report-fields .large-textarea:focus) {
border-color: rgba(102, 235, 255, 0.9);
box-shadow: inset 0 0 0 1px rgba(102, 235, 255, 0.42), 0 0 20px rgba(0, 159, 220, 0.16);
}
:deep(.report-fields .large-textarea::placeholder) {
color: #cbd4dc;
opacity: 1;
}
.visual-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.visual-card {
min-height: 120px;
padding: 16px;
border: 1px solid rgba(0, 180, 255, 0.22);
border-radius: 20px;
background: rgba(1, 14, 26, 0.72);
}
.visual-card span {
display: block;
margin-bottom: 14px;
color: #e8f8ff;
font-weight: 900;
}
.visual-card small {
display: block;
margin-top: 10px;
color: #9fdced;
word-break: break-all;
}
.step-actions {
display: flex;
justify-content: space-between;
gap: 14px;
margin-top: 28px;
}
.nav-action {
min-width: 112px;
min-height: 40px;
padding: 0 18px;
border-color: rgba(99, 217, 255, 0.55);
border-radius: 999px;
color: #e9fbff;
background: rgba(17, 126, 178, 0.36);
font-size: 14px;
font-weight: 800;
}
.nav-action--next {
color: #fff;
background: linear-gradient(180deg, rgba(25, 143, 192, 0.98), rgba(11, 108, 156, 0.98));
}
.training-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 52px;
margin-top: 36px;
}
.training-actions :deep(.training-action) {
min-width: 172px;
height: 64px;
padding: 0 28px;
border: 1px solid #2c83aa;
border-radius: 6px;
color: #dff7ff;
background: rgba(13, 64, 88, 0.62);
box-shadow: none;
font-size: 24px;
font-weight: 900;
line-height: 1;
letter-spacing: 0;
text-shadow: none;
}
.training-actions :deep(.training-action span) {
display: inline-flex;
align-items: center;
color: inherit;
font-size: inherit;
line-height: 1;
}
.training-actions :deep(.training-action--submit),
.training-actions :deep(.training-action--reset) {
border-color: #2c83aa;
color: #dff7ff;
background: rgba(13, 64, 88, 0.62);
}
.training-actions :deep(.training-action:hover:not(.is-disabled)) {
border-color: #46b5e7;
color: #ffffff;
background: rgba(18, 85, 116, 0.78);
}
@media (max-width: 1200px) {
.consumer-behavior-page {
grid-template-columns: 1fr;
}
.visual-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.training-core {
padding: 18px;
border-radius: 24px;
}
.workbench-head,
.workbench-actions {
flex-direction: column;
align-items: stretch;
}
.workbench-actions :deep(.material-download),
.workbench-actions .outline-action {
width: 100%;
}
.step-indicator {
overflow-x: auto;
}
.step-tab {
min-width: 132px;
}
}
@media (max-width: 760px) {
.consumer-behavior-page {
padding-bottom: 18px;
}
.training-core,
.task-card,
.step-card {
padding: 18px;
}
.step-actions,
.training-actions {
align-items: stretch;
flex-direction: column;
}
.step-tab {
width: 100%;
}
.questionnaire-head {
flex-direction: column;
}
.questionnaire-upload {
width: 100%;
}
.training-actions {
gap: 14px;
}
.training-actions :deep(.training-action) {
width: 100%;
}
.visual-grid {
grid-template-columns: 1fr;
}
}
</style>