feat: add target user rfm upload

dev-QQq
chenyuan 3 weeks ago
parent 7a3b2a8b02
commit 45f8e8791a

@ -335,3 +335,24 @@ export function checkProductDevelopmentFactorsStepFour(payload) {
}
return request({ url: "/api/student-training-answers/product-development-factors/step-4/validate", method: "post", data: payload, headers: { repeatSubmit: false } });
}
export function checkTargetUserProfileStepOne(payload) {
const value = payload || {};
if (isStudentDemo()) {
const fileName = String(value.excelName || "").trim();
const fileUrl = String(value.excelUrl || "").trim();
if (!fileUrl || !/\.xlsx?$/i.test(fileName)) {
return Promise.reject(new Error("请上传 RFM 用户分层分析结果 Excel 数据"));
}
if (Number(value.previewRowCount || 0) < 1 || Number(value.previewColumnCount || 0) < 1) {
return Promise.reject(new Error("Excel 文件需要包含可预览的表头和数据"));
}
return Promise.resolve({ code: 200, data: { valid: true, message: "校验通过" } });
}
return request({
url: "/api/student-training-answers/target-user-profile/step-1/validate",
method: "post",
data: value,
headers: { repeatSubmit: false },
});
}

@ -51,29 +51,44 @@
</div>
<section v-show="currentStep === 1" class="step-panel">
<p class="task-desc">
通过电商数据平台搜索智能吸顶灯助眠产品等产品的客户群画像如没有电商数据平台账号可通过百度指数进行搜索并将搜索结果截图进行分析
公司团队通过第三方数据公司采集了电商数据上购买相关产品的用户行为数据包含用户 ID最近购买天数R购买次数F消费金额M等字段请完成 RFM 分析和用户分层后上传分析结果 Excel 数据
</p>
<div class="upload-panel">
<input ref="fileInputRef" type="file" class="file-input" accept=".png,.jpg,.jpeg,.webp,.pdf,.doc,.docx,.xls,.xlsx" @change="handleFileChange" />
<input ref="fileInputRef" type="file" class="file-input" accept=".xls,.xlsx" @change="handleFileChange" />
<el-button class="upload-action" :loading="uploading" @click="triggerFileSelect">
<el-icon><Upload /></el-icon>
上传客户群画像搜索结果
上传 RFM 用户分层分析结果 Excel 数据
</el-button>
<div v-if="form.profileFileName" class="file-chip">
<el-icon><Document /></el-icon>
<span>{{ form.profileFileName }}</span>
</div>
<el-button v-if="hasExcelPreview" class="preview-action" @click="previewDialogVisible = true">
预览数据
</el-button>
</div>
<p class="upload-tip">仅支持 .xls.xlsx 文件上传后可预览首个工作表的表头和前 20 行数据</p>
<label class="field-block">
<span>智能吸顶灯助眠产品等产品客户群画像分析</span>
<textarea
v-model="form.profileAnalysis"
class="profile-textarea"
placeholder="结合搜索结果填写客户群画像,例如年龄、性别、地域、消费能力、核心需求、典型场景、内容偏好和购买顾虑。"
/>
</label>
<el-dialog v-model="previewDialogVisible" title="RFM 用户分层分析结果预览" width="min(960px, 92vw)" append-to-body>
<div class="excel-preview-meta">
工作表{{ form.profilePreview.sheetName || "Sheet1" }}  {{ form.profilePreview.totalRows || 0 }} {{ form.profilePreview.totalColumns || 0 }} 仅展示前 {{ form.profilePreview.rows.length }} 行数据
</div>
<div class="excel-preview-wrap">
<table class="excel-preview-table">
<thead>
<tr>
<th v-for="(column, index) in form.profilePreview.columns" :key="`${column}-${index}`">{{ column || `${index + 1}` }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in form.profilePreview.rows" :key="rowIndex">
<td v-for="(_, columnIndex) in form.profilePreview.columns" :key="columnIndex">{{ row[columnIndex] }}</td>
</tr>
</tbody>
</table>
</div>
</el-dialog>
</section>
<section v-show="currentStep === 2" class="step-panel">
@ -214,7 +229,7 @@
<el-icon><CircleCheck /></el-icon>
保存当前进度
</el-button>
<el-button class="step-action step-action--next" :disabled="currentStep >= steps.length" @click="currentStep += 1">
<el-button class="step-action step-action--next" :disabled="currentStep >= steps.length" @click="goNext">
下一步
<el-icon><ArrowRight /></el-icon>
</el-button>
@ -244,11 +259,12 @@
<script setup>
import { ArrowLeft, ArrowRight, CircleCheck, Document, Download, Upload } from "@element-plus/icons-vue";
import * as XLSX from "xlsx";
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, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
import { checkTargetUserProfileStepOne, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
import productReferenceImage from "@/assets/images/target-user-profile-u1344.png";
const TASK_KEY = "target-user-profile";
@ -258,6 +274,7 @@ const taskConfig = ref(null);
const saving = ref(false);
const uploading = ref(false);
const fileInputRef = ref(null);
const previewDialogVisible = ref(false);
const currentStep = ref(1);
const activeInterviewIndex = ref(0);
const interviewDialogVisible = ref(false);
@ -400,6 +417,7 @@ function createForm() {
return {
profileFileName: "",
profileFileUrl: "",
profilePreview: createEmptyPreview(),
profileAnalysis: "",
keyUserProfile: "",
keyUserRecords: createKeyUserRecords(),
@ -412,6 +430,16 @@ function createForm() {
};
}
function createEmptyPreview() {
return {
sheetName: "",
columns: [],
rows: [],
totalRows: 0,
totalColumns: 0,
};
}
const form = ref(createForm());
const taskTitle = computed(() => taskConfig.value?.taskName || "目标用户画像");
@ -426,6 +454,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 progressItems = computed(() => [
...steps.value.map((step) => ({
@ -515,6 +544,7 @@ function applySavedAnswer(value) {
...createForm(),
profileFileName: parsed.profileFileName || parsed.file?.name || "",
profileFileUrl: parsed.profileFileUrl || parsed.file?.url || "",
profilePreview: normalizeExcelPreview(parsed.profilePreview),
profileAnalysis: parsed.profileAnalysis || "",
keyUserProfile: parsed.keyUserProfile || "",
keyUserRecords: normalizeKeyUserRecords(parsed.keyUserRecords, parsed.keyUserProfile),
@ -531,6 +561,19 @@ function applySavedAnswer(value) {
}
}
function normalizeExcelPreview(preview) {
if (!preview || typeof preview !== "object" || !Array.isArray(preview.columns) || !Array.isArray(preview.rows)) {
return createEmptyPreview();
}
return {
sheetName: String(preview.sheetName || ""),
columns: preview.columns.slice(0, 100).map((item) => String(item ?? "")),
rows: preview.rows.slice(0, 20).map((row) => (Array.isArray(row) ? row.slice(0, 100).map((item) => String(item ?? "")) : [])),
totalRows: Math.max(0, Number(preview.totalRows || 0)),
totalColumns: Math.max(0, Number(preview.totalColumns || 0)),
};
}
function triggerFileSelect() {
fileInputRef.value?.click();
}
@ -546,20 +589,61 @@ function handleInterviewClick(index) {
async function handleFileChange(event) {
const file = event.target.files?.[0];
if (!file) return;
const fileName = file.name.toLowerCase();
if (!/\.xlsx?$/.test(fileName)) {
proxy?.$modal?.msgError("请上传 .xls 或 .xlsx 格式的 Excel 文件");
event.target.value = "";
return;
}
if (file.size > 20 * 1024 * 1024) {
proxy?.$modal?.msgError("Excel 文件不能超过 20MB");
event.target.value = "";
return;
}
const data = new FormData();
data.append("file", file);
uploading.value = true;
try {
const preview = await parseExcelPreview(file);
const res = await uploadStudentTrainingFile(data);
form.value.profileFileName = file.name;
form.value.profileFileUrl = res?.url || res?.fileName || res?.data?.url || res?.data?.fileName || "";
proxy?.$modal?.msgSuccess("上传成功");
form.value.profilePreview = preview;
previewDialogVisible.value = true;
proxy?.$modal?.msgSuccess("上传成功,已生成数据预览");
} catch (error) {
proxy?.$modal?.msgError(error?.message || "Excel 文件解析或上传失败,请检查文件后重试");
} finally {
uploading.value = false;
event.target.value = "";
}
}
async function parseExcelPreview(file) {
const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" });
const sheetName = workbook.SheetNames?.[0];
if (!sheetName || !workbook.Sheets?.[sheetName]) {
throw new Error("Excel 文件中未找到可预览的工作表");
}
const rawRows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { header: 1, defval: "", raw: false });
const rows = rawRows.filter((row) => Array.isArray(row) && row.some((cell) => String(cell ?? "").trim()));
if (rows.length < 2) {
throw new Error("Excel 至少需要包含一行表头和一行数据");
}
const columns = rows[0].map((cell) => String(cell ?? ""));
if (!columns.length) {
throw new Error("Excel 表头不能为空");
}
const dataRows = rows.slice(1);
return {
sheetName,
columns,
rows: dataRows.slice(0, 20).map((row) => columns.map((_, index) => String(row[index] ?? ""))),
totalRows: dataRows.length,
totalColumns: columns.length,
};
}
function buildAnswerPayload(status) {
const saveAction = status === "SUBMITTED" ? "SUBMIT" : status === "RESET" ? "RESET" : "SAVE";
return {
@ -572,6 +656,7 @@ function buildAnswerPayload(status) {
currentStep: currentStep.value,
profileFileName: form.value.profileFileName,
profileFileUrl: form.value.profileFileUrl,
profilePreview: form.value.profilePreview,
file: {
name: form.value.profileFileName,
url: form.value.profileFileUrl,
@ -592,6 +677,14 @@ function buildAnswerPayload(status) {
async function persist(status = "IN_PROGRESS") {
saving.value = true;
try {
if (status !== "RESET" && (currentStep.value === 1 || status === "SUBMITTED")) {
await checkTargetUserProfileStepOne({
excelUrl: form.value.profileFileUrl,
excelName: form.value.profileFileName,
previewRowCount: form.value.profilePreview.rows.length,
previewColumnCount: form.value.profilePreview.columns.length,
});
}
await saveStudentTrainingAnswer(TASK_KEY, buildAnswerPayload(status));
proxy?.$modal?.msgSuccess(status === "SUBMITTED" ? "提交成功" : "保存成功");
} finally {
@ -603,6 +696,13 @@ function saveCurrentProgress() {
return persist("IN_PROGRESS");
}
async function goNext() {
if (currentStep.value === 1) {
await persist("IN_PROGRESS");
}
currentStep.value += 1;
}
function submitTask() {
return persist("SUBMITTED");
}
@ -1426,6 +1526,74 @@ function exportOutcome() {
}
}
.preview-action {
height: 36px;
border: 1px solid rgba(93, 213, 255, 0.5);
border-radius: 999px;
color: #a8eeff;
background: rgba(17, 126, 178, 0.18);
font-weight: 700;
}
.upload-tip {
margin: -6px 0 8px;
color: #8fa8b8;
font-size: 13px;
line-height: 1.7;
}
.excel-preview-meta {
margin-bottom: 12px;
color: #52687a;
font-size: 13px;
line-height: 1.6;
}
.excel-preview-wrap {
max-height: 480px;
overflow: auto;
border: 1px solid #dce6ec;
border-radius: 8px;
}
.excel-preview-table {
width: 100%;
min-width: 640px;
border-collapse: collapse;
color: #22313f;
font-size: 13px;
th,
td {
min-width: 112px;
padding: 9px 12px;
border-right: 1px solid #e4edf2;
border-bottom: 1px solid #e4edf2;
text-align: left;
vertical-align: top;
white-space: pre-wrap;
word-break: break-word;
}
th {
position: sticky;
top: 0;
z-index: 1;
color: #ffffff;
background: #158fc4;
font-weight: 700;
}
tr:last-child td {
border-bottom: 0;
}
th:last-child,
td:last-child {
border-right: 0;
}
}
.field-block {
display: block;

Loading…
Cancel
Save