feat: add consumer scenario journey map upload

dev-QQq
chenyuan 3 weeks ago
parent 0443512735
commit 7e0a687c5a

@ -480,3 +480,23 @@ export function checkConsumerScenarioStepTwo(payload) {
headers: { repeatSubmit: false },
});
}
export function checkConsumerScenarioStepThree(payload) {
const journeyMapUrl = String(payload?.journeyMapUrl || "").trim();
if (isStudentDemo()) {
const valid = Boolean(journeyMapUrl);
return Promise.resolve({
code: 200,
data: {
valid,
message: valid ? "用户购买旅程图已上传。" : "请上传用户购买旅程图。",
},
});
}
return request({
url: "/api/student-training-answers/consumer-scenario/step-3/validate",
method: "post",
data: { journeyMapUrl },
headers: { repeatSubmit: false },
});
}

@ -140,27 +140,53 @@
</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 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>支持 PNGJPGJPEGWEBP 格式单个文件不超过 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>
@ -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%;

Loading…
Cancel
Save