feat: add consumer scenario journey ordering

dev-QQq
chenyuan 3 weeks ago
parent ec33c97b93
commit 731438754c

@ -423,3 +423,32 @@ export function checkConsumerBehaviorStepFour(payload) {
headers: { repeatSubmit: false },
});
}
const consumerScenarioJourneyOrder = [
"problem-awareness",
"information-search",
"evaluation-decision",
"purchase-conversion",
"post-purchase",
];
export function checkConsumerScenarioStepOne(payload) {
const stageOrder = Array.isArray(payload?.stageOrder) ? payload.stageOrder : [];
if (isStudentDemo()) {
const valid = stageOrder.length === consumerScenarioJourneyOrder.length
&& stageOrder.every((stageId, index) => stageId === consumerScenarioJourneyOrder[index]);
return Promise.resolve({
code: 200,
data: {
valid,
message: valid ? "排序正确,可以进入下一步。" : "排序还不正确,请根据消费者购买旅程再次调整。",
},
});
}
return request({
url: "/api/student-training-answers/consumer-scenario/step-1/validate",
method: "post",
data: { stageOrder },
headers: { repeatSubmit: false },
});
}

@ -52,15 +52,46 @@
</div>
<section v-show="isSectionVisible(1)" class="scenario-step-section">
<div class="journey-head">
<h4>用户购买旅程</h4>
<div class="journey-flow" aria-label="">
<template v-for="(phase, index) in phases" :key="phase.key">
<div class="phase-node">{{ phase.label }}</div>
<div v-if="index < phases.length - 1" class="phase-arrow" aria-hidden="true"></div>
<div class="journey-sort-head">
<div>
<p class="journey-sort-kicker">Customer Journey</p>
<h4>将购买旅程卡片按正确顺序排列</h4>
<p>从需求触发到购买后的使用反馈共五个阶段拖动卡片或使用卡片右侧的上下移动按钮调整顺序</p>
</div>
<button type="button" class="journey-check-action" :disabled="checkingJourney" @click="checkJourneyOrder">
{{ checkingJourney ? "正在校验…" : "检查排序" }}
</button>
</div>
<div class="journey-sort-list" role="list" aria-label="">
<template v-for="(stage, index) in orderedJourneyStages" :key="stage.id">
<article
class="journey-sort-card"
:class="{ dragging: draggedStageId === stage.id }"
draggable="true"
role="listitem"
@dragstart="startJourneyDrag(stage.id)"
@dragover.prevent
@drop="dropJourneyStage(stage.id)"
@dragend="endJourneyDrag"
>
<span class="journey-card-order">{{ String(index + 1).padStart(2, "0") }}</span>
<div class="journey-card-copy">
<strong>{{ stage.title }}</strong>
<span>{{ stage.description }}</span>
</div>
<div class="journey-card-actions" aria-label="">
<button type="button" :disabled="index === 0" :aria-label="`将${stage.title}上移`" @click="moveJourneyStage(index, -1)"></button>
<button type="button" :disabled="index === orderedJourneyStages.length - 1" :aria-label="`将${stage.title}下移`" @click="moveJourneyStage(index, 1)"></button>
</div>
</article>
<span v-if="index < orderedJourneyStages.length - 1" class="journey-sort-arrow" aria-hidden="true"></span>
</template>
</div>
</div>
<p v-if="journeyCheckMessage" :class="['journey-check-message', { success: journeyCheckedCorrectly }]" role="status">
{{ journeyCheckMessage }}
</p>
</section>
<section v-show="isSectionVisible(2)" class="scenario-step-section">
@ -204,7 +235,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 { getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
import { checkConsumerScenarioStepOne, getStudentTrainingAnswer, saveStudentTrainingAnswer } from "@/api/studentTrainingAnswer";
const TASK_KEY = "consumer-scenario";
const { proxy } = getCurrentInstance();
@ -219,6 +250,34 @@ const defaultGoals = [
"将场景洞察转化为产品机会。",
];
const journeyStages = [
{
id: "problem-awareness",
title: "问题感知(需求触发)",
description: "用户意识到自身存在某个问题或需求",
},
{
id: "information-search",
title: "信息搜索(产品探索)",
description: "用户主动搜索相关信息,了解可选方案",
},
{
id: "evaluation-decision",
title: "评估决策(对比选择)",
description: "用户对比不同产品的功能、价格、口碑",
},
{
id: "purchase-conversion",
title: "购买转化(下单支付)",
description: "用户完成购买决策并下单",
},
{
id: "post-purchase",
title: "购后行为(使用反馈)",
description: "用户使用产品后形成体验评价,决定是否推荐或复购",
},
];
const phases = [
{ key: "problem", label: "问题感知(需求触发)" },
{ key: "search", label: "信息搜索(产品探索)" },
@ -316,6 +375,10 @@ const emotionPoints = [
const taskConfig = ref(null);
const saving = ref(false);
const checkingJourney = ref(false);
const draggedStageId = ref("");
const journeyCheckMessage = ref("");
const journeyCheckedCorrectly = ref(false);
function createMatrix() {
return journeyRows.reduce((matrix, row) => {
@ -337,6 +400,7 @@ function createClassifications() {
function createForm() {
return {
activeStep: 1,
journeyOrder: createShuffledJourneyOrder(),
classifications: createClassifications(),
matrix: createMatrix(),
};
@ -351,13 +415,30 @@ const taskRequirement = computed(() => taskConfig.value?.requirements || default
const trainingSteps = computed(() => parseArray(taskConfig.value?.steps, []));
const hasConfiguredSteps = computed(() => trainingSteps.value.length > 0);
const activeStepName = computed(() => trainingSteps.value[form.value.activeStep - 1] || "");
const orderedJourneyStages = computed(() =>
form.value.journeyOrder
.map((stageId) => journeyStages.find((stage) => stage.id === stageId))
.filter(Boolean)
);
const progressItems = computed(() => [
{ text: isClassifiedCorrectly.value ? "已完成:旅程阶段判断" : "进行中:旅程阶段判断", status: isClassifiedCorrectly.value ? "done" : "doing" },
{ text: journeyCheckedCorrectly.value ? "已完成:购买旅程排序" : "进行中:购买旅程排序", status: journeyCheckedCorrectly.value ? "done" : "doing" },
{ text: hasMatrixContent.value ? "进行中:用户旅程地图填写" : "待完成:用户旅程地图填写", status: hasMatrixContent.value ? "doing" : "" },
{ text: "待完成:提交实训成果", status: "" },
]);
function createShuffledJourneyOrder() {
const order = journeyStages.map((stage) => stage.id);
for (let index = order.length - 1; index > 0; index -= 1) {
const randomIndex = Math.floor(Math.random() * (index + 1));
[order[index], order[randomIndex]] = [order[randomIndex], order[index]];
}
if (order.every((stageId, index) => stageId === journeyStages[index].id)) {
[order[0], order[1]] = [order[1], order[0]];
}
return order;
}
const isClassifiedCorrectly = computed(() =>
scenarioStatements.every((item) => form.value.classifications[item.key] === item.defaultPhase)
);
@ -415,17 +496,30 @@ function applySavedAnswer(value) {
if (!parsed || typeof parsed !== "object") return;
form.value = {
activeStep: normalizeActiveStep(parsed.currentStep),
journeyOrder: normalizeJourneyOrder(parsed.journeyOrder),
classifications: {
...createClassifications(),
...(parsed.classifications || {}),
},
matrix: normalizeMatrix(parsed.matrix),
};
journeyCheckedCorrectly.value = parsed.journeyCheckedCorrectly === true;
journeyCheckMessage.value = journeyCheckedCorrectly.value ? "排序正确,已完成本步骤。" : "";
} catch (error) {
// Ignore malformed historical answers.
}
}
function normalizeJourneyOrder(value) {
if (!Array.isArray(value) || value.length !== journeyStages.length) return createShuffledJourneyOrder();
const uniqueOrder = [...new Set(value)];
const validIds = journeyStages.map((stage) => stage.id);
if (uniqueOrder.length !== validIds.length || uniqueOrder.some((stageId) => !validIds.includes(stageId))) {
return createShuffledJourneyOrder();
}
return uniqueOrder;
}
function normalizeMatrix(value = {}) {
const matrix = createMatrix();
journeyRows.forEach((row) => {
@ -443,9 +537,11 @@ function normalizeActiveStep(value) {
return total ? Math.min(step, total) : 1;
}
function setCurrentStep(step) {
async function setCurrentStep(step) {
if (!hasConfiguredSteps.value) return;
form.value.activeStep = normalizeActiveStep(step);
const targetStep = normalizeActiveStep(step);
if (targetStep > form.value.activeStep && form.value.activeStep === 1 && !(await persist("IN_PROGRESS"))) return;
form.value.activeStep = targetStep;
}
function isSectionVisible(section) {
@ -457,8 +553,66 @@ function goPreviousStep() {
setCurrentStep(form.value.activeStep - 1);
}
function goNextStep() {
setCurrentStep(form.value.activeStep + 1);
async function goNextStep() {
await setCurrentStep(form.value.activeStep + 1);
}
function startJourneyDrag(stageId) {
draggedStageId.value = stageId;
}
function endJourneyDrag() {
draggedStageId.value = "";
}
function dropJourneyStage(targetStageId) {
const sourceStageId = draggedStageId.value;
endJourneyDrag();
if (!sourceStageId || sourceStageId === targetStageId) return;
const sourceIndex = form.value.journeyOrder.indexOf(sourceStageId);
const targetIndex = form.value.journeyOrder.indexOf(targetStageId);
if (sourceIndex < 0 || targetIndex < 0) return;
const nextOrder = [...form.value.journeyOrder];
nextOrder.splice(sourceIndex, 1);
nextOrder.splice(targetIndex, 0, sourceStageId);
updateJourneyOrder(nextOrder);
}
function moveJourneyStage(index, offset) {
const targetIndex = index + offset;
if (targetIndex < 0 || targetIndex >= form.value.journeyOrder.length) return;
const nextOrder = [...form.value.journeyOrder];
[nextOrder[index], nextOrder[targetIndex]] = [nextOrder[targetIndex], nextOrder[index]];
updateJourneyOrder(nextOrder);
}
function updateJourneyOrder(nextOrder) {
form.value.journeyOrder = nextOrder;
journeyCheckedCorrectly.value = false;
journeyCheckMessage.value = "";
}
async function checkJourneyOrder() {
await ensureJourneyOrderValid(true);
}
async function ensureJourneyOrderValid(showSuccess = false) {
if (checkingJourney.value) return false;
checkingJourney.value = true;
try {
const response = await checkConsumerScenarioStepOne({ stageOrder: form.value.journeyOrder });
const result = response?.data || {};
journeyCheckedCorrectly.value = result.valid === true;
journeyCheckMessage.value = result.message || (result.valid ? "排序正确,已完成本步骤。" : "排序还不正确,请继续调整。");
if (result.valid && showSuccess) proxy?.$modal?.msgSuccess("排序正确,可以进入下一步");
return result.valid === true;
} catch (error) {
journeyCheckedCorrectly.value = false;
journeyCheckMessage.value = error?.message || "排序校验失败,请稍后重试。";
return false;
} finally {
checkingJourney.value = false;
}
}
function buildOutcome() {
@ -467,6 +621,8 @@ function buildOutcome() {
taskId: taskConfig.value?.id || "",
taskName: taskTitle.value,
currentStep: form.value.activeStep,
journeyOrder: form.value.journeyOrder,
journeyCheckedCorrectly: journeyCheckedCorrectly.value,
phases,
classifications: form.value.classifications,
matrix: form.value.matrix,
@ -485,10 +641,12 @@ function buildAnswerPayload(status) {
async function persist(status = "IN_PROGRESS") {
if (saving.value) return;
if (status !== "RESET" && form.value.activeStep === 1 && !(await ensureJourneyOrderValid())) return false;
saving.value = true;
try {
await saveStudentTrainingAnswer(TASK_KEY, buildAnswerPayload(status));
proxy?.$modal?.msgSuccess(status === "SUBMITTED" ? "提交成功" : "保存成功");
return true;
} finally {
saving.value = false;
}
@ -872,6 +1030,184 @@ function exportOutcome() {
font-weight: 900;
}
.journey-sort-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-bottom: 22px;
padding: 20px;
border: 1px solid rgba(91, 218, 255, 0.28);
border-radius: 18px;
background: linear-gradient(120deg, rgba(7, 94, 132, 0.26), rgba(2, 26, 43, 0.72));
h4 {
margin: 5px 0 8px;
color: #ffffff;
font-size: 21px;
line-height: 1.35;
}
p {
max-width: 700px;
margin: 0;
color: #b9d8e9;
font-size: 14px;
line-height: 1.75;
}
}
.journey-sort-kicker {
color: #69e3ff !important;
font-size: 12px !important;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.journey-check-action {
flex: 0 0 auto;
min-height: 42px;
padding: 0 18px;
border: 1px solid rgba(112, 235, 255, 0.74);
border-radius: 10px;
color: #ecfdff;
background: linear-gradient(135deg, #078ac2, #07558a);
box-shadow: 0 8px 20px rgba(0, 145, 203, 0.2);
font: inherit;
font-size: 14px;
font-weight: 800;
cursor: pointer;
&:hover:not(:disabled) {
transform: translateY(-1px);
background: linear-gradient(135deg, #0ba9df, #0870aa);
}
&:disabled {
cursor: not-allowed;
opacity: 0.64;
}
}
.journey-sort-list {
display: grid;
grid-template-columns: repeat(9, minmax(0, 1fr));
align-items: center;
gap: 8px;
}
.journey-sort-card {
position: relative;
display: grid;
grid-column: span 1;
min-height: 162px;
min-width: 0;
grid-template-rows: auto 1fr auto;
padding: 16px;
border: 1px solid rgba(63, 174, 222, 0.48);
border-radius: 14px;
color: #ecfbff;
background: linear-gradient(150deg, rgba(8, 74, 109, 0.82), rgba(3, 24, 42, 0.9));
box-shadow: inset 0 1px 0 rgba(201, 247, 255, 0.1), 0 12px 24px rgba(0, 0, 0, 0.16);
cursor: grab;
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, opacity 0.18s ease;
&:hover {
transform: translateY(-3px);
border-color: rgba(106, 235, 255, 0.94);
box-shadow: inset 0 1px 0 rgba(201, 247, 255, 0.16), 0 16px 30px rgba(0, 0, 0, 0.28), 0 0 20px rgba(15, 199, 242, 0.14);
}
&.dragging {
opacity: 0.45;
cursor: grabbing;
}
}
.journey-card-order {
color: #78e9ff;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.08em;
}
.journey-card-copy {
align-self: center;
strong,
span {
display: block;
}
strong {
margin-bottom: 8px;
color: #ffffff;
font-size: 15px;
line-height: 1.45;
}
span {
color: #b6d9e9;
font-size: 12px;
line-height: 1.65;
}
}
.journey-card-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
button {
display: inline-grid;
width: 26px;
height: 26px;
place-items: center;
border: 1px solid rgba(111, 220, 246, 0.45);
border-radius: 7px;
color: #dffbff;
background: rgba(4, 41, 65, 0.76);
font: inherit;
font-weight: 900;
cursor: pointer;
&:hover:not(:disabled) {
border-color: #72e8ff;
background: rgba(9, 119, 160, 0.9);
}
&:disabled {
cursor: not-allowed;
opacity: 0.28;
}
}
}
.journey-sort-arrow {
justify-self: center;
color: #42cfff;
font-size: 20px;
font-weight: 900;
}
.journey-check-message {
margin: 20px 0 0;
padding: 11px 14px;
border: 1px solid rgba(255, 136, 106, 0.7);
border-radius: 10px;
color: #ffd2c7;
background: rgba(127, 45, 30, 0.25);
font-size: 14px;
font-weight: 700;
&.success {
border-color: rgba(90, 238, 198, 0.65);
color: #d6fff3;
background: rgba(16, 122, 98, 0.25);
}
}
.journey-flow {
display: grid;
grid-template-columns: repeat(5, minmax(120px, 1fr));
@ -1295,6 +1631,26 @@ function exportOutcome() {
grid-template-columns: 1fr;
}
.journey-sort-head {
flex-direction: column;
}
.journey-check-action {
width: 100%;
}
.journey-sort-list {
grid-template-columns: 1fr;
}
.journey-sort-card {
min-height: 126px;
}
.journey-sort-arrow {
transform: rotate(90deg);
}
.nav-action,
.training-action {
width: 100%;

Loading…
Cancel
Save