feat: add consumer scenario journey analysis

fix/non-blocking-route-navigation
chenyuan 2 months ago
parent 731438754c
commit 0443512735

@ -452,3 +452,31 @@ export function checkConsumerScenarioStepOne(payload) {
headers: { repeatSubmit: false },
});
}
export function checkConsumerScenarioStepTwo(payload) {
const rows = Array.isArray(payload?.rows) ? payload.rows : [];
if (isStudentDemo()) {
const requiredStageIds = consumerScenarioJourneyOrder;
const incomplete = rows.find((row) => !String(row?.behaviorDescription || "").trim()
|| !String(row?.keyTouchpoints || "").trim()
|| !["负面", "中性", "正面"].includes(row?.emotionExperience)
|| !String(row?.decisionFactors || "").trim());
const valid = rows.length === requiredStageIds.length
&& rows.every((row) => requiredStageIds.includes(row?.stageId))
&& new Set(rows.map((row) => row.stageId)).size === requiredStageIds.length
&& !incomplete;
return Promise.resolve({
code: 200,
data: {
valid,
message: valid ? "旅程分析填写完整。" : "请完整填写五个阶段的用户行为、关键触点、情感体验和决策因素。",
},
});
}
return request({
url: "/api/student-training-answers/consumer-scenario/step-2/validate",
method: "post",
data: { rows },
headers: { repeatSubmit: false },
});
}

