|
|
|
|
@ -191,60 +191,30 @@
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section v-show="isSectionVisible(4)" class="scenario-step-section">
|
|
|
|
|
<section class="reference-map">
|
|
|
|
|
<div class="reference-title">示例:APP 用户体验地图</div>
|
|
|
|
|
<div class="reference-grid reference-grid--stage">
|
|
|
|
|
<div class="reference-side">阶段</div>
|
|
|
|
|
<div v-for="phase in referencePhases" :key="phase.name" class="stage-chip">{{ phase.name }}</div>
|
|
|
|
|
<div class="journey-optimization-intro">
|
|
|
|
|
<span>Step 04</span>
|
|
|
|
|
<h4>识别旅程痛点,形成优化建议</h4>
|
|
|
|
|
<p>结合用户购买旅程图,分析各阶段存在的痛点及未满足的需求,提出可落地的产品优化建议。</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="reference-grid">
|
|
|
|
|
<div class="reference-side">用户目标</div>
|
|
|
|
|
<div v-for="phase in referencePhases" :key="phase.name" class="reference-cell">
|
|
|
|
|
<p v-for="goal in phase.goals" :key="goal">{{ goal }}</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="reference-grid reference-grid--behavior">
|
|
|
|
|
<div class="reference-side">用户行为</div>
|
|
|
|
|
<div class="behavior-lane">
|
|
|
|
|
<div v-for="item in behaviorNodes" :key="item" class="behavior-node">
|
|
|
|
|
<span>{{ item }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="reference-grid">
|
|
|
|
|
<div class="reference-side">接触点</div>
|
|
|
|
|
<div v-for="phase in referencePhases" :key="phase.name" class="reference-cell">
|
|
|
|
|
<p>{{ phase.touchpoint }}</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="reference-grid reference-grid--emotion">
|
|
|
|
|
<div class="reference-side">情绪曲线</div>
|
|
|
|
|
<div class="emotion-chart">
|
|
|
|
|
<svg viewBox="0 0 1000 210" role="img" aria-label="用户情绪曲线示例">
|
|
|
|
|
<defs>
|
|
|
|
|
<linearGradient id="emotionFill" x1="0" x2="0" y1="0" y2="1">
|
|
|
|
|
<stop offset="0%" stop-color="#28c7ef" stop-opacity="0.28" />
|
|
|
|
|
<stop offset="100%" stop-color="#28c7ef" stop-opacity="0.04" />
|
|
|
|
|
</linearGradient>
|
|
|
|
|
</defs>
|
|
|
|
|
<path d="M20 160 C80 140 100 74 160 92 S250 170 300 126 S390 60 440 126 S520 174 570 118 S650 64 710 110 S790 174 850 140 S930 120 980 96 L980 200 L20 200 Z" fill="url(#emotionFill)" />
|
|
|
|
|
<path d="M20 160 C80 140 100 74 160 92 S250 170 300 126 S390 60 440 126 S520 174 570 118 S650 64 710 110 S790 174 850 140 S930 120 980 96" fill="none" stroke="#28c7ef" stroke-width="5" />
|
|
|
|
|
</svg>
|
|
|
|
|
<div class="emotion-points">
|
|
|
|
|
<span v-for="point in emotionPoints" :key="point.label" :style="{ left: point.left, top: point.top }">
|
|
|
|
|
{{ point.icon }}
|
|
|
|
|
<small>{{ point.label }}</small>
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="reference-grid">
|
|
|
|
|
<div class="reference-side">痛点/机会点</div>
|
|
|
|
|
<div v-for="phase in referencePhases" :key="phase.name" class="reference-cell">
|
|
|
|
|
<p>{{ phase.pain }}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="journey-optimization-wrap">
|
|
|
|
|
<table class="journey-optimization-table">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>旅程阶段</th>
|
|
|
|
|
<th>识别的痛点</th>
|
|
|
|
|
<th>优化建议</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
<tr v-for="row in form.journeyOptimizations" :key="row.stageId">
|
|
|
|
|
<th>{{ stageShortTitle(row.stageId) }}</th>
|
|
|
|
|
<td><textarea v-model="row.painPoint" placeholder="填写该阶段用户遇到的痛点或未满足需求"></textarea></td>
|
|
|
|
|
<td><textarea v-model="row.optimizationSuggestion" placeholder="填写针对痛点的产品、服务或体验优化建议"></textarea></td>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
<p v-if="journeyOptimizationMessage" class="journey-check-message" role="status">{{ journeyOptimizationMessage }}</p>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<div v-if="hasConfiguredSteps" class="step-actions">
|
|
|
|
|
@ -291,7 +261,7 @@ 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, checkConsumerScenarioStepThree, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
|
|
|
|
|
import { checkConsumerScenarioStepOne, checkConsumerScenarioStepTwo, checkConsumerScenarioStepThree, checkConsumerScenarioStepFour, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
|
|
|
|
|
import { isStudentDemo } from "@/utils/studentDemo";
|
|
|
|
|
|
|
|
|
|
const TASK_KEY = "consumer-scenario";
|
|
|
|
|
@ -436,12 +406,14 @@ const taskConfig = ref(null);
|
|
|
|
|
const saving = ref(false);
|
|
|
|
|
const checkingJourney = ref(false);
|
|
|
|
|
const checkingJourneyAnalysis = ref(false);
|
|
|
|
|
const checkingJourneyOptimization = ref(false);
|
|
|
|
|
const journeyMapInput = ref(null);
|
|
|
|
|
const uploadingJourneyMap = ref(false);
|
|
|
|
|
const draggedStageId = ref("");
|
|
|
|
|
const journeyCheckMessage = ref("");
|
|
|
|
|
const journeyCheckedCorrectly = ref(false);
|
|
|
|
|
const journeyAnalysisMessage = ref("");
|
|
|
|
|
const journeyOptimizationMessage = ref("");
|
|
|
|
|
|
|
|
|
|
function createMatrix() {
|
|
|
|
|
return journeyRows.reduce((matrix, row) => {
|
|
|
|
|
@ -470,12 +442,21 @@ function createJourneyAnalysis() {
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createJourneyOptimizations() {
|
|
|
|
|
return journeyStages.map((stage) => ({
|
|
|
|
|
stageId: stage.id,
|
|
|
|
|
painPoint: "",
|
|
|
|
|
optimizationSuggestion: "",
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createForm() {
|
|
|
|
|
return {
|
|
|
|
|
activeStep: 1,
|
|
|
|
|
journeyOrder: createShuffledJourneyOrder(),
|
|
|
|
|
journeyAnalysis: createJourneyAnalysis(),
|
|
|
|
|
journeyMap: { url: "", name: "" },
|
|
|
|
|
journeyOptimizations: createJourneyOptimizations(),
|
|
|
|
|
classifications: createClassifications(),
|
|
|
|
|
matrix: createMatrix(),
|
|
|
|
|
};
|
|
|
|
|
@ -581,6 +562,7 @@ function applySavedAnswer(value) {
|
|
|
|
|
journeyOrder: normalizeJourneyOrder(parsed.journeyOrder),
|
|
|
|
|
journeyAnalysis: normalizeJourneyAnalysis(parsed.journeyAnalysis),
|
|
|
|
|
journeyMap: normalizeJourneyMap(parsed.journeyMap),
|
|
|
|
|
journeyOptimizations: normalizeJourneyOptimizations(parsed.journeyOptimizations),
|
|
|
|
|
classifications: {
|
|
|
|
|
...createClassifications(),
|
|
|
|
|
...(parsed.classifications || {}),
|
|
|
|
|
@ -615,6 +597,18 @@ function normalizeJourneyAnalysis(value) {
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeJourneyOptimizations(value) {
|
|
|
|
|
const savedRows = Array.isArray(value) ? value : [];
|
|
|
|
|
return createJourneyOptimizations().map((row) => {
|
|
|
|
|
const savedRow = savedRows.find((item) => item?.stageId === row.stageId) || {};
|
|
|
|
|
return {
|
|
|
|
|
...row,
|
|
|
|
|
painPoint: savedRow.painPoint || "",
|
|
|
|
|
optimizationSuggestion: savedRow.optimizationSuggestion || "",
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeJourneyOrder(value) {
|
|
|
|
|
if (!Array.isArray(value) || value.length !== journeyStages.length) return createShuffledJourneyOrder();
|
|
|
|
|
const uniqueOrder = [...new Set(value)];
|
|
|
|
|
@ -724,6 +718,10 @@ function stageTitle(stageId) {
|
|
|
|
|
return journeyStages.find((stage) => stage.id === stageId)?.title || "未知阶段";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stageShortTitle(stageId) {
|
|
|
|
|
return stageTitle(stageId).split("(")[0];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function ensureJourneyAnalysisValid() {
|
|
|
|
|
if (checkingJourneyAnalysis.value) return false;
|
|
|
|
|
checkingJourneyAnalysis.value = true;
|
|
|
|
|
@ -802,6 +800,22 @@ async function ensureJourneyMapValid() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function ensureJourneyOptimizationValid() {
|
|
|
|
|
if (checkingJourneyOptimization.value) return false;
|
|
|
|
|
checkingJourneyOptimization.value = true;
|
|
|
|
|
try {
|
|
|
|
|
const response = await checkConsumerScenarioStepFour({ rows: form.value.journeyOptimizations });
|
|
|
|
|
const result = response?.data || {};
|
|
|
|
|
journeyOptimizationMessage.value = result.message || (result.valid ? "五个阶段的优化分析填写完整。" : "请完整填写五个阶段的痛点和优化建议。");
|
|
|
|
|
return result.valid === true;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
journeyOptimizationMessage.value = error?.message || "优化建议校验失败,请稍后重试。";
|
|
|
|
|
return false;
|
|
|
|
|
} finally {
|
|
|
|
|
checkingJourneyOptimization.value = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function downloadJourneyMapTemplate() {
|
|
|
|
|
const escapeCsvValue = (value) => `"${String(value || "").replaceAll('"', '""')}"`;
|
|
|
|
|
const header = ["序号", "旅程阶段", "用户行为描述", "关键触点", "情感体验", "关键决策因素", "痛点标记", "机会点标记"];
|
|
|
|
|
@ -834,6 +848,7 @@ function buildOutcome() {
|
|
|
|
|
journeyCheckedCorrectly: journeyCheckedCorrectly.value,
|
|
|
|
|
journeyAnalysis: form.value.journeyAnalysis,
|
|
|
|
|
journeyMap: form.value.journeyMap,
|
|
|
|
|
journeyOptimizations: form.value.journeyOptimizations,
|
|
|
|
|
phases,
|
|
|
|
|
classifications: form.value.classifications,
|
|
|
|
|
matrix: form.value.matrix,
|
|
|
|
|
@ -855,6 +870,7 @@ async function persist(status = "IN_PROGRESS") {
|
|
|
|
|
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;
|
|
|
|
|
if (status !== "RESET" && form.value.activeStep === 4 && !(await ensureJourneyOptimizationValid())) return false;
|
|
|
|
|
saving.value = true;
|
|
|
|
|
try {
|
|
|
|
|
await saveStudentTrainingAnswer(TASK_KEY, buildAnswerPayload(status));
|
|
|
|
|
@ -1680,6 +1696,106 @@ function exportOutcome() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.journey-optimization-intro {
|
|
|
|
|
margin-bottom: 18px;
|
|
|
|
|
padding: 20px 22px;
|
|
|
|
|
border-left: 3px solid #41d9ff;
|
|
|
|
|
border-radius: 0 16px 16px 0;
|
|
|
|
|
background: linear-gradient(90deg, rgba(7, 98, 143, 0.3), rgba(3, 27, 42, 0.12));
|
|
|
|
|
|
|
|
|
|
span {
|
|
|
|
|
color: #51d8ff;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
font-weight: 900;
|
|
|
|
|
letter-spacing: 0.08em;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h4 {
|
|
|
|
|
margin: 6px 0 8px;
|
|
|
|
|
color: #ffffff;
|
|
|
|
|
font-size: 21px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
p {
|
|
|
|
|
margin: 0;
|
|
|
|
|
color: #c4dfe9;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
line-height: 1.8;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.journey-optimization-wrap {
|
|
|
|
|
overflow-x: auto;
|
|
|
|
|
border: 1px solid rgba(72, 182, 228, 0.46);
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
background: rgba(2, 20, 33, 0.72);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.journey-optimization-table {
|
|
|
|
|
width: 100%;
|
|
|
|
|
min-width: 900px;
|
|
|
|
|
border-collapse: collapse;
|
|
|
|
|
table-layout: fixed;
|
|
|
|
|
|
|
|
|
|
th,
|
|
|
|
|
td {
|
|
|
|
|
border: 1px solid rgba(77, 167, 220, 0.28);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
thead th {
|
|
|
|
|
padding: 14px 12px;
|
|
|
|
|
color: #f0fcff;
|
|
|
|
|
background: linear-gradient(90deg, rgba(8, 128, 178, 0.94), rgba(5, 94, 143, 0.9));
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
font-weight: 900;
|
|
|
|
|
text-align: center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
thead th:first-child {
|
|
|
|
|
width: 170px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tbody th {
|
|
|
|
|
padding: 18px 14px;
|
|
|
|
|
color: #a6ecff;
|
|
|
|
|
background: rgba(3, 36, 55, 0.58);
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
text-align: center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
td {
|
|
|
|
|
padding: 10px;
|
|
|
|
|
background: rgba(1, 17, 29, 0.45);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
textarea {
|
|
|
|
|
width: 100%;
|
|
|
|
|
min-height: 92px;
|
|
|
|
|
padding: 10px 12px;
|
|
|
|
|
border: 1px solid transparent;
|
|
|
|
|
border-radius: 9px;
|
|
|
|
|
outline: none;
|
|
|
|
|
resize: vertical;
|
|
|
|
|
color: #ebfaff;
|
|
|
|
|
background: transparent;
|
|
|
|
|
font: inherit;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
line-height: 1.65;
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
|
|
|
|
|
&:focus {
|
|
|
|
|
border-color: rgba(92, 224, 255, 0.72);
|
|
|
|
|
background: rgba(8, 64, 92, 0.42);
|
|
|
|
|
box-shadow: 0 0 0 3px rgba(34, 207, 255, 0.1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
textarea::placeholder {
|
|
|
|
|
color: #7696a8;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.journey-flow {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
|
|
|
|
|