feat: 数据概览简化 + 用户管理增加余额/提现列

- 数据概览:去掉代付统计独立卡片,总收入中以小标签显示代付金额
- 数据概览:移除余额统计区块(余额改在用户管理中展示)
- 数据概览:恢复转化率卡片(唯一付费用户/总用户)
- 用户管理:用户列表新增「余额/提现」列,显示钱包余额和已提现金额
- 后端:DBUsersList 增加 user_balances 查询,返回 walletBalance 字段
- 后端:User model 添加 WalletBalance 非数据库字段
- 包含之前的小程序埋点和管理后台点击统计面板

Made-with: Cursor
This commit is contained in:
卡若
2026-03-15 15:57:09 +08:00
parent 991e17698c
commit 708547d0dd
52 changed files with 3161 additions and 1103 deletions

View File

@@ -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) }}

View File

@@ -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">RRecency</span> 40%</p>
<p><span className="text-[#38bdac] font-medium">FFrequency</span> 30%</p>
<p><span className="text-[#38bdac] font-medium">MMonetary</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>&lt;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>
)