@ -95,18 +95,48 @@
</section>
<section v-show="isSectionVisible(2)" class="scenario-step-section">
<p class="requirement">
需求说明:上述几项内容让学生拖动到相应框中进行排序,完成用户旅程地图填充。
</p>
<div class="scenario-list">
<label v-for="item in scenarioStatements" :key="item.key" class="scenario-row">
<span>{{ item.text }}</span>
<select v-model="form.classifications[item.key]" class="phase-select">
<option v-for="phase in phases" :key="phase.key" :value="phase.key">{{ phase.label }}</option>
</select>
</label>
</div>
<div class="journey-analysis-intro">
<p class="journey-sort-kicker">Journey evidence map</p>
<h4>分析购买旅程中的行为与体验</h4>
<p>请根据访谈记录,分析用户在购买旅程各阶段的行为表现,识别关键触点,并评估用户的情感体验水平。</p>
</div>
<div class="journey-analysis-wrap">
<table class="journey-analysis-table">
<thead>
<tr>
<th>序号</th>
<th>旅程阶段</th>
<th>用户行为描述</th>
<th>关键触点</th>
<th>情感体验</th>
<th>关键决策因素</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in form.journeyAnalysis" :key="row.stageId">
<td class="journey-analysis-index">{{ index + 1 }}</td>
<th class="journey-analysis-stage">{{ stageTitle(row.stageId) }}</th>
<td>
<textarea v-model="row.behaviorDescription" placeholder="描述用户在此阶段的行为表现"></textarea>
</td>
<td>
<textarea v-model="row.keyTouchpoints" placeholder="填写平台、内容、服务或产品触点"></textarea>
</td>
<td>
<select v-model="row.emotionExperience" aria-label="选择情感体验">
<option value="" disabled>请选择</option>
<option v-for="emotion in emotionOptions" :key="emotion" :value="emotion">{{ emotion }}</option>
</select>
</td>
<td>
<textarea v-model="row.decisionFactors" placeholder="填写推动或阻碍决策的关键因素"></textarea>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="journeyAnalysisMessage" class="journey-check-message" role="status">{{ journeyAnalysisMessage }}</p>
</section>
<section v-show="isSectionVisible(3)" class="scenario-step-section">
@ -235,7 +265,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, getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
import { checkConsumerScenarioStepOne, checkConsumerScenarioStepTwo, getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
const TASK_KEY = "consumer-scenario";
const { proxy } = getCurrentInstance();
@ -278,6 +308,8 @@ const journeyStages = [
},
];
const emotionOptions = ["负面", "中性", "正面"];
const phases = [
{ key: "problem", label: "问题感知(需求触发)" },
{ key: "search", label: "信息搜索(产品探索)" },
@ -376,9 +408,11 @@ const emotionPoints = [
const taskConfig = ref(null);
const saving = ref(false);
const checkingJourney = ref(false);
const checkingJourneyAnalysis = ref(false);
const draggedStageId = ref("");
const journeyCheckMessage = ref("");
const journeyCheckedCorrectly = ref(false);
const journeyAnalysisMessage = ref("");
function createMatrix() {
return journeyRows.reduce((matrix, row) => {
@ -397,10 +431,21 @@ function createClassifications() {
}, {});
}
function createJourneyAnalysis() {
return journeyStages.map((stage) => ({
stageId: stage.id,
behaviorDescription: "",
keyTouchpoints: "",
emotionExperience: "",
decisionFactors: "",
}));
}
function createForm() {
return {
activeStep: 1,
journeyOrder: createShuffledJourneyOrder(),
journeyAnalysis: createJourneyAnalysis(),
classifications: createClassifications(),
matrix: createMatrix(),
};
@ -423,7 +468,7 @@ const orderedJourneyStages = computed(() =>
const progressItems = computed(() => [
{ text: journeyCheckedCorrectly.value ? "已完成:购买旅程排序" : "进行中:购买旅程排序", status: journeyCheckedCorrectly.value ? "done" : "doing" },
{ text: hasMatrixContent.value ? "进行中:用户旅程地图填写" : "待完成:用户旅程地图填写", status: hasMatrixContent.value ? "doing" : "" },
{ text: hasJourneyAnalysisContent.value ? "进行中:用户旅程分析" : "待完成:用户旅程分析", status: hasJourneyAnalysisContent.value ? "doing" : "" },
{ text: "待完成:提交实训成果", status: "" },
]);
@ -449,6 +494,13 @@ const hasMatrixContent = computed(() =>
)
);
const hasJourneyAnalysisContent = computed(() =>
form.value.journeyAnalysis.some((row) =>
[row.behaviorDescription, row.keyTouchpoints, row.emotionExperience, row.decisionFactors]
.some((value) => String(value || "").trim())
)
);
onMounted(async () => {
await loadTaskConfig();
await loadSavedAnswer();
@ -497,6 +549,7 @@ function applySavedAnswer(value) {
form.value = {
activeStep: normalizeActiveStep(parsed.currentStep),
journeyOrder: normalizeJourneyOrder(parsed.journeyOrder),
journeyAnalysis: normalizeJourneyAnalysis(parsed.journeyAnalysis),
classifications: {
...createClassifications(),
...(parsed.classifications || {}),
@ -510,6 +563,20 @@ function applySavedAnswer(value) {
}
}
function normalizeJourneyAnalysis(value) {
const savedRows = Array.isArray(value) ? value : [];
return createJourneyAnalysis().map((row) => {
const savedRow = savedRows.find((item) => item?.stageId === row.stageId) || {};
return {
...row,
behaviorDescription: savedRow.behaviorDescription || "",
keyTouchpoints: savedRow.keyTouchpoints || "",
emotionExperience: emotionOptions.includes(savedRow.emotionExperience) ? savedRow.emotionExperience : "",
decisionFactors: savedRow.decisionFactors || "",
};
});
}
function normalizeJourneyOrder(value) {
if (!Array.isArray(value) || value.length !== journeyStages.length) return createShuffledJourneyOrder();
const uniqueOrder = [...new Set(value)];
@ -540,7 +607,7 @@ function normalizeActiveStep(value) {
async function setCurrentStep(step) {
if (!hasConfiguredSteps.value) return;
const targetStep = normalizeActiveStep(step);
if (targetStep > form.value.activeStep && form.value.activeStep === 1 && !(await persist("IN_PROGRESS"))) return;
if (targetStep > form.value.activeStep && [1, 2].includes(form.value.activeStep) && !(await persist("IN_PROGRESS"))) return;
form.value.activeStep = targetStep;
}
@ -615,6 +682,26 @@ async function ensureJourneyOrderValid(showSuccess = false) {
}
}
function stageTitle(stageId) {
return journeyStages.find((stage) => stage.id === stageId)?.title || "未知阶段";
}
async function ensureJourneyAnalysisValid() {
if (checkingJourneyAnalysis.value) return false;
checkingJourneyAnalysis.value = true;
try {
const response = await checkConsumerScenarioStepTwo({ rows: form.value.journeyAnalysis });
const result = response?.data || {};
journeyAnalysisMessage.value = result.message || (result.valid ? "旅程分析填写完整。" : "请完整填写五个阶段的旅程分析。");
return result.valid === true;
} catch (error) {
journeyAnalysisMessage.value = error?.message || "旅程分析校验失败,请稍后重试。";
return false;
} finally {
checkingJourneyAnalysis.value = false;
}
}
function buildOutcome() {
return {
taskKey: TASK_KEY,
@ -623,6 +710,7 @@ function buildOutcome() {
currentStep: form.value.activeStep,
journeyOrder: form.value.journeyOrder,
journeyCheckedCorrectly: journeyCheckedCorrectly.value,
journeyAnalysis: form.value.journeyAnalysis,
phases,
classifications: form.value.classifications,
matrix: form.value.matrix,
@ -642,6 +730,7 @@ function buildAnswerPayload(status) {
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;
saving.value = true;
try {
await saveStudentTrainingAnswer(TASK_KEY, buildAnswerPayload(status));
@ -1208,6 +1297,125 @@ function exportOutcome() {
}
}
.journey-analysis-intro {
margin-bottom: 18px;
padding: 20px;
border-left: 3px solid #3bd8ff;
border-radius: 0 16px 16px 0;
background: linear-gradient(90deg, rgba(7, 95, 137, 0.28), rgba(4, 31, 49, 0.18));
h4 {
margin: 4px 0 8px;
color: #ffffff;
font-size: 21px;
}
p {
margin: 0;
color: #c3dfec;
font-size: 14px;
line-height: 1.8;
}
}
.journey-analysis-wrap {
overflow-x: auto;
border: 1px solid rgba(72, 182, 228, 0.46);
border-radius: 16px;
background: rgba(2, 20, 33, 0.72);
}
.journey-analysis-table {
width: 100%;
min-width: 1120px;
border-collapse: collapse;
table-layout: fixed;
th,
td {
border: 1px solid rgba(77, 167, 220, 0.28);
}
thead th {
padding: 13px 10px;
color: #effcff;
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;
}
tbody tr {
background: rgba(2, 17, 29, 0.48);
&:nth-child(even) {
background: rgba(9, 47, 67, 0.34);
}
}
td,
tbody th {
padding: 10px;
vertical-align: top;
}
th:nth-child(1) { width: 54px; }
th:nth-child(2) { width: 132px; }
th:nth-child(3), th:nth-child(4), th:nth-child(6) { width: 27%; }
th:nth-child(5) { width: 118px; }
textarea,
select {
width: 100%;
border: 1px solid transparent;
border-radius: 8px;
color: #eaf9ff;
background: transparent;
font: inherit;
font-size: 13px;
line-height: 1.65;
outline: none;
box-sizing: border-box;
&:focus {
border-color: rgba(92, 224, 255, 0.76);
background: rgba(8, 64, 92, 0.42);
box-shadow: 0 0 0 3px rgba(34, 207, 255, 0.1);
}
}
textarea {
min-height: 112px;
padding: 8px;
resize: vertical;
}
textarea::placeholder {
color: #7897a8;
}
select {
min-height: 38px;
margin-top: 30px;
padding: 0 9px;
border-color: rgba(99, 177, 211, 0.6);
background: rgba(6, 38, 58, 0.78);
}
}
.journey-analysis-index,
.journey-analysis-stage {
color: #dff7ff;
font-weight: 800;
text-align: center;
vertical-align: middle !important;
}
.journey-analysis-stage {
color: #9defff;
line-height: 1.65;
}
.journey-flow {
display: grid;
grid-template-columns: repeat(5, minmax(120px, 1fr));

Loading…
Cancel
Save