Files
soul-yongping/soul-admin/src/pages/dashboard/DashboardPage.tsx

386 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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