383 lines
20 KiB
TypeScript
383 lines
20 KiB
TypeScript
"use client"
|
||
|
||
import { useState, useEffect } from "react"
|
||
import { Search, MoreVertical, UserCheck, UserX, Edit, Trash2, Eye, Plus, RefreshCw, Loader2 } from "lucide-react"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Input } from "@/components/ui/input"
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||
import { Badge } from "@/components/ui/badge"
|
||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||
import { getUsers, updateUser, deleteUser as adminDeleteUser, getUserDetail, type UserDetailEnriched } from "@/lib/api/admin"
|
||
|
||
type UserRow = Record<string, unknown> & {
|
||
id?: string
|
||
name?: string
|
||
phone?: string
|
||
status?: string
|
||
isVip?: boolean
|
||
vipType?: string
|
||
balance?: number
|
||
avatar?: string
|
||
createdAt?: string
|
||
/** 分类(来自四张表导入) */
|
||
category?: string[]
|
||
/** 来源表(存客宝、用户估值等) */
|
||
sourceCollections?: string[]
|
||
registerSource?: string
|
||
valuation?: number
|
||
}
|
||
|
||
export default function AdminUsersClient() {
|
||
const [users, setUsers] = useState<UserRow[]>([])
|
||
const [searchTerm, setSearchTerm] = useState("")
|
||
const [statusFilter, setStatusFilter] = useState("all")
|
||
const [loading, setLoading] = useState(true)
|
||
const [detailOpen, setDetailOpen] = useState(false)
|
||
const [detailUserId, setDetailUserId] = useState<string | null>(null)
|
||
const [detailData, setDetailData] = useState<{
|
||
local: Record<string, unknown>
|
||
enriched: UserDetailEnriched
|
||
} | null>(null)
|
||
const [detailLoading, setDetailLoading] = useState(false)
|
||
|
||
useEffect(() => {
|
||
loadUsers()
|
||
}, [])
|
||
|
||
const loadUsers = async () => {
|
||
setLoading(true)
|
||
try {
|
||
const allUsers = await getUsers()
|
||
setUsers(allUsers as UserRow[])
|
||
} catch (error) {
|
||
console.error("Failed to load users:", error)
|
||
}
|
||
setLoading(false)
|
||
}
|
||
|
||
const filteredUsers = users.filter((user) => {
|
||
const name = String(user.name ?? "")
|
||
const phone = String(user.phone ?? "")
|
||
const matchesSearch =
|
||
name.toLowerCase().includes(searchTerm.toLowerCase()) || phone.includes(searchTerm)
|
||
const matchesStatus = statusFilter === "all" || user.status === statusFilter
|
||
return matchesSearch && matchesStatus
|
||
})
|
||
|
||
const updateUserStatus = async (userId: string, status: string) => {
|
||
await updateUser(userId, { status })
|
||
loadUsers()
|
||
}
|
||
|
||
const deleteUser = async (userId: string) => {
|
||
if (confirm("确定要删除该用户吗?")) {
|
||
await adminDeleteUser(userId)
|
||
loadUsers()
|
||
}
|
||
}
|
||
|
||
const openUserDetail = async (userId: string) => {
|
||
setDetailUserId(userId)
|
||
setDetailOpen(true)
|
||
setDetailData(null)
|
||
setDetailLoading(true)
|
||
try {
|
||
const data = await getUserDetail(userId)
|
||
setDetailData(data ?? null)
|
||
} catch (e) {
|
||
console.error("Failed to load user detail:", e)
|
||
}
|
||
setDetailLoading(false)
|
||
}
|
||
|
||
return (
|
||
<div className="p-4 lg:p-6 max-w-[1600px] mx-auto">
|
||
<div className="mb-6">
|
||
<h1 className="text-2xl font-bold text-slate-800">用户管理</h1>
|
||
<p className="text-sm text-slate-500 mt-1">管理平台用户(数据来自 Mongo 四张表导入:用户名、手机号、分类)</p>
|
||
</div>
|
||
|
||
<Card className="admin-glass rounded-2xl overflow-hidden mb-6 border border-slate-200/80 shadow-lg">
|
||
<CardContent className="p-4">
|
||
<div className="flex flex-wrap gap-4">
|
||
<div className="relative flex-1 min-w-[200px]">
|
||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||
<Input
|
||
placeholder="搜索用户名/手机号..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="bg-white/90 border-slate-200 text-slate-800 placeholder:text-slate-400 pl-10"
|
||
/>
|
||
</div>
|
||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||
<SelectTrigger className="w-[150px] bg-white/90 border-slate-200 text-slate-800">
|
||
<SelectValue placeholder="用户状态" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">全部状态</SelectItem>
|
||
<SelectItem value="active">正常</SelectItem>
|
||
<SelectItem value="banned">已封禁</SelectItem>
|
||
<SelectItem value="pending">待审核</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<Button variant="outline" onClick={loadUsers} className="border-slate-200 text-slate-600 hover:bg-slate-50">
|
||
<RefreshCw className="w-4 h-4 mr-2" />
|
||
刷新
|
||
</Button>
|
||
<Button className="bg-gradient-to-r from-indigo-500 to-purple-500 text-white hover:from-indigo-600 hover:to-purple-600 shadow-md">
|
||
<Plus className="w-4 h-4 mr-2" />
|
||
添加用户
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||
<Card className="admin-glass rounded-2xl overflow-hidden border border-slate-200/80 shadow-lg">
|
||
<CardContent className="p-4 text-center">
|
||
<p className="text-2xl font-bold text-slate-800">{users.length}</p>
|
||
<p className="text-slate-500 text-sm">总用户数</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="admin-glass rounded-2xl overflow-hidden border border-slate-200/80 shadow-lg">
|
||
<CardContent className="p-4 text-center">
|
||
<p className="text-2xl font-bold text-emerald-600">{users.filter((u) => u.status === "active").length}</p>
|
||
<p className="text-slate-500 text-sm">活跃用户</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="admin-glass rounded-2xl overflow-hidden border border-slate-200/80 shadow-lg">
|
||
<CardContent className="p-4 text-center">
|
||
<p className="text-2xl font-bold text-amber-600">
|
||
{users.filter((u) => u.isVip === true).length}
|
||
</p>
|
||
<p className="text-slate-500 text-sm">VIP用户</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="admin-glass rounded-2xl overflow-hidden border border-slate-200/80 shadow-lg">
|
||
<CardContent className="p-4 text-center">
|
||
<p className="text-2xl font-bold text-red-600">{users.filter((u) => u.status === "banned").length}</p>
|
||
<p className="text-slate-500 text-sm">已封禁</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<Card className="admin-glass rounded-2xl overflow-hidden border border-slate-200/80 shadow-lg">
|
||
<CardHeader>
|
||
<CardTitle className="text-slate-800">用户列表 ({filteredUsers.length})</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{loading ? (
|
||
<div className="text-center py-8 text-slate-500">加载中...</div>
|
||
) : filteredUsers.length === 0 ? (
|
||
<div className="text-center py-8 text-slate-500">暂无用户数据(可运行 pnpm run import:lytiao:mongo 从四张表导入)</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead>
|
||
<tr className="border-b border-slate-200">
|
||
<th className="text-left text-slate-600 text-sm font-medium py-3 px-4">用户</th>
|
||
<th className="text-left text-slate-600 text-sm font-medium py-3 px-4">手机号</th>
|
||
<th className="text-left text-slate-600 text-sm font-medium py-3 px-4">分类</th>
|
||
<th className="text-left text-slate-600 text-sm font-medium py-3 px-4">来源表</th>
|
||
<th className="text-left text-slate-600 text-sm font-medium py-3 px-4">余额</th>
|
||
<th className="text-left text-slate-600 text-sm font-medium py-3 px-4">VIP</th>
|
||
<th className="text-left text-slate-600 text-sm font-medium py-3 px-4">状态</th>
|
||
<th className="text-left text-slate-600 text-sm font-medium py-3 px-4">注册时间</th>
|
||
<th className="text-left text-slate-600 text-sm font-medium py-3 px-4">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filteredUsers.map((user) => (
|
||
<tr key={user.id as string} className="border-b border-slate-100 hover:bg-slate-50/80">
|
||
<td className="py-3 px-4">
|
||
<div className="flex items-center gap-3">
|
||
<Avatar className="w-10 h-10 border border-slate-200">
|
||
<AvatarImage src={user.avatar || "/placeholder.svg"} />
|
||
<AvatarFallback className="bg-gradient-to-r from-indigo-500 to-purple-500 text-white">{user.name?.[0]}</AvatarFallback>
|
||
</Avatar>
|
||
<div>
|
||
<p className="text-slate-800 font-medium">{String(user.name ?? "")}</p>
|
||
<p className="text-slate-500 text-xs">ID: {user.id}</p>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="py-3 px-4 text-slate-700">{user.phone || "未绑定"}</td>
|
||
<td className="py-3 px-4">
|
||
{(Array.isArray(user.category) ? user.category : []).length > 0 ? (
|
||
<div className="flex flex-wrap gap-1">
|
||
{(user.category as string[]).slice(0, 3).map((c, i) => (
|
||
<Badge key={i} variant="outline" className="text-xs border-indigo-200 text-indigo-700 bg-indigo-50/80">
|
||
{String(c)}
|
||
</Badge>
|
||
))}
|
||
{(user.category as string[]).length > 3 && (
|
||
<span className="text-slate-500 text-xs">+{(user.category as string[]).length - 3}</span>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<span className="text-slate-400 text-sm">—</span>
|
||
)}
|
||
</td>
|
||
<td className="py-3 px-4 text-slate-600 text-sm max-w-[140px] truncate" title={Array.isArray(user.sourceCollections) ? (user.sourceCollections as string[]).join("、") : ""}>
|
||
{Array.isArray(user.sourceCollections) && (user.sourceCollections as string[]).length > 0
|
||
? (user.sourceCollections as string[]).slice(0, 2).join("、")
|
||
: user.registerSource ?? "—"}
|
||
</td>
|
||
<td className="py-3 px-4 text-amber-600">{user.balance != null ? Number(user.balance).toLocaleString() : 0} 币</td>
|
||
<td className="py-3 px-4">
|
||
{user.isVip ? (
|
||
<Badge className="bg-amber-100 text-amber-700 border-amber-200">{user.vipType === "year" ? "年卡" : user.vipType === "quarter" ? "季卡" : "月卡"}</Badge>
|
||
) : (
|
||
<span className="text-slate-500">普通</span>
|
||
)}
|
||
</td>
|
||
<td className="py-3 px-4">
|
||
<Badge
|
||
className={
|
||
user.status === "active"
|
||
? "bg-emerald-100 text-emerald-700 border-emerald-200"
|
||
: user.status === "banned"
|
||
? "bg-red-100 text-red-700 border-red-200"
|
||
: "bg-amber-100 text-amber-700 border-amber-200"
|
||
}
|
||
>
|
||
{user.status === "active" ? "正常" : user.status === "banned" ? "封禁" : "待审"}
|
||
</Badge>
|
||
</td>
|
||
<td className="py-3 px-4 text-slate-500 text-sm">
|
||
{user.createdAt ? new Date(String(user.createdAt)).toLocaleDateString("zh-CN") : "-"}
|
||
</td>
|
||
<td className="py-3 px-4">
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button variant="ghost" size="icon" className="text-slate-500 hover:text-slate-800 hover:bg-slate-100">
|
||
<MoreVertical className="w-4 h-4" />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent className="bg-white border-slate-200 shadow-xl">
|
||
<DropdownMenuItem
|
||
onClick={() => user.id && openUserDetail(user.id)}
|
||
>
|
||
<Eye className="w-4 h-4 mr-2" />
|
||
查看详情
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem>
|
||
<Edit className="w-4 h-4 mr-2" />
|
||
编辑资料
|
||
</DropdownMenuItem>
|
||
{user.status === "active" ? (
|
||
<DropdownMenuItem
|
||
className="text-red-600 focus:bg-red-50"
|
||
onClick={() => updateUserStatus(user.id, "banned")}
|
||
>
|
||
<UserX className="w-4 h-4 mr-2" />
|
||
封禁用户
|
||
</DropdownMenuItem>
|
||
) : (
|
||
<DropdownMenuItem
|
||
className="text-emerald-600 focus:bg-emerald-50"
|
||
onClick={() => updateUserStatus(user.id, "active")}
|
||
>
|
||
<UserCheck className="w-4 h-4 mr-2" />
|
||
解除封禁
|
||
</DropdownMenuItem>
|
||
)}
|
||
<DropdownMenuItem
|
||
className="text-red-600 focus:bg-red-50"
|
||
onClick={() => user.id && deleteUser(user.id)}
|
||
>
|
||
<Trash2 className="w-4 h-4 mr-2" />
|
||
删除用户
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Dialog open={detailOpen} onOpenChange={(open) => { setDetailOpen(open); if (!open) setDetailData(null) }}>
|
||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto bg-white border-slate-200 text-slate-800 shadow-xl">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-slate-800">用户详情</DialogTitle>
|
||
</DialogHeader>
|
||
{detailLoading ? (
|
||
<div className="flex items-center justify-center py-12 text-slate-500">
|
||
<Loader2 className="w-8 h-8 animate-spin mr-2" />
|
||
加载中...
|
||
</div>
|
||
) : detailData ? (
|
||
<div className="space-y-6">
|
||
<div>
|
||
<h3 className="text-sm font-medium text-slate-600 mb-2">基本信息</h3>
|
||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||
<p><span className="text-slate-500">姓名:</span>{String(detailData.local.name ?? detailData.enriched.user?.name ?? "-")}</p>
|
||
<p><span className="text-slate-500">手机:</span>{String(detailData.local.phone ?? detailData.enriched.user?.phone ?? "-")}</p>
|
||
<p><span className="text-slate-500">ID:</span>{detailUserId ?? "-"}</p>
|
||
<p><span className="text-slate-500">注册时间:</span>{detailData.local.createdAt ? new Date(String(detailData.local.createdAt)).toLocaleString("zh-CN") : (detailData.enriched.user?.created_at ? new Date(String(detailData.enriched.user.created_at)).toLocaleString("zh-CN") : "-")}</p>
|
||
{detailData.local.balance != null && <p><span className="text-slate-500">余额:</span>{Number(detailData.local.balance).toLocaleString()} 币</p>}
|
||
{Array.isArray(detailData.local.category) && (detailData.local.category as string[]).length > 0 && (
|
||
<p className="col-span-2"><span className="text-slate-500">分类:</span>
|
||
{(detailData.local.category as string[]).map((c, i) => (
|
||
<Badge key={i} variant="outline" className="mr-1 mt-1 text-xs border-indigo-200 text-indigo-700">{String(c)}</Badge>
|
||
))}
|
||
</p>
|
||
)}
|
||
{Array.isArray(detailData.local.sourceCollections) && (detailData.local.sourceCollections as string[]).length > 0 && (
|
||
<p className="col-span-2"><span className="text-slate-500">来源表:</span>{(detailData.local.sourceCollections as string[]).join("、")}</p>
|
||
)}
|
||
{detailData.enriched.fromExternal && <p className="col-span-2"><Badge className="bg-emerald-100 text-emerald-700 border-emerald-200">已对接用户资产 API</Badge></p>}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<h3 className="text-sm font-medium text-slate-600 mb-2">绑定主播</h3>
|
||
{detailData.enriched.boundStreamer && (detailData.enriched.boundStreamer.streamer_name || detailData.enriched.boundStreamer.streamer_id) ? (
|
||
<p className="text-sm text-slate-700">{detailData.enriched.boundStreamer.streamer_name ?? ""}(ID: {detailData.enriched.boundStreamer.streamer_id ?? "-"})</p>
|
||
) : (
|
||
<p className="text-sm text-slate-500">暂无绑定主播</p>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<h3 className="text-sm font-medium text-slate-600 mb-2">消费记录(共 {detailData.enriched.consumption?.length ?? 0} 条)</h3>
|
||
{detailData.enriched.consumption && detailData.enriched.consumption.length > 0 ? (
|
||
<div className="border border-slate-200 rounded-lg overflow-hidden">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-slate-50">
|
||
<tr>
|
||
<th className="text-left py-2 px-3 text-slate-600">金额</th>
|
||
<th className="text-left py-2 px-3 text-slate-600">时间</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{detailData.enriched.consumption.slice(0, 20).map((r, i) => (
|
||
<tr key={i} className="border-t border-slate-100">
|
||
<td className="py-2 px-3 text-amber-600">{Number(r.amount ?? 0).toLocaleString()} 元</td>
|
||
<td className="py-2 px-3 text-slate-600">{r.timestamp ? new Date(String(r.timestamp)).toLocaleString("zh-CN") : "-"}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
{detailData.enriched.consumption.length > 20 && <p className="text-xs text-slate-500 py-2 px-3">仅展示最近 20 条</p>}
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-slate-500">暂无消费记录</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<p className="text-slate-500 py-4">无法加载用户详情</p>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
)
|
||
}
|