feat: 数据概览简化 + 用户管理增加余额/提现列
- 数据概览:去掉代付统计独立卡片,总收入中以小标签显示代付金额 - 数据概览:移除余额统计区块(余额改在用户管理中展示) - 数据概览:恢复转化率卡片(唯一付费用户/总用户) - 用户管理:用户列表新增「余额/提现」列,显示钱包余额和已提现金额 - 后端:DBUsersList 增加 user_balances 查询,返回 walletBalance 字段 - 后端:User model 添加 WalletBalance 非数据库字段 - 包含之前的小程序埋点和管理后台点击统计面板 Made-with: Cursor
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Users, Eye, ShoppingBag, TrendingUp, RefreshCw, ChevronRight } from 'lucide-react'
|
||||
import { Users, ShoppingBag, TrendingUp, RefreshCw, ChevronRight, BarChart3 } from 'lucide-react'
|
||||
import { get } from '@/api/client'
|
||||
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
|
||||
|
||||
@@ -71,11 +71,19 @@ export function DashboardPage() {
|
||||
const [totalUsersCount, setTotalUsersCount] = useState(0)
|
||||
const [paidOrderCount, setPaidOrderCount] = useState(0)
|
||||
const [totalRevenue, setTotalRevenue] = useState(0)
|
||||
const [todayClicks, setTodayClicks] = useState(0)
|
||||
const [conversionRate, setConversionRate] = useState(0)
|
||||
const [giftedTotal, setGiftedTotal] = useState(0)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [detailUserId, setDetailUserId] = useState<string | null>(null)
|
||||
const [showDetailModal, setShowDetailModal] = useState(false)
|
||||
|
||||
const [trackPeriod, setTrackPeriod] = useState<string>('today')
|
||||
const [trackStats, setTrackStats] = useState<{
|
||||
total: number
|
||||
byModule: Record<string, { action: string; target: string; module: string; page: string; count: number }[]>
|
||||
} | null>(null)
|
||||
const [trackLoading, setTrackLoading] = useState(false)
|
||||
|
||||
const showError = (err: unknown) => {
|
||||
const e = err as Error & { status?: number; name?: string }
|
||||
if (e?.status === 401) setLoadError('登录已过期,请重新登录')
|
||||
@@ -95,6 +103,7 @@ export function DashboardPage() {
|
||||
setTotalUsersCount(stats.totalUsers ?? 0)
|
||||
setPaidOrderCount(stats.paidOrderCount ?? 0)
|
||||
setTotalRevenue(stats.totalRevenue ?? 0)
|
||||
setConversionRate(stats.conversionRate ?? 0)
|
||||
}
|
||||
} catch (e) {
|
||||
if ((e as Error)?.name !== 'AbortError') {
|
||||
@@ -105,6 +114,7 @@ export function DashboardPage() {
|
||||
setTotalUsersCount(overview.totalUsers ?? 0)
|
||||
setPaidOrderCount(overview.paidOrderCount ?? 0)
|
||||
setTotalRevenue(overview.totalRevenue ?? 0)
|
||||
setConversionRate(overview.conversionRate ?? 0)
|
||||
}
|
||||
} catch (e2) {
|
||||
showError(e2)
|
||||
@@ -114,14 +124,14 @@ export function DashboardPage() {
|
||||
setStatsLoading(false)
|
||||
}
|
||||
|
||||
// 加载今日点击(从推广中心接口)
|
||||
// 加载代付总额(仅用于收入标签展示)
|
||||
try {
|
||||
const distOverview = await get<{ success?: boolean; todayClicks?: number }>('/api/admin/distribution/overview', init)
|
||||
if (distOverview?.success) {
|
||||
setTodayClicks(distOverview.todayClicks ?? 0)
|
||||
const balRes = await get<{ success?: boolean; data?: { totalGifted?: number } }>('/api/admin/balance/summary', init)
|
||||
if (balRes?.success && balRes.data) {
|
||||
setGiftedTotal(balRes.data.totalGifted ?? 0)
|
||||
}
|
||||
} catch {
|
||||
// 推广数据加载失败不影响主面板
|
||||
// 不影响主面板
|
||||
}
|
||||
|
||||
// 2. 并行加载订单和用户
|
||||
@@ -174,10 +184,28 @@ export function DashboardPage() {
|
||||
await Promise.all([loadOrders(), loadUsers()])
|
||||
}
|
||||
|
||||
async function loadTrackStats(period?: string) {
|
||||
const p = period || trackPeriod
|
||||
setTrackLoading(true)
|
||||
try {
|
||||
const res = await get<{ success?: boolean; total?: number; byModule?: Record<string, { action: string; target: string; module: string; page: string; count: number }[]> }>(
|
||||
`/api/admin/track/stats?period=${p}`
|
||||
)
|
||||
if (res?.success) {
|
||||
setTrackStats({ total: res.total ?? 0, byModule: res.byModule ?? {} })
|
||||
}
|
||||
} catch {
|
||||
setTrackStats(null)
|
||||
} finally {
|
||||
setTrackLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const ctrl = new AbortController()
|
||||
loadAll(ctrl.signal)
|
||||
const timer = setInterval(() => loadAll(), 30000)
|
||||
loadTrackStats()
|
||||
const timer = setInterval(() => { loadAll(); loadTrackStats() }, 30000)
|
||||
return () => {
|
||||
ctrl.abort()
|
||||
clearInterval(timer)
|
||||
@@ -223,6 +251,7 @@ export function DashboardPage() {
|
||||
{
|
||||
title: '总用户数',
|
||||
value: statsLoading ? null : totalUsers,
|
||||
sub: null as string | null,
|
||||
icon: Users,
|
||||
color: 'text-blue-400',
|
||||
bg: 'bg-blue-500/20',
|
||||
@@ -231,6 +260,7 @@ export function DashboardPage() {
|
||||
{
|
||||
title: '总收入',
|
||||
value: statsLoading ? null : `¥${(totalRevenue ?? 0).toFixed(2)}`,
|
||||
sub: giftedTotal > 0 ? `含代付 ¥${giftedTotal.toFixed(2)}` : null,
|
||||
icon: TrendingUp,
|
||||
color: 'text-[#38bdac]',
|
||||
bg: 'bg-[#38bdac]/20',
|
||||
@@ -239,18 +269,20 @@ export function DashboardPage() {
|
||||
{
|
||||
title: '订单数',
|
||||
value: statsLoading ? null : paidOrderCount,
|
||||
sub: null as string | null,
|
||||
icon: ShoppingBag,
|
||||
color: 'text-purple-400',
|
||||
bg: 'bg-purple-500/20',
|
||||
link: '/orders',
|
||||
},
|
||||
{
|
||||
title: '今日点击',
|
||||
value: statsLoading ? null : todayClicks,
|
||||
icon: Eye,
|
||||
color: 'text-blue-400',
|
||||
bg: 'bg-blue-500/20',
|
||||
link: '/distribution',
|
||||
title: '转化率',
|
||||
value: statsLoading ? null : `${conversionRate.toFixed(1)}%`,
|
||||
sub: null as string | null,
|
||||
icon: TrendingUp,
|
||||
color: 'text-amber-400',
|
||||
bg: 'bg-amber-500/20',
|
||||
link: '/users',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -269,7 +301,7 @@ export function DashboardPage() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
{stats.map((stat, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
@@ -284,14 +316,19 @@ export function DashboardPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-2xl font-bold text-white min-h-[2rem] flex items-center">
|
||||
{stat.value != null ? (
|
||||
stat.value
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-2 text-gray-500">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
加载中
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-white min-h-8 flex items-center">
|
||||
{stat.value != null ? (
|
||||
stat.value
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-2 text-gray-500">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
加载中
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{stat.sub && (
|
||||
<p className="text-xs text-gray-500 mt-1">{stat.sub}</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-gray-600 group-hover:text-[#38bdac] transition-colors" />
|
||||
@@ -480,6 +517,95 @@ export function DashboardPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 分类标签点击统计 */}
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mt-8">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-[#38bdac]" />
|
||||
分类标签点击统计
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{(['today', 'week', 'month', 'all'] as const).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => { setTrackPeriod(p); loadTrackStats(p) }}
|
||||
className={`px-3 py-1 text-xs rounded-full transition-colors ${
|
||||
trackPeriod === p
|
||||
? 'bg-[#38bdac] text-white'
|
||||
: 'bg-gray-700/50 text-gray-400 hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{{ today: '今日', week: '本周', month: '本月', all: '全部' }[p]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{trackLoading && !trackStats ? (
|
||||
<div className="flex items-center justify-center py-12 text-gray-500">
|
||||
<RefreshCw className="w-6 h-6 animate-spin mr-2" />
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
) : trackStats && Object.keys(trackStats.byModule).length > 0 ? (
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-gray-400">
|
||||
总点击 <span className="text-white font-bold text-lg">{trackStats.total}</span> 次
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Object.entries(trackStats.byModule)
|
||||
.sort((a, b) => b[1].reduce((s, i) => s + i.count, 0) - a[1].reduce((s, i) => s + i.count, 0))
|
||||
.map(([mod, items]) => {
|
||||
const moduleTotal = items.reduce((s, i) => s + i.count, 0)
|
||||
const moduleLabels: Record<string, string> = {
|
||||
home: '首页', chapters: '目录', read: '阅读', my: '我的',
|
||||
vip: 'VIP', wallet: '钱包', match: '找伙伴', referral: '推广',
|
||||
search: '搜索', settings: '设置', about: '关于', other: '其他',
|
||||
}
|
||||
return (
|
||||
<div key={mod} className="bg-[#0a1628] rounded-lg border border-gray-700/30 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-[#38bdac]">
|
||||
{moduleLabels[mod] || mod}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">{moduleTotal} 次</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{items
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 8)
|
||||
.map((item, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-300 truncate mr-2" title={`${item.action}: ${item.target}`}>
|
||||
{item.target || item.action}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="w-16 h-1.5 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[#38bdac] rounded-full"
|
||||
style={{ width: `${moduleTotal > 0 ? (item.count / moduleTotal) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-gray-400 w-8 text-right">{item.count}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<BarChart3 className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-gray-500">暂无点击数据</p>
|
||||
<p className="text-gray-600 text-xs mt-1">小程序端接入埋点后,数据将在此实时展示</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<UserDetailModal
|
||||
open={showDetailModal}
|
||||
onClose={() => { setShowDetailModal(false); setDetailUserId(null) }}
|
||||
|
||||
@@ -44,6 +44,8 @@ import {
|
||||
ChevronUp,
|
||||
Crown,
|
||||
Tag,
|
||||
Star,
|
||||
Info,
|
||||
} from 'lucide-react'
|
||||
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
|
||||
import { Pagination } from '@/components/ui/Pagination'
|
||||
@@ -64,10 +66,10 @@ interface User {
|
||||
earnings: number | string
|
||||
pendingEarnings?: number | string
|
||||
withdrawnEarnings?: number | string
|
||||
walletBalance?: number | string
|
||||
referralCount?: number
|
||||
createdAt: string
|
||||
updatedAt?: string | null
|
||||
// RFM(排序模式时有值)
|
||||
rfmScore?: number
|
||||
rfmLevel?: string
|
||||
}
|
||||
@@ -155,6 +157,12 @@ export function UsersPage() {
|
||||
// ===== 用户旅程总览 =====
|
||||
const [journeyStats, setJourneyStats] = useState<Record<string, number>>({})
|
||||
const [journeyLoading, setJourneyLoading] = useState(false)
|
||||
const [journeySelectedStage, setJourneySelectedStage] = useState<string | null>(null)
|
||||
const [journeyUsers, setJourneyUsers] = useState<{ id: string; nickname: string; phone?: string; createdAt?: string }[]>([])
|
||||
const [journeyUsersLoading, setJourneyUsersLoading] = useState(false)
|
||||
|
||||
// RFM 算法说明
|
||||
const [showRfmInfo, setShowRfmInfo] = useState(false)
|
||||
|
||||
// ===== 用户列表 =====
|
||||
async function loadUsers(fromRefresh = false) {
|
||||
@@ -488,6 +496,41 @@ export function UsersPage() {
|
||||
} catch { } finally { setJourneyLoading(false) }
|
||||
}, [])
|
||||
|
||||
async function loadJourneyUsers(stageId: string) {
|
||||
setJourneySelectedStage(stageId)
|
||||
setJourneyUsersLoading(true)
|
||||
try {
|
||||
const data = await get<{ success?: boolean; users?: { id: string; nickname: string; phone?: string; createdAt?: string }[] }>(
|
||||
`/api/db/users/journey-users?stage=${encodeURIComponent(stageId)}&limit=20`
|
||||
)
|
||||
if (data?.success && data.users) setJourneyUsers(data.users)
|
||||
else setJourneyUsers([])
|
||||
} catch {
|
||||
setJourneyUsers([])
|
||||
} finally {
|
||||
setJourneyUsersLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetSuperMember(user: User) {
|
||||
if (!user.hasFullBook) {
|
||||
toast.error('仅 VIP 用户可置顶到超级个体')
|
||||
return
|
||||
}
|
||||
if (!confirm('确定将该用户置顶到首页超级个体位?(最多4位)')) return
|
||||
try {
|
||||
const res = await put<{ success?: boolean; error?: string }>('/api/db/users', { id: user.id, vipSort: 1 })
|
||||
if (!res?.success) {
|
||||
toast.error(res?.error || '置顶失败')
|
||||
return
|
||||
}
|
||||
toast.success('已置顶到超级个体')
|
||||
loadUsers()
|
||||
} catch {
|
||||
toast.error('置顶失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 w-full">
|
||||
{error && (
|
||||
@@ -499,7 +542,12 @@ export function UsersPage() {
|
||||
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white">用户管理</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-2xl font-bold text-white">用户管理</h2>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowRfmInfo(true)} className="text-gray-500 hover:text-[#38bdac] h-8 w-8 p-0" title="RFM 算法说明">
|
||||
<Info className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-gray-400 mt-1 text-sm">共 {total} 位注册用户{rfmSortMode && ' · RFM 排序中'}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -576,6 +624,7 @@ export function UsersPage() {
|
||||
<TableHead className="text-gray-400">绑定信息</TableHead>
|
||||
<TableHead className="text-gray-400">购买状态</TableHead>
|
||||
<TableHead className="text-gray-400">分销收益</TableHead>
|
||||
<TableHead className="text-gray-400">余额/提现</TableHead>
|
||||
<TableHead className="text-gray-400 cursor-pointer select-none" onClick={toggleRfmSort}>
|
||||
<div className="flex items-center gap-1 group">
|
||||
<TrendingUp className="w-3.5 h-3.5" />
|
||||
@@ -650,6 +699,14 @@ export function UsersPage() {
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="text-white font-medium">¥{parseFloat(String(user.walletBalance || 0)).toFixed(2)}</div>
|
||||
{parseFloat(String(user.withdrawnEarnings || 0)) > 0 && (
|
||||
<div className="text-xs text-gray-400">已提现: ¥{parseFloat(String(user.withdrawnEarnings || 0)).toFixed(2)}</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* RFM 分值列 */}
|
||||
<TableCell>
|
||||
{user.rfmScore !== undefined ? (
|
||||
@@ -669,6 +726,9 @@ export function UsersPage() {
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => { setSelectedUserIdForDetail(user.id); setShowDetailModal(true) }} className="text-gray-400 hover:text-blue-400 hover:bg-blue-400/10" title="用户详情"><Eye className="w-4 h-4" /></Button>
|
||||
{user.hasFullBook && (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleSetSuperMember(user)} className="text-gray-400 hover:text-orange-400 hover:bg-orange-400/10" title="置顶超级个体"><Star className="w-4 h-4" /></Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEditUser(user)} className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10" title="编辑用户"><Edit3 className="w-4 h-4" /></Button>
|
||||
<Button variant="ghost" size="sm" className="text-red-400 hover:text-red-300 hover:bg-red-500/10" onClick={() => handleDelete(user.id)} title="删除"><Trash2 className="w-4 h-4" /></Button>
|
||||
</div>
|
||||
@@ -706,7 +766,13 @@ export function UsersPage() {
|
||||
{JOURNEY_STAGES.map((stage, idx) => (
|
||||
<div key={stage.id} className="relative flex flex-col items-center">
|
||||
{/* 阶段卡片 */}
|
||||
<div className={`relative w-full p-3 rounded-xl border ${stage.color} text-center cursor-default`}>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`relative w-full p-3 rounded-xl border ${stage.color} text-center cursor-pointer`}
|
||||
onClick={() => loadJourneyUsers(stage.id)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && loadJourneyUsers(stage.id)}
|
||||
>
|
||||
<div className="text-2xl mb-1">{stage.icon}</div>
|
||||
<div className={`text-xs font-medium ${stage.color.split(' ').find(c => c.startsWith('text-'))}`}>{stage.label}</div>
|
||||
{journeyStats[stage.id] !== undefined && (
|
||||
@@ -732,6 +798,44 @@ export function UsersPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 选中阶段的用户列表 */}
|
||||
{journeySelectedStage && (
|
||||
<div className="mb-6 bg-[#0f2137] border border-gray-700/50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-white font-medium">
|
||||
{JOURNEY_STAGES.find((s) => s.id === journeySelectedStage)?.label} — 用户列表
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => setJourneySelectedStage(null)} className="text-gray-500 hover:text-white">
|
||||
<X className="w-4 h-4" /> 关闭
|
||||
</Button>
|
||||
</div>
|
||||
{journeyUsersLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="w-5 h-5 text-[#38bdac] animate-spin" />
|
||||
</div>
|
||||
) : journeyUsers.length > 0 ? (
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{journeyUsers.map((u) => (
|
||||
<div
|
||||
key={u.id}
|
||||
className="flex items-center justify-between py-2 px-3 bg-[#0a1628] rounded-lg hover:bg-[#0a1628]/80 cursor-pointer"
|
||||
onClick={() => { setSelectedUserIdForDetail(u.id); setShowDetailModal(true) }}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (setSelectedUserIdForDetail(u.id), setShowDetailModal(true))}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="text-white font-medium">{u.nickname}</span>
|
||||
<span className="text-gray-400 text-sm">{u.phone || '—'}</span>
|
||||
<span className="text-gray-500 text-xs">{u.createdAt ? new Date(u.createdAt).toLocaleDateString() : '—'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm py-4">暂无用户</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 旅程说明 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-[#0f2137] border border-gray-700/50 rounded-lg p-4">
|
||||
@@ -1125,6 +1229,28 @@ export function UsersPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* RFM 算法说明 */}
|
||||
<Dialog open={showRfmInfo} onOpenChange={setShowRfmInfo}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-[#38bdac]" />
|
||||
RFM 算法说明
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-4 text-sm text-gray-300">
|
||||
<p><span className="text-[#38bdac] font-medium">R(Recency)</span>:距最近购买天数,越近分越高,权重 40%</p>
|
||||
<p><span className="text-[#38bdac] font-medium">F(Frequency)</span>:购买频次,越多分越高,权重 30%</p>
|
||||
<p><span className="text-[#38bdac] font-medium">M(Monetary)</span>:消费金额,越高分越高,权重 30%</p>
|
||||
<p className="text-gray-400">综合分 = R×40% + F×30% + M×30%,归一化到 0-100</p>
|
||||
<p className="text-gray-400">等级:<span className="text-amber-400">S</span>(≥85)、<span className="text-green-400">A</span>(≥70)、<span className="text-blue-400">B</span>(≥50)、<span className="text-gray-400">C</span>(≥30)、<span className="text-red-400">D</span>(<30)</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowRfmInfo(false)} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">关闭</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<UserDetailModal open={showDetailModal} onClose={() => setShowDetailModal(false)} userId={selectedUserIdForDetail} onUserUpdated={loadUsers} />
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user