292 lines
12 KiB
TypeScript
292 lines
12 KiB
TypeScript
"use client"
|
||
|
||
import { useState, useEffect } from "react"
|
||
import { useRouter } from "next/navigation"
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||
import { Users, BookOpen, ShoppingBag, TrendingUp, RefreshCw, ChevronRight } from "lucide-react"
|
||
|
||
export default function AdminDashboard() {
|
||
const router = useRouter()
|
||
const [mounted, setMounted] = useState(false)
|
||
const [users, setUsers] = useState<any[]>([])
|
||
const [purchases, setPurchases] = useState<any[]>([])
|
||
|
||
// 从API获取数据
|
||
async function loadData() {
|
||
try {
|
||
// 获取用户数据
|
||
const usersRes = await fetch('/api/db/users')
|
||
const usersData = await usersRes.json()
|
||
if (usersData.success && usersData.users) {
|
||
setUsers(usersData.users)
|
||
}
|
||
|
||
// 获取订单数据
|
||
const ordersRes = await fetch('/api/orders')
|
||
const ordersData = await ordersRes.json()
|
||
if (ordersData.success && ordersData.orders) {
|
||
setPurchases(ordersData.orders)
|
||
}
|
||
} catch (e) {
|
||
console.log('加载数据失败', e)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
setMounted(true)
|
||
loadData()
|
||
}, [])
|
||
|
||
// 防止Hydration错误:服务端渲染时显示加载状态
|
||
if (!mounted) {
|
||
return (
|
||
<div className="p-8 max-w-7xl mx-auto">
|
||
<h1 className="text-2xl font-bold mb-8 text-white">数据概览</h1>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||
{[1, 2, 3, 4].map((i) => (
|
||
<Card key={i} className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||
<div className="h-4 w-20 bg-gray-700 rounded animate-pulse" />
|
||
<div className="w-8 h-8 bg-gray-700 rounded-lg animate-pulse" />
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="h-8 w-16 bg-gray-700 rounded animate-pulse" />
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
<div className="flex items-center justify-center py-8">
|
||
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
|
||
<span className="ml-2 text-gray-400">加载中...</span>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const totalRevenue = purchases.reduce((sum, p) => sum + Number(p.amount || 0), 0)
|
||
const totalUsers = users.length
|
||
const totalPurchases = purchases.length
|
||
|
||
// 格式化订单商品信息(显示书名和章节)
|
||
const formatOrderProduct = (p: any) => {
|
||
const type = p.productType || ""
|
||
const desc = p.description || ""
|
||
|
||
// 优先使用 description,因为它包含完整的商品描述
|
||
if (desc) {
|
||
// 如果是章节购买,提取章节标题
|
||
if (type === "section" && desc.includes("章节")) {
|
||
// description 格式可能是:"章节购买-1-2" 或具体章节标题
|
||
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: "功能服务"
|
||
}
|
||
}
|
||
// 其他情况直接显示 description
|
||
return {
|
||
title: desc,
|
||
subtitle: type === "section" ? "单章" : type === "fullbook" ? "全书" : "其他"
|
||
}
|
||
}
|
||
|
||
// 如果没有 description,fallback 到原逻辑
|
||
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: "/admin/users" },
|
||
{
|
||
title: "总收入",
|
||
value: `¥${Number(totalRevenue).toFixed(2)}`,
|
||
icon: TrendingUp,
|
||
color: "text-[#38bdac]",
|
||
bg: "bg-[#38bdac]/20",
|
||
link: "/admin/orders",
|
||
},
|
||
{ title: "订单数", value: totalPurchases, icon: ShoppingBag, color: "text-purple-400", bg: "bg-purple-500/20", link: "/admin/orders" },
|
||
{
|
||
title: "转化率",
|
||
value: `${totalUsers > 0 ? ((totalPurchases / totalUsers) * 100).toFixed(1) : 0}%`,
|
||
icon: BookOpen,
|
||
color: "text-orange-400",
|
||
bg: "bg-orange-500/20",
|
||
link: "/admin/distribution",
|
||
},
|
||
]
|
||
|
||
return (
|
||
<div className="p-8 max-w-7xl mx-auto">
|
||
<h1 className="text-2xl font-bold mb-8 text-white">数据概览</h1>
|
||
|
||
<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 && router.push(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>
|
||
<CardTitle className="text-white">最近订单</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-3">
|
||
{purchases
|
||
.slice(-5)
|
||
.reverse()
|
||
.map((p) => {
|
||
const referrer = p.referrerId && users.find((u: any) => u.id === p.referrerId)
|
||
const inviteCode = p.referralCode || referrer?.referral_code || referrer?.nickname || p.referrerId?.slice(0, 8)
|
||
const product = formatOrderProduct(p)
|
||
const buyer = p.userNickname || users.find((u: any) => 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'
|
||
e.currentTarget.nextElementSibling?.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).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(-5)
|
||
.reverse()
|
||
.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>
|
||
)
|
||
}
|