feat: add consumer scenario optimization analysis

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

@ -500,3 +500,27 @@ export function checkConsumerScenarioStepThree(payload) {
headers: { repeatSubmit: false }, headers: { repeatSubmit: false },
}); });
} }
export function checkConsumerScenarioStepFour(payload) {
const rows = Array.isArray(payload?.rows) ? payload.rows : [];
if (isStudentDemo()) {
const valid = rows.length === consumerScenarioJourneyOrder.length
&& rows.every((row) => consumerScenarioJourneyOrder.includes(row?.stageId)
&& String(row?.painPoint || "").trim()
&& String(row?.optimizationSuggestion || "").trim())
&& new Set(rows.map((row) => row.stageId)).size === consumerScenarioJourneyOrder.length;
return Promise.resolve({
code: 200,
data: {
valid,
message: valid ? "五个阶段的优化分析填写完整。" : "请完整填写五个阶段的痛点和优化建议。",
},
});
}
return request({
url: "/api/student-training-answers/consumer-scenario/step-4/validate",
method: "post",
data: { rows },
headers: { repeatSubmit: false },
});
}

@ -191,60 +191,30 @@
</section> </section>
<section v-show="isSectionVisible(4)" class="scenario-step-section"> <section v-show="isSectionVisible(4)" class="scenario-step-section">
<section class="reference-map"> <div class="journey-optimization-intro">
<div class="reference-title">示例APP 用户体验地图</div> <span>Step 04</span>
<div class="reference-grid reference-grid--stage"> <h4>识别旅程痛点形成优化建议</h4>
<div class="reference-side">阶段</div> <p>结合用户购买旅程图分析各阶段存在的痛点及未满足的需求提出可落地的产品优化建议</p>
<div v-for="phase in referencePhases" :key="phase.name" class="stage-chip">{{ phase.name }}</div>
</div> </div>
<div class="reference-grid"> <div class="journey-optimization-wrap">
<div class="reference-side">用户目标</div> <table class="journey-optimization-table">
<div v-for="phase in referencePhases" :key="phase.name" class="reference-cell"> <thead>
<p v-for="goal in phase.goals" :key="goal">{{ goal }}</p> <tr>
</div> <th>旅程阶段</th>
</div> <th>识别的痛点</th>
<div class="reference-grid reference-grid--behavior"> <th>优化建议</th>
<div class="reference-side">用户行为</div> </tr>
<div class="behavior-lane"> </thead>
<div v-for="item in behaviorNodes" :key="item" class="behavior-node"> <tbody>
<span>{{ item }}</span> <tr v-for="row in form.journeyOptimizations" :key="row.stageId">
</div> <th>{{ stageShortTitle(row.stageId) }}</th>
</div> <td><textarea v-model="row.painPoint" placeholder="填写该阶段用户遇到的痛点或未满足需求"></textarea></td>
</div> <td><textarea v-model="row.optimizationSuggestion" placeholder="填写针对痛点的产品、服务或体验优化建议"></textarea></td>
<div class="reference-grid"> </tr>
<div class="reference-side">接触点</div> </tbody>
<div v-for="phase in referencePhases" :key="phase.name" class="reference-cell"> </table>
<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> </div>
</section> <p v-if="journeyOptimizationMessage" class="journey-check-message" role="status">{{ journeyOptimizationMessage }}</p>
</section> </section>
<div v-if="hasConfiguredSteps" class="step-actions"> <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 TrainingMaterialButton from "@/views/components/TrainingMaterialButton.vue";
import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue"; import TrainingTaskBrief from "@/views/components/TrainingTaskBrief.vue";
import { getTrainingTaskByKey } from "@/api/trainingTask"; 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"; import { isStudentDemo } from "@/utils/studentDemo";
const TASK_KEY = "consumer-scenario"; const TASK_KEY = "consumer-scenario";
@ -436,12 +406,14 @@ const taskConfig = ref(null);
const saving = ref(false); const saving = ref(false);
const checkingJourney = ref(false); const checkingJourney = ref(false);
const checkingJourneyAnalysis = ref(false); const checkingJourneyAnalysis = ref(false);
const checkingJourneyOptimization = ref(false);
const journeyMapInput = ref(null); const journeyMapInput = ref(null);
const uploadingJourneyMap = ref(false); const uploadingJourneyMap = ref(false);
const draggedStageId = ref(""); const draggedStageId = ref("");
const journeyCheckMessage = ref(""); const journeyCheckMessage = ref("");
const journeyCheckedCorrectly = ref(false); const journeyCheckedCorrectly = ref(false);
const journeyAnalysisMessage = ref(""); const journeyAnalysisMessage = ref("");
const journeyOptimizationMessage = ref("");
function createMatrix() { function createMatrix() {
return journeyRows.reduce((matrix, row) => { return journeyRows.reduce((matrix, row) => {
@ -470,12 +442,21 @@ function createJourneyAnalysis() {
})); }));
} }
function createJourneyOptimizations() {
return journeyStages.map((stage) => ({
stageId: stage.id,
painPoint: "",
optimizationSuggestion: "",
}));
}
function createForm() { function createForm() {
return { return {
activeStep: 1, activeStep: 1,
journeyOrder: createShuffledJourneyOrder(), journeyOrder: createShuffledJourneyOrder(),
journeyAnalysis: createJourneyAnalysis(), journeyAnalysis: createJourneyAnalysis(),
journeyMap: { url: "", name: "" }, journeyMap: { url: "", name: "" },
journeyOptimizations: createJourneyOptimizations(),
classifications: createClassifications(), classifications: createClassifications(),
matrix: createMatrix(), matrix: createMatrix(),
}; };
@ -581,6 +562,7 @@ function applySavedAnswer(value) {
journeyOrder: normalizeJourneyOrder(parsed.journeyOrder), journeyOrder: normalizeJourneyOrder(parsed.journeyOrder),
journeyAnalysis: normalizeJourneyAnalysis(parsed.journeyAnalysis), journeyAnalysis: normalizeJourneyAnalysis(parsed.journeyAnalysis),
journeyMap: normalizeJourneyMap(parsed.journeyMap), journeyMap: normalizeJourneyMap(parsed.journeyMap),
journeyOptimizations: normalizeJourneyOptimizations(parsed.journeyOptimizations),
classifications: { classifications: {
...createClassifications(), ...createClassifications(),
...(parsed.classifications || {}), ...(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) { function normalizeJourneyOrder(value) {
if (!Array.isArray(value) || value.length !== journeyStages.length) return createShuffledJourneyOrder(); if (!Array.isArray(value) || value.length !== journeyStages.length) return createShuffledJourneyOrder();
const uniqueOrder = [...new Set(value)]; const uniqueOrder = [...new Set(value)];
@ -724,6 +718,10 @@ function stageTitle(stageId) {
return journeyStages.find((stage) => stage.id === stageId)?.title || "未知阶段"; return journeyStages.find((stage) => stage.id === stageId)?.title || "未知阶段";
} }
function stageShortTitle(stageId) {
return stageTitle(stageId).split("")[0];
}
async function ensureJourneyAnalysisValid() { async function ensureJourneyAnalysisValid() {
if (checkingJourneyAnalysis.value) return false; if (checkingJourneyAnalysis.value) return false;
checkingJourneyAnalysis.value = true; 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() { function downloadJourneyMapTemplate() {
const escapeCsvValue = (value) => `"${String(value || "").replaceAll('"', '""')}"`; const escapeCsvValue = (value) => `"${String(value || "").replaceAll('"', '""')}"`;
const header = ["序号", "旅程阶段", "用户行为描述", "关键触点", "情感体验", "关键决策因素", "痛点标记", "机会点标记"]; const header = ["序号", "旅程阶段", "用户行为描述", "关键触点", "情感体验", "关键决策因素", "痛点标记", "机会点标记"];
@ -834,6 +848,7 @@ function buildOutcome() {
journeyCheckedCorrectly: journeyCheckedCorrectly.value, journeyCheckedCorrectly: journeyCheckedCorrectly.value,
journeyAnalysis: form.value.journeyAnalysis, journeyAnalysis: form.value.journeyAnalysis,
journeyMap: form.value.journeyMap, journeyMap: form.value.journeyMap,
journeyOptimizations: form.value.journeyOptimizations,
phases, phases,
classifications: form.value.classifications, classifications: form.value.classifications,
matrix: form.value.matrix, 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 === 1 && !(await ensureJourneyOrderValid())) return false;
if (status !== "RESET" && form.value.activeStep === 2 && !(await ensureJourneyAnalysisValid())) 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 === 3 && !(await ensureJourneyMapValid())) return false;
if (status !== "RESET" && form.value.activeStep === 4 && !(await ensureJourneyOptimizationValid())) return false;
saving.value = true; saving.value = true;
try { try {
await saveStudentTrainingAnswer(TASK_KEY, buildAnswerPayload(status)); 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 { .journey-flow {
display: grid; display: grid;
grid-template-columns: repeat(5, minmax(120px, 1fr)); grid-template-columns: repeat(5, minmax(120px, 1fr));

Loading…
Cancel
Save