386 lines
14 KiB
TypeScript
386 lines
14 KiB
TypeScript
import { useState, useEffect } from 'react'
|
||
import { useNavigate } from 'react-router-dom'
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||
import { Users, BookOpen, ShoppingBag, TrendingUp, RefreshCw, ChevronRight } from 'lucide-react'
|
||
import { get } from '@/api/client'
|
||
|
||
interface OrderRow {
|
||
id: string
|
||
amount?: number
|
||
status?: string
|
||
productType?: string
|
||
productId?: string
|
||
description?: string
|
||
userId?: string
|
||
userNickname?: string
|
||
userAvatar?: string
|
||
referrerId?: string
|
||
referralCode?: string
|
||
createdAt?: string
|
||
paymentMethod?: string
|
||
}
|
||
|
||
interface UserRow {
|
||
id: string
|
||
nickname?: string
|
||
phone?: string
|
||
referralCode?: string
|
||
createdAt?: string
|
||
}
|
||
|
||
interface DashboardOverviewRes {
|
||
success?: boolean
|
||
totalUsers?: number
|
||
paidOrderCount?: number
|
||
paidUserCount?: number
|
||
totalRevenue?: number
|
||
conversionRate?: number
|
||
recentOrders?: OrderRow[]
|
||
newUsers?: UserRow[]
|
||
}
|
||
|
||
interface UsersRes {
|
||
success?: boolean
|
||
users?: UserRow[]
|
||
total?: number
|
||
}
|
||
|
||
interface OrdersRes {
|
||
success?: boolean
|
||
orders?: OrderRow[]
|
||
total?: number
|
||
}
|
||
|
||
export function DashboardPage() {
|
||
const navigate = useNavigate()
|
||
const [isLoading, setIsLoading] = useState(true)
|
||
const [users, setUsers] = useState<UserRow[]>([])
|
||
const [purchases, setPurchases] = useState<OrderRow[]>([])
|
||
const [totalUsersCount, setTotalUsersCount] = useState(0)
|
||
const [paidOrderCount, setPaidOrderCount] = useState(0)
|
||
const [totalRevenue, setTotalRevenue] = useState(0)
|
||
const [conversionRate, setConversionRate] = useState(0)
|
||
const [loadError, setLoadError] = useState<string | null>(null)
|
||
|
||
async function loadData() {
|
||
setIsLoading(true)
|
||
setLoadError(null)
|
||
try {
|
||
const data = await get<DashboardOverviewRes>('/api/admin/dashboard/overview')
|
||
if (data?.success) {
|
||
setTotalUsersCount(data.totalUsers ?? 0)
|
||
setPaidOrderCount(data.paidOrderCount ?? 0)
|
||
setTotalRevenue(data.totalRevenue ?? 0)
|
||
setConversionRate(data.conversionRate ?? 0)
|
||
setPurchases(data.recentOrders ?? [])
|
||
setUsers(data.newUsers ?? [])
|
||
return
|
||
}
|
||
} catch (e) {
|
||
console.error('数据概览接口失败,尝试降级拉取', e)
|
||
}
|
||
// 降级:新接口未部署或失败时,用原有接口拉取用户与订单
|
||
try {
|
||
const [usersData, ordersData] = await Promise.all([
|
||
get<UsersRes>('/api/db/users?page=1&pageSize=10'),
|
||
get<OrdersRes>('/api/orders?page=1&pageSize=20&status=paid'),
|
||
])
|
||
const totalUsers = typeof usersData?.total === 'number' ? usersData.total : (usersData?.users?.length ?? 0)
|
||
const orders = ordersData?.orders ?? []
|
||
const total = typeof ordersData?.total === 'number' ? ordersData.total : orders.length
|
||
const paidOrders = orders.filter((p) => p.status === 'paid' || p.status === 'completed' || p.status === 'success')
|
||
const revenue = paidOrders.reduce((sum, p) => sum + Number(p.amount || 0), 0)
|
||
const paidUserIds = new Set(paidOrders.map((p) => p.userId).filter(Boolean))
|
||
const rate = totalUsers > 0 && paidUserIds.size > 0 ? (paidUserIds.size / totalUsers) * 100 : 0
|
||
setTotalUsersCount(totalUsers)
|
||
setPaidOrderCount(total)
|
||
setTotalRevenue(revenue)
|
||
setConversionRate(rate)
|
||
setPurchases(orders.slice(0, 5))
|
||
setUsers(usersData?.users ?? [])
|
||
} catch (fallbackErr) {
|
||
console.error('降级拉取失败', fallbackErr)
|
||
const err = fallbackErr as Error & { status?: number }
|
||
if (err?.status === 401) {
|
||
setLoadError('登录已过期,请重新登录')
|
||
} else {
|
||
setLoadError('加载失败,请检查网络或联系管理员')
|
||
}
|
||
} finally {
|
||
setIsLoading(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
loadData()
|
||
const timer = setInterval(loadData, 30000)
|
||
return () => clearInterval(timer)
|
||
}, [])
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="p-8 w-full">
|
||
<h1 className="text-2xl font-bold mb-8 text-white">数据概览</h1>
|
||
<div className="flex flex-col items-center justify-center py-24">
|
||
<RefreshCw className="w-12 h-12 text-[#38bdac] animate-spin mb-4" />
|
||
<span className="text-gray-400">加载中...</span>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const totalUsers = totalUsersCount
|
||
|
||
const formatOrderProduct = (p: OrderRow) => {
|
||
const type = p.productType || ''
|
||
const desc = p.description || ''
|
||
if (desc) {
|
||
if (type === 'section' && desc.includes('章节')) {
|
||
if (desc.includes('-')) {
|
||
const parts = desc.split('-')
|
||
if (parts.length >= 3) {
|
||
return {
|
||
title: `第${parts[1]}章 第${parts[2]}节`,
|
||
subtitle: '《一场Soul的创业实验》',
|
||
}
|
||
}
|
||
}
|
||
return { title: desc, subtitle: '章节购买' }
|
||
}
|
||
if (type === 'fullbook' || desc.includes('全书')) {
|
||
return { title: '《一场Soul的创业实验》', subtitle: '全书购买' }
|
||
}
|
||
if (type === 'match' || desc.includes('伙伴')) {
|
||
return { title: '找伙伴匹配', subtitle: '功能服务' }
|
||
}
|
||
return {
|
||
title: desc,
|
||
subtitle: type === 'section' ? '单章' : type === 'fullbook' ? '全书' : '其他',
|
||
}
|
||
}
|
||
if (type === 'section') return { title: `章节 ${p.productId || ''}`, subtitle: '单章购买' }
|
||
if (type === 'fullbook') return { title: '《一场Soul的创业实验》', subtitle: '全书购买' }
|
||
if (type === 'match') return { title: '找伙伴匹配', subtitle: '功能服务' }
|
||
return { title: '未知商品', subtitle: type || '其他' }
|
||
}
|
||
|
||
const stats = [
|
||
{
|
||
title: '总用户数',
|
||
value: totalUsers,
|
||
icon: Users,
|
||
color: 'text-blue-400',
|
||
bg: 'bg-blue-500/20',
|
||
link: '/users',
|
||
},
|
||
{
|
||
title: '总收入',
|
||
value: `¥${(totalRevenue ?? 0).toFixed(2)}`,
|
||
icon: TrendingUp,
|
||
color: 'text-[#38bdac]',
|
||
bg: 'bg-[#38bdac]/20',
|
||
link: '/orders',
|
||
},
|
||
{
|
||
title: '订单数',
|
||
value: paidOrderCount,
|
||
icon: ShoppingBag,
|
||
color: 'text-purple-400',
|
||
bg: 'bg-purple-500/20',
|
||
link: '/orders',
|
||
},
|
||
{
|
||
title: '转化率',
|
||
value: `${typeof conversionRate === 'number' ? conversionRate.toFixed(1) : 0}%`,
|
||
icon: BookOpen,
|
||
color: 'text-orange-400',
|
||
bg: 'bg-orange-500/20',
|
||
link: '/distribution',
|
||
},
|
||
]
|
||
|
||
return (
|
||
<div className="p-8 w-full">
|
||
<h1 className="text-2xl font-bold mb-8 text-white">数据概览</h1>
|
||
{loadError && (
|
||
<div className="mb-6 px-4 py-3 rounded-lg bg-amber-500/20 border border-amber-500/50 text-amber-200 text-sm flex items-center justify-between">
|
||
<span>{loadError}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => loadData()}
|
||
className="text-amber-400 hover:text-amber-300 underline"
|
||
>
|
||
重试
|
||
</button>
|
||
</div>
|
||
)}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||
{stats.map((stat, index) => (
|
||
<Card
|
||
key={index}
|
||
className="bg-[#0f2137] border-gray-700/50 shadow-xl cursor-pointer hover:border-[#38bdac]/50 transition-colors group"
|
||
onClick={() => stat.link && navigate(stat.link)}
|
||
>
|
||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||
<CardTitle className="text-sm font-medium text-gray-400">{stat.title}</CardTitle>
|
||
<div className={`p-2 rounded-lg ${stat.bg}`}>
|
||
<stat.icon className={`w-4 h-4 ${stat.color}`} />
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="flex items-center justify-between">
|
||
<div className="text-2xl font-bold text-white">{stat.value}</div>
|
||
<ChevronRight className="w-5 h-5 text-gray-600 group-hover:text-[#38bdac] transition-colors" />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||
<CardHeader className="flex flex-row items-center justify-between">
|
||
<CardTitle className="text-white">最近订单</CardTitle>
|
||
<button
|
||
type="button"
|
||
onClick={() => loadData()}
|
||
className="text-xs text-gray-400 hover:text-[#38bdac] flex items-center gap-1"
|
||
title="刷新"
|
||
>
|
||
<RefreshCw className="w-3.5 h-3.5" />
|
||
刷新(每 30 秒自动更新)
|
||
</button>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-3">
|
||
{purchases
|
||
.slice(0, 5)
|
||
.map((p) => {
|
||
const referrer: UserRow | undefined = p.referrerId
|
||
? users.find((u) => u.id === p.referrerId)
|
||
: undefined
|
||
const inviteCode =
|
||
p.referralCode ||
|
||
referrer?.referralCode ||
|
||
referrer?.nickname ||
|
||
(p.referrerId ? String(p.referrerId).slice(0, 8) : '')
|
||
const product = formatOrderProduct(p)
|
||
const buyer =
|
||
p.userNickname ||
|
||
users.find((u) => u.id === p.userId)?.nickname ||
|
||
'匿名用户'
|
||
|
||
return (
|
||
<div
|
||
key={p.id}
|
||
className="flex items-start justify-between p-4 bg-[#0a1628] rounded-lg border border-gray-700/30 hover:border-[#38bdac]/30 transition-colors"
|
||
>
|
||
<div className="flex items-start gap-3 flex-1">
|
||
{p.userAvatar ? (
|
||
<img
|
||
src={p.userAvatar}
|
||
alt={buyer}
|
||
className="w-9 h-9 rounded-full object-cover flex-shrink-0 mt-0.5"
|
||
onError={(e) => {
|
||
e.currentTarget.style.display = 'none'
|
||
const next = e.currentTarget.nextElementSibling as HTMLElement
|
||
if (next) next.classList.remove('hidden')
|
||
}}
|
||
/>
|
||
) : null}
|
||
<div
|
||
className={`w-9 h-9 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac] flex-shrink-0 mt-0.5 ${p.userAvatar ? 'hidden' : ''}`}
|
||
>
|
||
{buyer.charAt(0)}
|
||
</div>
|
||
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<span className="text-sm text-gray-300">{buyer}</span>
|
||
<span className="text-gray-600">·</span>
|
||
<span className="text-sm font-medium text-white truncate">
|
||
{product.title}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||
<span className="px-1.5 py-0.5 bg-gray-700/50 rounded">
|
||
{product.subtitle}
|
||
</span>
|
||
<span>
|
||
{new Date(p.createdAt || 0).toLocaleString('zh-CN', {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})}
|
||
</span>
|
||
</div>
|
||
{inviteCode && (
|
||
<p className="text-xs text-gray-600 mt-1">推荐: {inviteCode}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="text-right ml-4 flex-shrink-0">
|
||
<p className="text-sm font-bold text-[#38bdac]">
|
||
+¥{Number(p.amount).toFixed(2)}
|
||
</p>
|
||
<p className="text-xs text-gray-500 mt-0.5">
|
||
{p.paymentMethod || '微信'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
{purchases.length === 0 && (
|
||
<div className="text-center py-12">
|
||
<ShoppingBag className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
||
<p className="text-gray-500">暂无订单数据</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||
<CardHeader>
|
||
<CardTitle className="text-white">新注册用户</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-3">
|
||
{users
|
||
.slice(0, 5)
|
||
.map((u) => (
|
||
<div
|
||
key={u.id}
|
||
className="flex items-center justify-between p-4 bg-[#0a1628] rounded-lg border border-gray-700/30"
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-10 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac]">
|
||
{u.nickname?.charAt(0) || '?'}
|
||
</div>
|
||
<div>
|
||
<p className="text-sm font-medium text-white">
|
||
{u.nickname || '匿名用户'}
|
||
</p>
|
||
<p className="text-xs text-gray-500">{u.phone || '-'}</p>
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-gray-400">
|
||
{u.createdAt
|
||
? new Date(u.createdAt).toLocaleDateString()
|
||
: '-'}
|
||
</p>
|
||
</div>
|
||
))}
|
||
{users.length === 0 && (
|
||
<p className="text-gray-500 text-center py-8">暂无用户数据</p>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|