feat: add target user market segmentation

dev-QQq
chenyuan 3 weeks ago
parent 8837a0e573
commit cfef95fc81

@ -413,3 +413,22 @@ export function checkTargetUserProfileStepFour(payload) {
headers: { repeatSubmit: false }, headers: { repeatSubmit: false },
}); });
} }
export function checkTargetUserProfileStepFive(payload) {
const value = payload || {};
const segments = Array.isArray(value.segments) ? value.segments : [];
const requiredFields = ["market", "attractiveness", "painPoints", "behaviorCharacteristics", "productOpportunity"];
if (isStudentDemo()) {
const incomplete = segments.find((segment) => requiredFields.some((field) => !String(segment?.[field] || "").trim()));
if (!segments.length || segments.length > 6 || incomplete || !String(value.marketSelection || "").trim()) {
return Promise.reject(new Error("请完成细分市场分析表和市场选择结论"));
}
return Promise.resolve({ code: 200, data: { valid: true, message: "校验通过" } });
}
return request({
url: "/api/student-training-answers/target-user-profile/step-5/validate",
method: "post",
data: { segments, marketSelection: String(value.marketSelection || "") },
headers: { repeatSubmit: false },
});
}

@ -227,6 +227,65 @@
</div> </div>
</section> </section>
<section v-show="currentStep === 5" class="step-panel">
<p class="task-desc task-desc--compact">
结合 RFM 用户群画像和重点用户画像的分析结果进一步细分市场寻找产品开发机会请评估各细分市场的吸引力并从不同细分用户的行为特征和需求痛点中提炼产品机会
</p>
<div class="market-segment-toolbar">
<div>
<p class="section-eyebrow">用户细分</p>
<h4>细分市场分析表</h4>
</div>
<div class="market-segment-toolbar__actions">
<el-button class="segment-action" :disabled="form.marketSegments.length >= MAX_MARKET_SEGMENTS" @click="addMarketSegment">
增加一行
</el-button>
<el-button class="segment-action segment-action--muted" :disabled="form.marketSegments.length <= 1" @click="removeMarketSegment">
删除一行
</el-button>
</div>
</div>
<p class="market-segment-tip">可直接编辑表格内容支持行扩展最多 {{ MAX_MARKET_SEGMENTS }} </p>
<div class="market-segment-table-wrap">
<table class="market-segment-table">
<thead>
<tr>
<th>细分市场</th>
<th>市场吸引力</th>
<th>需求痛点</th>
<th>行为特征</th>
<th>产品机会</th>
</tr>
</thead>
<tbody>
<tr v-for="(segment, index) in form.marketSegments" :key="index">
<td>
<input v-model="segment.market" class="market-segment-input" placeholder="请输入细分市场" />
</td>
<td>
<el-radio-group v-model="segment.attractiveness" class="market-attractiveness">
<el-radio label="高"></el-radio>
<el-radio label="中"></el-radio>
<el-radio label="低"></el-radio>
</el-radio-group>
</td>
<td><textarea v-model="segment.painPoints" class="market-segment-textarea" placeholder="结合用户画像填写需求痛点" /></td>
<td><textarea v-model="segment.behaviorCharacteristics" class="market-segment-textarea" placeholder="填写该细分用户的行为特征" /></td>
<td><textarea v-model="segment.productOpportunity" class="market-segment-textarea" placeholder="提炼可切入的产品机会" /></td>
</tr>
</tbody>
</table>
</div>
<div class="market-selection-block">
<p class="section-eyebrow">市场选择</p>
<h4>结合前面的数据分析请说明团队应该针对哪些用户开发什么样的产品</h4>
<textarea v-model="form.marketSelection" class="market-selection-textarea" placeholder="请写明目标用户、核心需求和建议开发的产品方案" />
</div>
</section>
<div v-if="steps.length" class="step-actions"> <div v-if="steps.length" class="step-actions">
<el-button class="step-action step-action--previous" :disabled="currentStep <= 1" @click="currentStep -= 1"> <el-button class="step-action step-action--previous" :disabled="currentStep <= 1" @click="currentStep -= 1">
<el-icon><ArrowLeft /></el-icon> <el-icon><ArrowLeft /></el-icon>
@ -271,7 +330,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 { checkTargetUserProfileStepFour, checkTargetUserProfileStepOne, checkTargetUserProfileStepThree, checkTargetUserProfileStepTwo, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer"; import { checkTargetUserProfileStepFive, checkTargetUserProfileStepFour, checkTargetUserProfileStepOne, checkTargetUserProfileStepThree, checkTargetUserProfileStepTwo, getStudentTrainingAnswer, saveStudentTrainingAnswer, uploadStudentTrainingFile } from "@/api/studentTrainingAnswer";
const TASK_KEY = "target-user-profile"; const TASK_KEY = "target-user-profile";
const RFM_CHARTS = [ const RFM_CHARTS = [
@ -286,6 +345,8 @@ const RFM_INSIGHT_ROWS = [
{ dimension: "用户忠诚度", keyPoint: "F 分数分布反映的用户复购意愿如何?" }, { dimension: "用户忠诚度", keyPoint: "F 分数分布反映的用户复购意愿如何?" },
{ dimension: "重点客户群特征", keyPoint: "哪些客户最值得关注?为什么?" }, { dimension: "重点客户群特征", keyPoint: "哪些客户最值得关注?为什么?" },
]; ];
const DEFAULT_FLOW_STEPS = ["RFM 用户分层分析", "RFM 数据可视化", "用户群体特点分析", "重点用户画像", "用户细分与市场选择"];
const MAX_MARKET_SEGMENTS = 6;
const { proxy } = getCurrentInstance(); const { proxy } = getCurrentInstance();
const taskConfig = ref(null); const taskConfig = ref(null);
@ -427,12 +488,12 @@ function createKeyUserRecords() {
]; ];
} }
function createSegmentationRows() { function createMarketSegments() {
return ["新锐白领", "精致妈妈", "都市银发", "继续补充", "继续补充"].map((market) => ({ return ["职场白领25-35岁", "精致妈妈28-38岁", "银发人群50-65岁", ""].map((market) => createEmptyMarketSegment(market));
market, }
customerAnalysis: "",
productOpportunity: "", function createEmptyMarketSegment(market = "") {
})); return { market, attractiveness: "", painPoints: "", behaviorCharacteristics: "", productOpportunity: "" };
} }
function createForm() { function createForm() {
@ -445,12 +506,8 @@ function createForm() {
profileAnalysis: "", profileAnalysis: "",
keyUserProfile: "", keyUserProfile: "",
keyUserRecords: createKeyUserRecords(), keyUserRecords: createKeyUserRecords(),
segmentationStrategy: "", marketSegments: createMarketSegments(),
segmentationRows: createSegmentationRows(), marketSelection: "",
coreFunctions: ["", "", ""],
priceMin: "",
priceMax: "",
conclusion: "",
}; };
} }
@ -481,7 +538,10 @@ const taskTitle = computed(() => taskConfig.value?.taskName || "目标用户画
const taskBackground = computed(() => taskConfig.value?.background || defaultBackground); const taskBackground = computed(() => taskConfig.value?.background || defaultBackground);
const taskGoals = computed(() => parseArray(taskConfig.value?.objectives, defaultGoals)); const taskGoals = computed(() => parseArray(taskConfig.value?.objectives, defaultGoals));
const taskRequirement = computed(() => taskConfig.value?.requirements || defaultRequirement); const taskRequirement = computed(() => taskConfig.value?.requirements || defaultRequirement);
const flowSteps = computed(() => parseArray(taskConfig.value?.steps, [])); const flowSteps = computed(() => {
const configuredSteps = parseArray(taskConfig.value?.steps, DEFAULT_FLOW_STEPS);
return configuredSteps.length >= DEFAULT_FLOW_STEPS.length ? configuredSteps : DEFAULT_FLOW_STEPS;
});
const steps = computed(() => const steps = computed(() =>
flowSteps.value.map((name, index) => ({ flowSteps.value.map((name, index) => ({
no: index + 1, no: index + 1,
@ -558,16 +618,20 @@ function normalizeKeyUserRecords(records, legacyText = "") {
return defaults; return defaults;
} }
function normalizeSegmentationRows(rows, legacyText = "") { function normalizeMarketSegments(rows, legacyRows = []) {
const defaults = createSegmentationRows(); const defaults = createMarketSegments();
if (Array.isArray(rows)) { if (Array.isArray(rows) && rows.length) {
return defaults.map((item, index) => ({ return rows.slice(0, MAX_MARKET_SEGMENTS).map((item, index) => ({
...item, ...(defaults[index] || createEmptyMarketSegment()),
...(rows[index] || {}), ...(item || {}),
})); }));
} }
if (legacyText) { if (Array.isArray(legacyRows) && legacyRows.length) {
defaults[0].customerAnalysis = legacyText; return legacyRows.slice(0, MAX_MARKET_SEGMENTS).map((item, index) => ({
...(defaults[index] || createEmptyMarketSegment()),
market: String(item?.market || defaults[index]?.market || ""),
productOpportunity: String(item?.productOpportunity || ""),
}));
} }
return defaults; return defaults;
} }
@ -586,14 +650,10 @@ function applySavedAnswer(value) {
profileAnalysis: parsed.profileAnalysis || "", profileAnalysis: parsed.profileAnalysis || "",
keyUserProfile: parsed.keyUserProfile || "", keyUserProfile: parsed.keyUserProfile || "",
keyUserRecords: normalizeKeyUserRecords(parsed.keyUserRecords, parsed.keyUserProfile), keyUserRecords: normalizeKeyUserRecords(parsed.keyUserRecords, parsed.keyUserProfile),
segmentationStrategy: parsed.segmentationStrategy || "", marketSegments: normalizeMarketSegments(parsed.marketSegments, parsed.segmentationRows),
segmentationRows: normalizeSegmentationRows(parsed.segmentationRows, parsed.segmentationStrategy), marketSelection: parsed.marketSelection || parsed.segmentationStrategy || "",
coreFunctions: Array.isArray(parsed.coreFunctions) ? ["", "", ""].map((item, index) => parsed.coreFunctions[index] || item) : ["", "", ""],
priceMin: parsed.priceMin || "",
priceMax: parsed.priceMax || "",
conclusion: parsed.conclusion || "",
}; };
currentStep.value = Math.min(Math.max(Number(parsed.currentStep || 1), 1), 4); currentStep.value = Math.min(Math.max(Number(parsed.currentStep || 1), 1), steps.value.length || DEFAULT_FLOW_STEPS.length);
} catch (error) { } catch (error) {
// //
} }
@ -697,6 +757,16 @@ async function handleFileChange(event) {
} }
} }
function addMarketSegment() {
if (form.value.marketSegments.length >= MAX_MARKET_SEGMENTS) return;
form.value.marketSegments.push(createEmptyMarketSegment());
}
function removeMarketSegment() {
if (form.value.marketSegments.length <= 1) return;
form.value.marketSegments.pop();
}
async function handleChartFileChange(chart, event) { async function handleChartFileChange(chart, event) {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file) return; if (!file) return;
@ -777,12 +847,8 @@ function buildAnswerPayload(status) {
profileAnalysis: form.value.profileAnalysis, profileAnalysis: form.value.profileAnalysis,
keyUserProfile: form.value.keyUserProfile, keyUserProfile: form.value.keyUserProfile,
keyUserRecords: form.value.keyUserRecords, keyUserRecords: form.value.keyUserRecords,
segmentationStrategy: form.value.segmentationStrategy, marketSegments: form.value.marketSegments,
segmentationRows: form.value.segmentationRows, marketSelection: form.value.marketSelection,
coreFunctions: form.value.coreFunctions,
priceMin: form.value.priceMin,
priceMax: form.value.priceMax,
conclusion: form.value.conclusion,
}), }),
}; };
} }
@ -813,6 +879,9 @@ async function persist(status = "IN_PROGRESS") {
if (status !== "RESET" && (currentStep.value === 4 || status === "SUBMITTED")) { if (status !== "RESET" && (currentStep.value === 4 || status === "SUBMITTED")) {
await checkTargetUserProfileStepFour({ records: form.value.keyUserRecords }); await checkTargetUserProfileStepFour({ records: form.value.keyUserRecords });
} }
if (status !== "RESET" && (currentStep.value === 5 || status === "SUBMITTED")) {
await checkTargetUserProfileStepFive({ segments: form.value.marketSegments, marketSelection: form.value.marketSelection });
}
await saveStudentTrainingAnswer(TASK_KEY, buildAnswerPayload(status)); await saveStudentTrainingAnswer(TASK_KEY, buildAnswerPayload(status));
proxy?.$modal?.msgSuccess(status === "SUBMITTED" ? "提交成功" : "保存成功"); proxy?.$modal?.msgSuccess(status === "SUBMITTED" ? "提交成功" : "保存成功");
} finally { } finally {
@ -825,9 +894,7 @@ function saveCurrentProgress() {
} }
async function goNext() { async function goNext() {
if (currentStep.value === 1 || currentStep.value === 2 || currentStep.value === 3) { await persist("IN_PROGRESS");
await persist("IN_PROGRESS");
}
currentStep.value += 1; currentStep.value += 1;
} }
@ -848,13 +915,12 @@ function exportOutcome() {
`${record.title}${record.code}\n用户${record.user || ""}\n基础属性${record.basicAttributes || ""}\n标签${record.tags || ""}\n行为特征\n${record.behavior || ""}\n需求痛点\n${record.painPoints || ""}\n消费心理\n${record.psychology || ""}`, `${record.title}${record.code}\n用户${record.user || ""}\n基础属性${record.basicAttributes || ""}\n标签${record.tags || ""}\n行为特征\n${record.behavior || ""}\n需求痛点\n${record.painPoints || ""}\n消费心理\n${record.psychology || ""}`,
) )
.join("\n\n"); .join("\n\n");
const segmentationText = form.value.segmentationRows const segmentationText = form.value.marketSegments
.map((row) => `${row.market || ""}\n客户分析:\n${row.customerAnalysis || ""}\n产品机会\n${row.productOpportunity || ""}`) .map((row) => `${row.market || ""}\n市场吸引力:${row.attractiveness || ""}\n需求痛点\n${row.painPoints || ""}\n行为特征\n${row.behaviorCharacteristics || ""}\n产品机会\n${row.productOpportunity || ""}`)
.join("\n\n"); .join("\n\n");
const rfmInsightsText = form.value.rfmInsights.map((item) => `${item.dimension}\n${item.analysis || ""}`).join("\n\n"); const rfmInsightsText = form.value.rfmInsights.map((item) => `${item.dimension}\n${item.analysis || ""}`).join("\n\n");
const coreFunctionsText = form.value.coreFunctions.map((item, index) => `核心功能${index + 1}${item || ""}`).join("\n");
const rfmChartsText = RFM_CHARTS.map((chart) => `${chart.title}${form.value.rfmCharts[chart.id]?.url || "未上传"}`).join("\n"); const rfmChartsText = RFM_CHARTS.map((chart) => `${chart.title}${form.value.rfmCharts[chart.id]?.url || "未上传"}`).join("\n");
const content = `目标用户画像\n\n任务流程${flowSteps.value.join(" > ")}\n\nRFM 分层分析 Excel${form.value.profileFileName || "未上传"}\n文件地址${form.value.profileFileUrl || ""}\n\nRFM 可视化图表:\n${rfmChartsText}\n\nRFM 用户群体特点分析:\n${rfmInsightsText}\n\n重点用户画像\n${keyUserRecordsText}\n\n用户细分与市场选择\n${segmentationText}\n\n计划设计的核心功能:\n${coreFunctionsText}\n\n预计产品价格区间${form.value.priceMin || ""} - ${form.value.priceMax || ""}\n\n分析结论\n${form.value.conclusion || ""}`; const content = `目标用户画像\n\n任务流程${flowSteps.value.join(" > ")}\n\nRFM 分层分析 Excel${form.value.profileFileName || "未上传"}\n文件地址${form.value.profileFileUrl || ""}\n\nRFM 可视化图表:\n${rfmChartsText}\n\nRFM 用户群体特点分析:\n${rfmInsightsText}\n\n重点用户画像\n${keyUserRecordsText}\n\n用户细分与市场选择\n${segmentationText}\n\n市场选择结论:\n${form.value.marketSelection || ""}`;
const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement("a"); const link = document.createElement("a");
@ -2086,6 +2152,153 @@ function exportOutcome() {
background: rgba(18, 85, 116, 0.78); background: rgba(18, 85, 116, 0.78);
} }
.market-segment-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-top: 26px;
.section-eyebrow {
margin-bottom: 5px;
}
h4 {
margin: 0;
color: #f0fbff;
font-size: 18px;
}
}
.market-segment-toolbar__actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.market-segment-toolbar__actions :deep(.segment-action) {
min-width: 104px;
height: 40px;
border: 1px solid rgba(92, 224, 255, 0.7);
border-radius: 10px;
color: #ffffff;
background: linear-gradient(95deg, #0b84bd, #096491);
font-weight: 800;
}
.market-segment-toolbar__actions :deep(.segment-action--muted) {
border-color: rgba(118, 176, 202, 0.55);
color: #cce7f5;
background: rgba(14, 73, 101, 0.76);
}
.market-segment-tip {
margin: 10px 0 16px;
color: #6ee3ff;
font-size: 13px;
}
.market-segment-table-wrap {
overflow-x: auto;
border: 1px solid rgba(0, 180, 255, 0.25);
border-radius: 18px;
background: rgba(1, 14, 26, 0.72);
}
.market-segment-table {
width: 100%;
min-width: 1120px;
border-collapse: collapse;
table-layout: fixed;
th,
td {
padding: 12px;
border-right: 1px solid rgba(77, 167, 220, 0.24);
border-bottom: 1px solid rgba(77, 167, 220, 0.24);
vertical-align: middle;
}
th {
color: #ecfbff;
background: linear-gradient(100deg, #158fc4, #08739f);
font-size: 15px;
font-weight: 800;
}
td:nth-child(1) { width: 18%; }
td:nth-child(2) { width: 15%; }
td:nth-child(n + 3) { width: 22.33%; }
tr:last-child td { border-bottom: 0; }
th:last-child,
td:last-child { border-right: 0; }
}
.market-segment-input,
.market-segment-textarea,
.market-selection-textarea {
box-sizing: border-box;
width: 100%;
border: 1px solid rgba(67, 134, 166, 0.8);
outline: none;
color: #eaf7ff;
background: rgba(7, 29, 43, 0.92);
font-family: inherit;
line-height: 1.6;
&::placeholder { color: #7896a5; }
&:focus {
border-color: #5fe7ff;
box-shadow: 0 0 0 3px rgba(34, 207, 255, 0.14);
}
}
.market-segment-input {
height: 40px;
padding: 0 10px;
border-radius: 10px;
}
.market-segment-textarea {
min-height: 108px;
padding: 10px;
border-radius: 10px;
resize: vertical;
}
.market-attractiveness {
display: flex;
justify-content: center;
gap: 8px;
}
.market-attractiveness :deep(.el-radio) {
margin-right: 0;
color: #d9f2ff;
}
.market-selection-block {
margin-top: 34px;
.section-eyebrow { margin-bottom: 8px; }
h4 {
margin: 0 0 14px;
color: #dcefff;
font-size: 16px;
font-weight: 600;
line-height: 1.7;
}
}
.market-selection-textarea {
min-height: 180px;
padding: 14px;
border-radius: 14px;
resize: vertical;
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.target-profile-page { .target-profile-page {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@ -2102,6 +2315,11 @@ function exportOutcome() {
.product-reference { .product-reference {
justify-content: flex-start; justify-content: flex-start;
} }
.market-segment-toolbar {
align-items: flex-start;
flex-direction: column;
}
} }
@media (max-width: 900px) { @media (max-width: 900px) {
@ -2166,6 +2384,10 @@ function exportOutcome() {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.market-segment-toolbar__actions {
width: 100%;
}
.interview-tabs { .interview-tabs {
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;

Loading…
Cancel
Save