|
|
|
|
@ -140,28 +140,54 @@
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section v-show="isSectionVisible(3)" class="scenario-step-section">
|
|
|
|
|
<div class="journey-table-wrap">
|
|
|
|
|
<table class="journey-table">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th class="row-head">阶段</th>
|
|
|
|
|
<th v-for="phase in phases" :key="phase.key">{{ phase.label }}</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
<tr v-for="row in journeyRows" :key="row.key">
|
|
|
|
|
<th>{{ row.label }}</th>
|
|
|
|
|
<td v-for="phase in phases" :key="phase.key">
|
|
|
|
|
<textarea
|
|
|
|
|
v-model="form.matrix[row.key][phase.key]"
|
|
|
|
|
:placeholder="row.placeholder"
|
|
|
|
|
class="table-input"
|
|
|
|
|
></textarea>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="journey-map-intro">
|
|
|
|
|
<span>Step 03</span>
|
|
|
|
|
<h4>绘制用户购买旅程图</h4>
|
|
|
|
|
<p>基于步骤二的分析结果,绘制完整的用户购买旅程图,呈现旅程阶段、情感体验曲线、关键触点、痛点和机会点。</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="journey-map-upload-panel">
|
|
|
|
|
<input
|
|
|
|
|
ref="journeyMapInput"
|
|
|
|
|
class="hidden-file-input"
|
|
|
|
|
type="file"
|
|
|
|
|
accept="image/png,image/jpeg,image/webp"
|
|
|
|
|
@change="handleJourneyMapUpload"
|
|
|
|
|
/>
|
|
|
|
|
<template v-if="form.journeyMap.url">
|
|
|
|
|
<el-image
|
|
|
|
|
class="journey-map-preview"
|
|
|
|
|
:src="form.journeyMap.url"
|
|
|
|
|
:preview-src-list="[form.journeyMap.url]"
|
|
|
|
|
preview-teleported
|
|
|
|
|
fit="contain"
|
|
|
|
|
/>
|
|
|
|
|
<div class="journey-map-file-info">
|
|
|
|
|
<el-icon><UploadFilled /></el-icon>
|
|
|
|
|
<span>{{ form.journeyMap.name || "用户购买旅程图" }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="journey-map-actions">
|
|
|
|
|
<el-button type="primary" @click="openJourneyMapPicker">更换用户购买旅程图</el-button>
|
|
|
|
|
<el-button @click="clearJourneyMap">移除图片</el-button>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
<template v-else>
|
|
|
|
|
<el-icon class="journey-map-upload-icon"><UploadFilled /></el-icon>
|
|
|
|
|
<strong>上传用户购买旅程图</strong>
|
|
|
|
|
<p>支持 PNG、JPG、JPEG、WEBP 格式,单个文件不超过 5MB</p>
|
|
|
|
|
<el-button type="primary" :loading="uploadingJourneyMap" @click="openJourneyMapPicker">上传用户购买旅程图</el-button>
|
|
|
|
|
</template>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="journey-map-helper">
|
|
|
|
|
<div>
|
|
|
|
|
<h5>推荐工具及方法如下,自行选择:</h5>
|
|
|
|
|
<p>1. 下载模板,填写各阶段数据,完成用户购买旅程图。</p>
|
|
|
|
|
<p>2. 下载步骤二用户购买旅程表,使用通用 AI 大模型辅助生成。</p>
|
|
|
|
|
<p class="journey-map-prompt">参考提示词:请根据用户购买旅程表文件,生成一个美观的用户购买旅程图。</p>
|
|
|
|
|
</div>
|
|
|
|
|
<el-button plain @click="downloadJourneyMapTemplate"><el-icon><Download /></el-icon>下载用户购买旅程模板</el-button>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section v-show="isSectionVisible(4)" class="scenario-step-section">
|
|
|
|
|
@ -260,12 +286,13 @@
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
import { computed, getCurrentInstance, onMounted, ref } from "vue";
|
|
|
|
|
import { ArrowLeft, ArrowRight, CircleCheck, Download } from "@element-plus/icons-vue";
|
|
|
|
|
import { ArrowLeft, ArrowRight, CircleCheck, Download, UploadFilled } from "@element-plus/icons-vue";
|
|
|
|
|
import TrainingAiSidebar from "@/views/components/TrainingAiSidebar.vue";
|
|
|
|
|
import TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
|
|
|
|
|
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
|
|
|
|
|
import { getTrainingTaskByKey } from "@/api/trainingTask";
|
|
|
|
|
import { checkConsumerScenarioStepOne, checkConsumerScenarioStepTwo, getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
|
|
|
|
|
import { checkConsumerScenarioStepOne, checkConsumerScenarioStepTwo, checkConsumerScenarioStepThree, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
|
|
|
|
|
import { isStudentDemo } from "@/utils/studentDemo";
|
|
|
|
|
|
|
|
|
|
const TASK_KEY = "consumer-scenario";
|
|
|
|
|
const { proxy } = getCurrentInstance();
|
|
|
|
|
@ -409,6 +436,8 @@ const taskConfig = ref(null);
|
|
|
|
|
const saving = ref(false);
|
|
|
|
|
const checkingJourney = ref(false);
|
|
|
|
|
const checkingJourneyAnalysis = ref(false);
|
|
|
|
|
const journeyMapInput = ref(null);
|
|
|
|
|
const uploadingJourneyMap = ref(false);
|
|
|
|
|
const draggedStageId = ref("");
|
|
|
|
|
const journeyCheckMessage = ref("");
|
|
|
|
|
const journeyCheckedCorrectly = ref(false);
|
|
|
|
|
@ -446,6 +475,7 @@ function createForm() {
|
|
|
|
|
activeStep: 1,
|
|
|
|
|
journeyOrder: createShuffledJourneyOrder(),
|
|
|
|
|
journeyAnalysis: createJourneyAnalysis(),
|
|
|
|
|
journeyMap: { url: "", name: "" },
|
|
|
|
|
classifications: createClassifications(),
|
|
|
|
|
matrix: createMatrix(),
|
|
|
|
|
};
|
|
|
|
|
@ -550,6 +580,7 @@ function applySavedAnswer(value) {
|
|
|
|
|
activeStep: normalizeActiveStep(parsed.currentStep),
|
|
|
|
|
journeyOrder: normalizeJourneyOrder(parsed.journeyOrder),
|
|
|
|
|
journeyAnalysis: normalizeJourneyAnalysis(parsed.journeyAnalysis),
|
|
|
|
|
journeyMap: normalizeJourneyMap(parsed.journeyMap),
|
|
|
|
|
classifications: {
|
|
|
|
|
...createClassifications(),
|
|
|
|
|
...(parsed.classifications || {}),
|
|
|
|
|
@ -563,6 +594,13 @@ function applySavedAnswer(value) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeJourneyMap(value) {
|
|
|
|
|
return {
|
|
|
|
|
url: typeof value?.url === "string" ? value.url : "",
|
|
|
|
|
name: typeof value?.name === "string" ? value.name : "",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeJourneyAnalysis(value) {
|
|
|
|
|
const savedRows = Array.isArray(value) ? value : [];
|
|
|
|
|
return createJourneyAnalysis().map((row) => {
|
|
|
|
|
@ -607,7 +645,7 @@ function normalizeActiveStep(value) {
|
|
|
|
|
async function setCurrentStep(step) {
|
|
|
|
|
if (!hasConfiguredSteps.value) return;
|
|
|
|
|
const targetStep = normalizeActiveStep(step);
|
|
|
|
|
if (targetStep > form.value.activeStep && [1, 2].includes(form.value.activeStep) && !(await persist("IN_PROGRESS"))) return;
|
|
|
|
|
if (targetStep > form.value.activeStep && [1, 2, 3].includes(form.value.activeStep) && !(await persist("IN_PROGRESS"))) return;
|
|
|
|
|
form.value.activeStep = targetStep;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -702,6 +740,90 @@ async function ensureJourneyAnalysisValid() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openJourneyMapPicker() {
|
|
|
|
|
journeyMapInput.value?.click();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function revokeBlobUrl(url) {
|
|
|
|
|
if (typeof url === "string" && url.startsWith("blob:")) URL.revokeObjectURL(url);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleJourneyMapUpload(event) {
|
|
|
|
|
const file = event.target?.files?.[0];
|
|
|
|
|
if (!file) return;
|
|
|
|
|
const allowedTypes = ["image/png", "image/jpeg", "image/webp"];
|
|
|
|
|
if (!allowedTypes.includes(file.type)) {
|
|
|
|
|
proxy?.$modal?.msgError("请上传 PNG、JPG、JPEG 或 WEBP 图片");
|
|
|
|
|
event.target.value = "";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
|
|
|
proxy?.$modal?.msgError("图片大小不能超过 5MB");
|
|
|
|
|
event.target.value = "";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
uploadingJourneyMap.value = true;
|
|
|
|
|
try {
|
|
|
|
|
let url = "";
|
|
|
|
|
if (isStudentDemo()) {
|
|
|
|
|
url = URL.createObjectURL(file);
|
|
|
|
|
} else {
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
formData.append("file", file);
|
|
|
|
|
const response = await uploadStudentTrainingFile(formData);
|
|
|
|
|
url = response?.url || response?.data?.url || "";
|
|
|
|
|
}
|
|
|
|
|
if (!url) throw new Error("上传失败,请稍后重试");
|
|
|
|
|
revokeBlobUrl(form.value.journeyMap.url);
|
|
|
|
|
form.value.journeyMap = { url, name: file.name };
|
|
|
|
|
proxy?.$modal?.msgSuccess("用户购买旅程图上传成功");
|
|
|
|
|
} catch (error) {
|
|
|
|
|
proxy?.$modal?.msgError(error?.message || "上传失败,请稍后重试");
|
|
|
|
|
} finally {
|
|
|
|
|
uploadingJourneyMap.value = false;
|
|
|
|
|
event.target.value = "";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearJourneyMap() {
|
|
|
|
|
revokeBlobUrl(form.value.journeyMap.url);
|
|
|
|
|
form.value.journeyMap = { url: "", name: "" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function ensureJourneyMapValid() {
|
|
|
|
|
try {
|
|
|
|
|
const response = await checkConsumerScenarioStepThree({ journeyMapUrl: form.value.journeyMap.url });
|
|
|
|
|
const result = response?.data || {};
|
|
|
|
|
if (!result.valid) proxy?.$modal?.msgError(result.message || "请上传用户购买旅程图");
|
|
|
|
|
return result.valid === true;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
proxy?.$modal?.msgError(error?.message || "请先上传用户购买旅程图");
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function downloadJourneyMapTemplate() {
|
|
|
|
|
const escapeCsvValue = (value) => `"${String(value || "").replaceAll('"', '""')}"`;
|
|
|
|
|
const header = ["序号", "旅程阶段", "用户行为描述", "关键触点", "情感体验", "关键决策因素", "痛点标记", "机会点标记"];
|
|
|
|
|
const rows = form.value.journeyAnalysis.map((row, index) => [
|
|
|
|
|
index + 1,
|
|
|
|
|
stageTitle(row.stageId),
|
|
|
|
|
row.behaviorDescription,
|
|
|
|
|
row.keyTouchpoints,
|
|
|
|
|
row.emotionExperience,
|
|
|
|
|
row.decisionFactors,
|
|
|
|
|
"",
|
|
|
|
|
"",
|
|
|
|
|
]);
|
|
|
|
|
const csv = [header, ...rows].map((row) => row.map(escapeCsvValue).join(",")).join("\r\n");
|
|
|
|
|
const url = URL.createObjectURL(new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" }));
|
|
|
|
|
const link = document.createElement("a");
|
|
|
|
|
link.href = url;
|
|
|
|
|
link.download = "用户购买旅程图模板.csv";
|
|
|
|
|
link.click();
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildOutcome() {
|
|
|
|
|
return {
|
|
|
|
|
taskKey: TASK_KEY,
|
|
|
|
|
@ -711,6 +833,7 @@ function buildOutcome() {
|
|
|
|
|
journeyOrder: form.value.journeyOrder,
|
|
|
|
|
journeyCheckedCorrectly: journeyCheckedCorrectly.value,
|
|
|
|
|
journeyAnalysis: form.value.journeyAnalysis,
|
|
|
|
|
journeyMap: form.value.journeyMap,
|
|
|
|
|
phases,
|
|
|
|
|
classifications: form.value.classifications,
|
|
|
|
|
matrix: form.value.matrix,
|
|
|
|
|
@ -731,6 +854,7 @@ async function persist(status = "IN_PROGRESS") {
|
|
|
|
|
if (saving.value) return;
|
|
|
|
|
if (status !== "RESET" && form.value.activeStep === 1 && !(await ensureJourneyOrderValid())) return false;
|
|
|
|
|
if (status !== "RESET" && form.value.activeStep === 2 && !(await ensureJourneyAnalysisValid())) return false;
|
|
|
|
|
if (status !== "RESET" && form.value.activeStep === 3 && !(await ensureJourneyMapValid())) return false;
|
|
|
|
|
saving.value = true;
|
|
|
|
|
try {
|
|
|
|
|
await saveStudentTrainingAnswer(TASK_KEY, buildAnswerPayload(status));
|
|
|
|
|
@ -750,6 +874,7 @@ function submitTask() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function resetTraining() {
|
|
|
|
|
revokeBlobUrl(form.value.journeyMap.url);
|
|
|
|
|
form.value = createForm();
|
|
|
|
|
await persist("RESET");
|
|
|
|
|
}
|
|
|
|
|
@ -1416,6 +1541,145 @@ function exportOutcome() {
|
|
|
|
|
line-height: 1.65;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.journey-map-intro {
|
|
|
|
|
margin-bottom: 18px;
|
|
|
|
|
padding: 20px 22px;
|
|
|
|
|
border: 1px solid rgba(69, 202, 249, 0.36);
|
|
|
|
|
border-radius: 18px;
|
|
|
|
|
background: linear-gradient(112deg, rgba(9, 99, 145, 0.28), rgba(2, 22, 38, 0.48));
|
|
|
|
|
|
|
|
|
|
span {
|
|
|
|
|
color: #51d8ff;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
font-weight: 900;
|
|
|
|
|
letter-spacing: 0.08em;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h4 {
|
|
|
|
|
margin: 6px 0 8px;
|
|
|
|
|
color: #ffffff;
|
|
|
|
|
font-size: 22px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
p {
|
|
|
|
|
margin: 0;
|
|
|
|
|
color: #bed8e6;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
line-height: 1.8;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.hidden-file-input {
|
|
|
|
|
display: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.journey-map-upload-panel {
|
|
|
|
|
min-height: 330px;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
padding: 28px;
|
|
|
|
|
border: 1px dashed rgba(78, 210, 255, 0.64);
|
|
|
|
|
border-radius: 20px;
|
|
|
|
|
color: #e9fbff;
|
|
|
|
|
background: radial-gradient(circle at 50% 32%, rgba(13, 119, 171, 0.22), rgba(2, 18, 31, 0.52) 62%);
|
|
|
|
|
|
|
|
|
|
strong {
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
p {
|
|
|
|
|
margin: 0 0 8px;
|
|
|
|
|
color: #9abac9;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.journey-map-upload-icon {
|
|
|
|
|
width: 52px;
|
|
|
|
|
height: 52px;
|
|
|
|
|
display: grid;
|
|
|
|
|
place-items: center;
|
|
|
|
|
border: 1px solid rgba(94, 222, 255, 0.62);
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
color: #71e5ff;
|
|
|
|
|
background: rgba(8, 100, 148, 0.3);
|
|
|
|
|
font-size: 25px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.journey-map-preview {
|
|
|
|
|
width: min(100%, 760px);
|
|
|
|
|
max-height: 360px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
border: 1px solid rgba(90, 214, 255, 0.45);
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
background: #03111c;
|
|
|
|
|
cursor: zoom-in;
|
|
|
|
|
|
|
|
|
|
:deep(img) {
|
|
|
|
|
object-fit: contain;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.journey-map-file-info {
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
color: #cbf5ff;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
|
|
|
|
span {
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.journey-map-actions {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.journey-map-helper {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
gap: 24px;
|
|
|
|
|
margin-top: 20px;
|
|
|
|
|
padding: 18px 20px;
|
|
|
|
|
border: 1px solid rgba(61, 162, 210, 0.28);
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
color: #b9d4df;
|
|
|
|
|
background: rgba(3, 27, 43, 0.46);
|
|
|
|
|
|
|
|
|
|
h5,
|
|
|
|
|
p {
|
|
|
|
|
margin: 0;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
line-height: 1.8;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h5 {
|
|
|
|
|
color: #e5faff;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.journey-map-prompt {
|
|
|
|
|
margin-top: 4px;
|
|
|
|
|
color: #80dff8;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.el-button {
|
|
|
|
|
flex: 0 0 auto;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.journey-flow {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
|
|
|
|
@ -1859,6 +2123,15 @@ function exportOutcome() {
|
|
|
|
|
transform: rotate(90deg);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.journey-map-helper {
|
|
|
|
|
align-items: stretch;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.journey-map-helper .el-button {
|
|
|
|
|
width: 100%;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.nav-action,
|
|
|
|
|
.training-action {
|
|
|
|
|
width: 100%;
|
|
|
|
|
|