Spaces:
Sleeping
Sleeping
| <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> | |