Trae Assistant
Enhance: Support CSV/Excel upload, add Chinese localization, fix bugs
f23dcfb
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RFM 客户价值分群引擎 (RFM Segmentation Engine)</title>
<!-- Element Plus & Vue -->
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css" />
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/element-plus"></script>
<script src="https://unpkg.com/@element-plus/icons-vue"></script>
<!-- ECharts -->
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<!-- PapaParse -->
<script src="https://unpkg.com/papaparse@5.4.1/papaparse.min.js"></script>
<!-- Tailwind (Utility) -->
<script src="https://cdn.tailwindcss.com"></script>
<style>
[v-cloak] { display: none; }
body { font-family: 'Inter', 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif; background-color: #f5f7fa; }
.chart-container { height: 400px; background: #fff; padding: 16px; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05); }
.kpi-card { background: #fff; border-radius: 8px; padding: 20px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05); text-align: center; }
.kpi-value { font-size: 24px; font-weight: bold; color: #409EFF; margin-top: 10px; }
.kpi-label { color: #909399; font-size: 14px; }
</style>
</head>
<body>
<div id="app" class="p-6" v-cloak>
<!-- Header -->
<div class="mb-8 flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-gray-800">RFM 客户价值分群引擎</h1>
<p class="text-gray-500 mt-2">基于 Recency(近度), Frequency(频度), Monetary(额度) 的智能客户分层系统</p>
</div>
<div class="space-x-4">
<el-button type="primary" @click="loadDemoData" :loading="loading">加载演示数据</el-button>
<el-upload
action="#"
:auto-upload="false"
:show-file-list="false"
:on-change="handleFileUpload"
accept=".csv,.xlsx,.xls"
style="display: inline-block"
>
<el-button type="success">导入数据 (CSV/Excel)</el-button>
</el-upload>
<el-button type="warning" @click="exportData" :disabled="!hasData">导出结果</el-button>
</div>
</div>
<!-- KPIs -->
<el-row :gutter="20" class="mb-6">
<el-col :span="8">
<div class="kpi-card">
<div class="kpi-label">分析客户总数 (Total Customers)</div>
<div class="kpi-value">${ summary.total_customers || 0 }</div>
</div>
</el-col>
<el-col :span="8">
<div class="kpi-card">
<div class="kpi-label">总营收 (Total Revenue)</div>
<div class="kpi-value">¥${ formatMoney(summary.total_revenue) }</div>
</div>
</el-col>
<el-col :span="8">
<div class="kpi-card">
<div class="kpi-label">客单价 (Average Order Value)</div>
<div class="kpi-value">¥${ formatMoney(summary.avg_order_value) }</div>
</div>
</el-col>
</el-row>
<!-- Charts Row 1 -->
<el-row :gutter="20" class="mb-6">
<el-col :span="14">
<div class="chart-container" id="scatterChart"></div>
</el-col>
<el-col :span="10">
<div class="chart-container" id="pieChart"></div>
</el-col>
</el-row>
<!-- Charts Row 2 -->
<el-row :gutter="20" class="mb-6">
<el-col :span="24">
<div class="chart-container" id="barChart"></div>
</el-col>
</el-row>
<!-- Data Table -->
<div class="bg-white rounded-lg shadow p-4">
<h3 class="text-lg font-bold mb-4">客户分层详情 (Top 100 by Value)</h3>
<el-table :data="tableData" stripe style="width: 100%" height="400">
<el-table-column prop="CustomerID" label="客户 ID" width="120"></el-table-column>
<el-table-column prop="Segment" label="所属人群" width="180">
<template #default="scope">
<el-tag :type="getSegmentTagType(scope.row.Segment)">${ scope.row.Segment }</el-tag>
</template>
</el-table-column>
<el-table-column prop="Recency" label="R (未购天数)" sortable></el-table-column>
<el-table-column prop="Frequency" label="F (购买次数)" sortable></el-table-column>
<el-table-column prop="Monetary" label="M (消费总额)" sortable>
<template #default="scope">¥${ formatMoney(scope.row.Monetary) }</template>
</el-table-column>
<el-table-column prop="RFM_Score" label="RFM Score" sortable></el-table-column>
</el-table>
</div>
<!-- Help Dialog -->
<el-dialog v-model="showHelp" title="如何使用导入功能" width="50%">
<p>请上传包含以下列头的 CSV 或 Excel (.xlsx) 文件:</p>
<ul class="list-disc pl-6 mt-2 mb-4">
<li><b>CustomerID (客户ID)</b>: 客户唯一标识</li>
<li><b>OrderDate (订单日期)</b>: 订单日期 (YYYY-MM-DD)</li>
<li><b>Amount (金额)</b>: 订单金额</li>
</ul>
<p class="text-gray-500">支持列名:[CustomerID, 客户ID], [OrderDate, 订单日期], [Amount, 金额]。系统将自动合并同一客户的多条订单进行计算。</p>
</el-dialog>
</div>
<script>
const { createApp, ref, onMounted, reactive } = Vue;
createApp({
delimiters: ['${', '}'],
setup() {
const loading = ref(false);
const showHelp = ref(false);
const hasData = ref(false);
const summary = reactive({ total_customers: 0, total_revenue: 0, avg_order_value: 0 });
const tableData = ref([]);
let scatterChart = null;
let pieChart = null;
let barChart = null;
const formatMoney = (val) => {
return val ? val.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '0.00';
};
const getSegmentTagType = (segment) => {
if (segment.includes('Champions')) return 'success';
if (segment.includes('Loyal')) return '';
if (segment.includes('Risk')) return 'danger';
if (segment.includes('Hibernating')) return 'info';
if (segment.includes('Potential')) return 'warning';
return 'info';
};
const initCharts = () => {
scatterChart = echarts.init(document.getElementById('scatterChart'));
pieChart = echarts.init(document.getElementById('pieChart'));
barChart = echarts.init(document.getElementById('barChart'));
window.addEventListener('resize', () => {
scatterChart.resize();
pieChart.resize();
barChart.resize();
});
};
const updateCharts = (data) => {
// Scatter: R vs F (Size = M)
scatterChart.setOption({
title: { text: '客户分布矩阵 (R vs F)', left: 'center' },
tooltip: {
formatter: (params) => {
const d = params.data;
return `${params.seriesName}<br/>ID: ${d[3]}<br/>R: ${d[0]} days<br/>F: ${d[1]} times<br/>M: ¥${d[2]}`;
}
},
xAxis: { name: 'Recency (Days)', type: 'value', nameLocation: 'middle', nameGap: 30 },
yAxis: { name: 'Frequency (Count)', type: 'value' },
legend: { bottom: 0 },
series: data.scatter_series.map(s => ({
name: s.name,
type: 'scatter',
symbolSize: (val) => {
return Math.min(Math.max(Math.log(val[2]) * 3, 5), 30);
},
data: s.data,
itemStyle: { opacity: 0.7 }
}))
});
pieChart.setOption({
title: { text: '人群占比 (Segments)', left: 'center' },
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
series: [{
name: 'Segment',
type: 'pie',
radius: '50%',
data: data.segments_pie,
emphasis: {
itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0, 0, 0, 0.5)' }
}
}]
});
barChart.setOption({
title: { text: '各人群贡献营收 (Revenue by Segment)', left: 'center' },
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: data.segments_bar.map(i => i.name), axisLabel: { interval: 0, rotate: 30 } },
yAxis: { type: 'value' },
series: [{
data: data.segments_bar.map(i => i.value),
type: 'bar',
itemStyle: { color: '#409EFF' }
}]
});
};
// Process result data from backend
const processResult = (result) => {
// Update State
summary.total_customers = result.summary.total_customers;
summary.total_revenue = result.summary.total_revenue;
summary.avg_order_value = result.summary.avg_order_value;
tableData.value = result.table_data;
updateCharts(result);
hasData.value = true;
ElementPlus.ElMessage.success('分析完成');
};
const analyzeData = async (jsonData) => {
loading.value = true;
try {
const res = await fetch('/api/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(jsonData)
});
const result = await res.json();
if (result.error) {
ElementPlus.ElMessage.error(result.error);
return;
}
processResult(result);
} catch (e) {
ElementPlus.ElMessage.error('分析失败: ' + e.message);
} finally {
loading.value = false;
}
};
const loadDemoData = async () => {
loading.value = true;
try {
const res = await fetch('/api/demo-data');
const data = await res.json();
await analyzeData(data);
} catch (e) {
ElementPlus.ElMessage.error('加载演示数据失败');
} finally {
loading.value = false;
}
};
const handleFileUpload = async (file) => {
loading.value = true;
const formData = new FormData();
formData.append('file', file.raw);
try {
const res = await fetch('/api/upload', {
method: 'POST',
body: formData
});
const result = await res.json();
if (result.error) {
ElementPlus.ElMessage.error(result.error);
if (result.error.includes("缺少必要列")) {
showHelp.value = true;
}
return;
}
processResult(result);
} catch (e) {
ElementPlus.ElMessage.error('上传失败: ' + e.message);
} finally {
loading.value = false;
}
};
// Define triggerUpload as a no-op or alias if user code expects it,
// but strictly speaking we use handleFileUpload via Element UI.
const triggerUpload = () => {
// Placeholder in case of external call, but normally not needed with ElUpload
console.log("Upload triggered");
};
const exportData = () => {
// Export tableData to CSV
const csv = Papa.unparse(tableData.value);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'rfm_segments.csv';
link.click();
};
onMounted(() => {
initCharts();
// Auto load demo data for better UX
loadDemoData();
});
return {
loading,
showHelp,
hasData,
summary,
tableData,
loadDemoData,
handleFileUpload,
triggerUpload, // Return this just in case
exportData,
formatMoney,
getSegmentTagType
};
}
}).use(ElementPlus).mount('#app');
</script>
</body>
</html>