Files
wzdj/app/admin/users/client.tsx

383 lines
20 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.

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