feat: add target user rfm charts

dev-QQq
chenyuan 3 weeks ago
parent 45f8e8791a
commit 037f9cc33c

@ -356,3 +356,22 @@ export function checkTargetUserProfileStepOne(payload) {
headers: { repeatSubmit: false },
});
}
export function checkTargetUserProfileStepTwo(payload) {
const charts = Array.isArray(payload?.charts) ? payload.charts : [];
const expectedTypes = ["segment-share", "average-spend", "score-heatmap"];
if (isStudentDemo()) {
const submitted = new Set(charts.map((chart) => chart?.type));
const invalid = charts.find((chart) => !String(chart?.url || "").trim() || !/\.(png|jpe?g|webp)$/i.test(String(chart?.name || "")));
if (charts.length !== expectedTypes.length || expectedTypes.some((type) => !submitted.has(type)) || invalid) {
return Promise.reject(new Error("请上传客户分层占比饼图、平均消费对比柱状图和 RFM 分数分布热力图"));
}
return Promise.resolve({ code: 200, data: { valid: true, message: "校验通过" } });
}
return request({
url: "/api/student-training-answers/target-user-profile/step-2/validate",
method: "post",
data: { charts },
headers: { repeatSubmit: false },
});
}

@ -93,64 +93,41 @@
<section v-show="currentStep === 2" class="step-panel">
<p class="task-desc task-desc--compact">
重点用户画像开发团队已经访谈了3位重点用户并撰写了访谈记录请根据访谈记录从用户的行为特征需求痛点消费心理等三个维度进行重点用户画像
基于步骤一的 RFM 分析结果制作可视化图表包括客户分层占比饼图各客户分层平均消费对比柱状图RFM 分数分布热力图
</p>
<div class="interview-tabs" aria-label="访">
<button
v-for="(record, index) in form.keyUserRecords"
:key="record.code"
type="button"
:class="['interview-tab', { active: activeInterviewIndex === index }]"
@click="handleInterviewClick(index)"
>
{{ record.title }}{{ record.code }}
</button>
</div>
<div v-if="interviewDialogVisible" class="interview-dialog-layer" role="dialog" aria-modal="true">
<div class="interview-dialog">
<button type="button" class="interview-dialog-close" @click="interviewDialogVisible = false">关闭</button>
<div class="interview-dialog-body">
<p class="interview-line">访谈编号{{ currentInterviewTranscript.code }}</p>
<p class="interview-line">基本属性{{ currentInterviewTranscript.basicAttributes }}</p>
<p class="interview-title">访谈内容</p>
<div v-for="item in currentInterviewTranscript.content" :key="item.question" class="interview-qa">
<p>Q{{ item.question }}</p>
<p>A{{ item.answer }}</p>
<div class="rfm-chart-grid">
<article v-for="chart in rfmCharts" :key="chart.id" class="rfm-chart-card">
<div class="rfm-chart-card__head">
<span class="rfm-chart-card__index">0{{ chart.index }}</span>
<h4>{{ chart.title }}</h4>
</div>
<input
:ref="(element) => setChartInputRef(chart.id, element)"
type="file"
class="file-input"
accept=".png,.jpg,.jpeg,.webp"
@change="(event) => handleChartFileChange(chart, event)"
/>
<button type="button" class="rfm-chart-upload" :disabled="uploadingChartId === chart.id" @click="triggerChartFileSelect(chart.id)">
<el-icon><Upload /></el-icon>
{{ uploadingChartId === chart.id ? "上传中…" : chart.buttonText }}
</button>
<div v-if="form.rfmCharts[chart.id]?.url" class="rfm-chart-file">
<img :src="form.rfmCharts[chart.id].url" :alt="`${chart.title}缩略图`" @click="openChartPreview(chart)" />
<div>
<strong>{{ form.rfmCharts[chart.id].name }}</strong>
<button type="button" @click="openChartPreview(chart)"></button>
</div>
</div>
</div>
<p v-else class="rfm-chart-empty">上传 PNGJPG WEBP 格式的图表</p>
</article>
</div>
<p class="upload-tip">三类图表均需上传图片上传后可点击缩略图预览保存后再次进入页面仍可查看</p>
<div class="portrait-table-wrap">
<section v-for="(record, index) in form.keyUserRecords" :key="record.code" class="portrait-record">
<div class="portrait-user">
<p v-if="index === 0" class="portrait-example"></p>
<label>
<span>用户</span>
<input v-model="record.user" class="portrait-input" />
</label>
<label>
<span>基础属性</span>
<input v-model="record.basicAttributes" class="portrait-input portrait-input--wide" />
</label>
<label>
<span>标签</span>
<input v-model="record.tags" class="portrait-input portrait-input--wide" />
</label>
</div>
<div class="portrait-grid">
<div class="portrait-head">行为特征</div>
<div class="portrait-head">需求痛点</div>
<div class="portrait-head">消费心理</div>
<textarea v-model="record.behavior" class="portrait-cell" />
<textarea v-model="record.painPoints" class="portrait-cell" />
<textarea v-model="record.psychology" class="portrait-cell" />
</div>
</section>
</div>
<el-dialog v-model="chartPreviewVisible" :title="activeChartPreview.title" width="min(960px, 92vw)" append-to-body>
<img class="rfm-chart-preview" :src="activeChartPreview.url" :alt="activeChartPreview.title" />
</el-dialog>
</section>
<section v-show="currentStep === 3" class="step-panel">
@ -264,10 +241,15 @@ 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 { checkTargetUserProfileStepOne, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
import { checkTargetUserProfileStepOne, checkTargetUserProfileStepTwo, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
import productReferenceImage from "@/assets/images/target-user-profile-u1344.png";
const TASK_KEY = "target-user-profile";
const RFM_CHARTS = [
{ id: "segment-share", index: 1, title: "客户分层占比饼图", buttonText: "上传客户分层占比饼图" },
{ id: "average-spend", index: 2, title: "客户分层平均消费对比柱状图", buttonText: "上传客户分层平均消费对比柱状图" },
{ id: "score-heatmap", index: 3, title: "RFM 分数分布热力图", buttonText: "上传 RFM 分布热力图" },
];
const { proxy } = getCurrentInstance();
const taskConfig = ref(null);
@ -275,6 +257,10 @@ const saving = ref(false);
const uploading = ref(false);
const fileInputRef = ref(null);
const previewDialogVisible = ref(false);
const chartFileInputRefs = ref({});
const uploadingChartId = ref("");
const chartPreviewVisible = ref(false);
const activeChartPreview = ref({ title: "图表预览", url: "" });
const currentStep = ref(1);
const activeInterviewIndex = ref(0);
const interviewDialogVisible = ref(false);
@ -418,6 +404,7 @@ function createForm() {
profileFileName: "",
profileFileUrl: "",
profilePreview: createEmptyPreview(),
rfmCharts: createEmptyRfmCharts(),
profileAnalysis: "",
keyUserProfile: "",
keyUserRecords: createKeyUserRecords(),
@ -430,6 +417,13 @@ function createForm() {
};
}
function createEmptyRfmCharts() {
return RFM_CHARTS.reduce((charts, chart) => {
charts[chart.id] = { url: "", name: "" };
return charts;
}, {});
}
function createEmptyPreview() {
return {
sheetName: "",
@ -455,6 +449,7 @@ const steps = computed(() =>
);
const activeStepName = computed(() => steps.value.find((step) => step.no === currentStep.value)?.name || "");
const hasExcelPreview = computed(() => form.value.profilePreview.columns.length > 0);
const rfmCharts = computed(() => RFM_CHARTS);
const progressItems = computed(() => [
...steps.value.map((step) => ({
@ -545,6 +540,7 @@ function applySavedAnswer(value) {
profileFileName: parsed.profileFileName || parsed.file?.name || "",
profileFileUrl: parsed.profileFileUrl || parsed.file?.url || "",
profilePreview: normalizeExcelPreview(parsed.profilePreview),
rfmCharts: normalizeRfmCharts(parsed.rfmCharts),
profileAnalysis: parsed.profileAnalysis || "",
keyUserProfile: parsed.keyUserProfile || "",
keyUserRecords: normalizeKeyUserRecords(parsed.keyUserRecords, parsed.keyUserProfile),
@ -561,6 +557,21 @@ function applySavedAnswer(value) {
}
}
function normalizeRfmCharts(charts) {
const defaults = createEmptyRfmCharts();
if (!charts || typeof charts !== "object") {
return defaults;
}
return RFM_CHARTS.reduce((result, chart) => {
const value = charts[chart.id] || {};
result[chart.id] = {
url: String(value.url || ""),
name: String(value.name || ""),
};
return result;
}, defaults);
}
function normalizeExcelPreview(preview) {
if (!preview || typeof preview !== "object" || !Array.isArray(preview.columns) || !Array.isArray(preview.rows)) {
return createEmptyPreview();
@ -578,6 +589,23 @@ function triggerFileSelect() {
fileInputRef.value?.click();
}
function setChartInputRef(chartId, element) {
if (element) {
chartFileInputRefs.value[chartId] = element;
}
}
function triggerChartFileSelect(chartId) {
chartFileInputRefs.value[chartId]?.click();
}
function openChartPreview(chart) {
const file = form.value.rfmCharts[chart.id];
if (!file?.url) return;
activeChartPreview.value = { title: chart.title, url: file.url };
chartPreviewVisible.value = true;
}
function handleInterviewClick(index) {
activeInterviewIndex.value = index;
const record = form.value.keyUserRecords[index];
@ -619,6 +647,39 @@ async function handleFileChange(event) {
}
}
async function handleChartFileChange(chart, event) {
const file = event.target.files?.[0];
if (!file) return;
if (!/\.(png|jpe?g|webp)$/i.test(file.name)) {
proxy?.$modal?.msgError("请上传 PNG、JPG 或 WEBP 格式的图表图片");
event.target.value = "";
return;
}
if (file.size > 10 * 1024 * 1024) {
proxy?.$modal?.msgError("图表图片不能超过 10MB");
event.target.value = "";
return;
}
const data = new FormData();
data.append("file", file);
uploadingChartId.value = chart.id;
try {
const res = await uploadStudentTrainingFile(data);
const url = res?.url || res?.fileName || res?.data?.url || res?.data?.fileName || "";
if (!url) {
throw new Error("上传结果缺少文件地址");
}
form.value.rfmCharts[chart.id] = { url, name: file.name };
openChartPreview(chart);
proxy?.$modal?.msgSuccess(`${chart.title}上传成功`);
} catch (error) {
proxy?.$modal?.msgError(error?.message || "图表上传失败,请重试");
} finally {
uploadingChartId.value = "";
event.target.value = "";
}
}
async function parseExcelPreview(file) {
const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" });
const sheetName = workbook.SheetNames?.[0];
@ -657,6 +718,7 @@ function buildAnswerPayload(status) {
profileFileName: form.value.profileFileName,
profileFileUrl: form.value.profileFileUrl,
profilePreview: form.value.profilePreview,
rfmCharts: form.value.rfmCharts,
file: {
name: form.value.profileFileName,
url: form.value.profileFileUrl,
@ -685,6 +747,15 @@ async function persist(status = "IN_PROGRESS") {
previewColumnCount: form.value.profilePreview.columns.length,
});
}
if (status !== "RESET" && (currentStep.value === 2 || status === "SUBMITTED")) {
await checkTargetUserProfileStepTwo({
charts: RFM_CHARTS.map((chart) => ({
type: chart.id,
url: form.value.rfmCharts[chart.id]?.url || "",
name: form.value.rfmCharts[chart.id]?.name || "",
})),
});
}
await saveStudentTrainingAnswer(TASK_KEY, buildAnswerPayload(status));
proxy?.$modal?.msgSuccess(status === "SUBMITTED" ? "提交成功" : "保存成功");
} finally {
@ -697,7 +768,7 @@ function saveCurrentProgress() {
}
async function goNext() {
if (currentStep.value === 1) {
if (currentStep.value === 1 || currentStep.value === 2) {
await persist("IN_PROGRESS");
}
currentStep.value += 1;
@ -1594,6 +1665,140 @@ function exportOutcome() {
}
}
.rfm-chart-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
margin: 26px 0 18px;
}
.rfm-chart-card {
display: flex;
min-height: 232px;
flex-direction: column;
padding: 18px;
border: 1px solid rgba(83, 201, 244, 0.28);
border-radius: 18px;
background:
linear-gradient(145deg, rgba(23, 111, 153, 0.2), rgba(4, 20, 34, 0.72)),
rgba(2, 15, 26, 0.82);
}
.rfm-chart-card__head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: auto;
h4 {
margin: 0;
color: #e9f8ff;
font-size: 16px;
line-height: 1.45;
}
}
.rfm-chart-card__index {
display: grid;
width: 31px;
height: 31px;
flex: 0 0 auto;
place-items: center;
border: 1px solid rgba(114, 232, 255, 0.64);
border-radius: 50%;
color: #5ee8ff;
background: rgba(6, 84, 124, 0.38);
font-size: 12px;
font-weight: 800;
}
.rfm-chart-upload {
display: inline-flex;
min-height: 42px;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 18px;
padding: 0 14px;
border: 1px solid rgba(92, 224, 255, 0.7);
border-radius: 10px;
color: #ffffff;
background: linear-gradient(95deg, #0b84bd, #096491);
cursor: pointer;
font-size: 14px;
font-weight: 800;
transition: transform 0.16s ease, box-shadow 0.16s ease;
&:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 8px 18px rgba(0, 141, 205, 0.22);
}
&:disabled {
cursor: wait;
opacity: 0.72;
}
}
.rfm-chart-empty {
margin: 15px 0 0;
color: #7f9aa9;
font-size: 12px;
line-height: 1.5;
}
.rfm-chart-file {
display: flex;
align-items: center;
gap: 11px;
margin-top: 16px;
padding: 8px;
border: 1px solid rgba(102, 219, 255, 0.28);
border-radius: 10px;
background: rgba(0, 13, 23, 0.58);
img {
width: 58px;
height: 46px;
flex: 0 0 auto;
border-radius: 6px;
cursor: zoom-in;
object-fit: cover;
}
div {
min-width: 0;
}
strong {
display: block;
overflow: hidden;
color: #bcefff;
font-size: 12px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
button {
margin-top: 4px;
padding: 0;
border: 0;
color: #55dfff;
background: transparent;
cursor: pointer;
font-size: 12px;
}
}
.rfm-chart-preview {
display: block;
width: 100%;
max-height: 72vh;
border-radius: 8px;
object-fit: contain;
}
.field-block {
display: block;
@ -1718,6 +1923,10 @@ function exportOutcome() {
grid-template-columns: 1fr;
}
.rfm-chart-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.product-reference {
justify-content: flex-start;
}
@ -1781,6 +1990,10 @@ function exportOutcome() {
}
@media (max-width: 760px) {
.rfm-chart-grid {
grid-template-columns: 1fr;
}
.interview-tabs {
flex-direction: column;
gap: 12px;

Loading…
Cancel
Save