Files
wzdj/app/admin/streamers/client.tsx
2026-04-11 17:15:11 +08:00

382 lines
17 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 { useRouter } from "next/navigation"
import {
Search,
Plus,
MoreVertical,
Radio,
Eye,
Edit,
Trash2,
CheckCircle,
XCircle,
RefreshCw,
Users,
Building2,
} 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 { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { getStreamers, createStreamer, updateStreamer, deleteStreamer as apiDeleteStreamer } from "@/lib/api/admin"
import { getStreamerAvatarUrl, getStreamerCoverUrl } from "@/lib/streamer-avatar"
type StreamerRow = Record<string, unknown> & {
id: string
name: string
game?: string
isLive?: boolean
fans?: number
tags?: string[]
verifiedAt?: string
coverImage?: string
avatar?: string
bindingCount?: number
guildName?: string | null
guildId?: string
}
/** 主播/明星名称统一显示为 4 个字(不足补·,超出截断) */
function formatNameFourChars(name: string | undefined): string {
const s = String(name ?? "").trim()
const chars = [...s]
if (chars.length >= 4) return chars.slice(0, 4).join("")
return s + "·".repeat(4 - chars.length)
}
export default function AdminStreamersClient() {
const router = useRouter()
const [streamers, setStreamers] = useState<StreamerRow[]>([])
const [searchTerm, setSearchTerm] = useState("")
const [loading, setLoading] = useState(true)
const [showAddDialog, setShowAddDialog] = useState(false)
const [newStreamer, setNewStreamer] = useState({ name: "", game: "", description: "" })
useEffect(() => {
loadStreamers()
}, [])
const loadStreamers = async () => {
setLoading(true)
try {
const allStreamers = await getStreamers()
setStreamers(allStreamers as StreamerRow[])
} catch (error) {
console.error("Failed to load streamers:", error)
}
setLoading(false)
}
const filteredStreamers = streamers.filter(
(streamer) =>
(streamer.name ?? "").toLowerCase().includes(searchTerm.toLowerCase()) ||
(streamer.game ?? "").toLowerCase().includes(searchTerm.toLowerCase()),
)
const toggleLiveStatus = async (streamerId: string, isLive: boolean) => {
await updateStreamer(streamerId, { isLive: !isLive })
loadStreamers()
}
const deleteStreamerById = async (streamerId: string) => {
if (confirm("确定要删除该主播吗?")) {
await apiDeleteStreamer(streamerId)
loadStreamers()
}
}
const addStreamer = async () => {
if (!newStreamer.name || !newStreamer.game) {
alert("请填写主播名称和游戏类型")
return
}
const now = new Date().toISOString()
const res = await createStreamer({
userId: "",
name: newStreamer.name,
avatar: "",
coverImage: "",
title: newStreamer.description || `${newStreamer.game}主播`,
game: newStreamer.game,
gameId: 1,
tags: [newStreamer.game],
level: 1,
fans: 0,
hotValue: 0,
isLive: false,
totalGifts: 0,
totalIncome: 0,
commissionRate: 0.7,
status: "pending",
createdAt: now,
updatedAt: now,
})
if (res) {
setShowAddDialog(false)
setNewStreamer({ name: "", game: "", description: "" })
loadStreamers()
}
}
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"> MongoDB</p>
</div>
<Card className="admin-glass border-0 rounded-2xl overflow-hidden mb-6">
<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/80 border-slate-200 text-slate-800 placeholder:text-slate-400 pl-10"
/>
</div>
<Button
variant="outline"
onClick={loadStreamers}
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-blue-500 to-purple-500 text-white hover:from-blue-600 hover:to-purple-600 shadow-md" onClick={() => setShowAddDialog(true)}>
<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 border-0 rounded-2xl overflow-hidden">
<CardContent className="p-4 text-center">
<p className="text-2xl font-bold text-slate-800">{streamers.length}</p>
<p className="text-slate-500 text-sm"></p>
</CardContent>
</Card>
<Card className="admin-glass border-0 rounded-2xl overflow-hidden">
<CardContent className="p-4 text-center">
<p className="text-2xl font-bold text-emerald-600">{streamers.filter((s) => s.isLive).length}</p>
<p className="text-slate-500 text-sm"></p>
</CardContent>
</Card>
<Card className="admin-glass border-0 rounded-2xl overflow-hidden">
<CardContent className="p-4 text-center">
<p className="text-2xl font-bold text-amber-600">{streamers.filter((s) => s.verifiedAt).length}</p>
<p className="text-slate-500 text-sm"></p>
</CardContent>
</Card>
<Card className="admin-glass border-0 rounded-2xl overflow-hidden">
<CardContent className="p-4 text-center">
<p className="text-2xl font-bold text-slate-800">
{streamers.reduce((sum, s) => sum + (Number(s.fans) || 0), 0).toLocaleString()}
</p>
<p className="text-slate-500 text-sm"></p>
</CardContent>
</Card>
</div>
<Card className="admin-glass border-0 rounded-2xl overflow-hidden">
<CardHeader>
<CardTitle className="text-slate-800"> ({filteredStreamers.length})</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="text-center py-8 text-slate-400">...</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredStreamers.map((streamer) => (
<Card
key={streamer.id}
className="bg-white border border-slate-200/80 overflow-hidden cursor-pointer hover:ring-2 hover:ring-indigo-500/30 hover:shadow-md transition-all shadow-sm rounded-xl"
onClick={() => router.push(`/admin/streamers/${streamer.id}`)}
>
<div className="relative h-32 rounded-t-xl overflow-hidden bg-gradient-to-br from-emerald-600 to-cyan-700" onClick={(e) => e.stopPropagation()}>
<img
src={getStreamerCoverUrl("玩值电竞", streamer.coverImage as string, 300, 128)}
alt={streamer.name}
className="w-full h-full object-cover"
onError={(e) => {
const t = e.target as HTMLImageElement
if (t) t.src = getStreamerCoverUrl("玩值电竞", undefined, 300, 128)
}}
/>
<span className="absolute bottom-2 left-2 px-2 py-0.5 rounded text-[10px] font-medium bg-white/90 text-slate-700"></span>
{streamer.isLive && (
<Badge className="absolute top-2 left-2 bg-red-500">
<Radio className="w-3 h-3 mr-1 animate-pulse" />
</Badge>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 bg-black/50 text-white hover:bg-black/70"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-white border-white/10 shadow-lg">
<DropdownMenuItem className="text-slate-200 focus:bg-slate-700/50" onClick={() => router.push(`/admin/streamers/${streamer.id}`)}>
<Eye className="w-4 h-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-200 focus:bg-slate-700/50">
<Edit className="w-4 h-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem
className="text-slate-200 focus:bg-slate-700/50"
onClick={(e) => {
e.stopPropagation()
toggleLiveStatus(streamer.id, streamer.isLive || false)
}}
>
{streamer.isLive ? (
<>
<XCircle className="w-4 h-4 mr-2" />
</>
) : (
<>
<CheckCircle className="w-4 h-4 mr-2" />
</>
)}
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-400"
onClick={(e) => {
e.stopPropagation()
deleteStreamerById(streamer.id)
}}
>
<Trash2 className="w-4 h-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<CardContent className="p-4">
<div className="flex items-center gap-3 mb-2">
<Avatar className="w-12 h-12 border-2 border-white/10">
<AvatarImage src={getStreamerAvatarUrl(streamer.name as string, streamer.avatar as string, 48)} />
<AvatarFallback>{formatNameFourChars(streamer.name as string)[0]}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-slate-800 font-medium truncate" title={String(streamer.name ?? "")}>{formatNameFourChars(streamer.name as string)}</span>
{streamer.verifiedAt && <CheckCircle className="w-4 h-4 text-cyan-400 shrink-0" />}
</div>
<p className="text-slate-500 text-sm truncate">{(typeof streamer.game === "number" && Number.isNaN(streamer.game)) ? "—" : (streamer.game ?? "—")}</p>
</div>
</div>
<div className="flex items-center gap-3 text-sm text-slate-500 mb-1">
<span className="text-xs text-slate-400"></span>
<div className="flex items-center gap-1" title="捆绑人数30天绑定">
<Users className="w-4 h-4" />
<span>{(Number(streamer.bindingCount) || 0).toLocaleString()}</span>
<span className="text-slate-400 text-xs"></span>
</div>
<div className="flex items-center gap-1 min-w-0" title="所属公会">
<Building2 className="w-4 h-4 shrink-0" />
<span className="truncate">{(streamer.guildName != null && streamer.guildName !== "") ? String(streamer.guildName) : "—"}</span>
</div>
</div>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1 text-slate-500">
<Users className="w-4 h-4" />
<span>{(Number(streamer.fans) || 0).toLocaleString()}</span>
<span className="text-slate-400 text-xs"></span>
</div>
<div className="flex gap-1 flex-wrap justify-end">
{streamer.tags?.slice(0, 3).map((tag, i) => (
<Badge key={i} variant="outline" className="text-xs border-slate-200 text-slate-500">
{(typeof tag === "number" && Number.isNaN(tag)) ? "—" : String(tag ?? "—")}
</Badge>
))}
</div>
</div>
<Button
variant="outline"
size="sm"
className="w-full mt-3 border-slate-200 text-slate-600 hover:bg-slate-50"
onClick={(e) => { e.stopPropagation(); router.push(`/admin/streamers/${streamer.id}`) }}
>
<Eye className="w-4 h-4 mr-2" />
</Button>
</CardContent>
</Card>
))}
</div>
)}
</CardContent>
</Card>
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogContent className="bg-white border-white/10 shadow-xl">
<DialogHeader>
<DialogTitle className="text-slate-100"></DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm text-slate-300 mb-1 block"> 4 </label>
<Input
value={newStreamer.name}
onChange={(e) => setNewStreamer({ ...newStreamer, name: e.target.value })}
placeholder="请输入主播名称,如:京丶落、炎凉··"
className="bg-slate-800/50 border-white/10 text-slate-100"
/>
</div>
<div>
<label className="text-sm text-slate-300 mb-1 block"></label>
<Input
value={newStreamer.game}
onChange={(e) => setNewStreamer({ ...newStreamer, game: e.target.value })}
placeholder="如:王者荣耀、英雄联盟"
className="bg-slate-800/50 border-white/10 text-slate-100"
/>
</div>
<div>
<label className="text-sm text-slate-300 mb-1 block"></label>
<Input
value={newStreamer.description}
onChange={(e) => setNewStreamer({ ...newStreamer, description: e.target.value })}
placeholder="主播简介"
className="bg-slate-800/50 border-white/10 text-slate-100"
/>
</div>
<div className="flex gap-3 pt-2">
<Button
variant="outline"
onClick={() => setShowAddDialog(false)}
className="flex-1 border-white/10 text-slate-200 hover:bg-slate-800/50"
>
</Button>
<Button onClick={addStreamer} className="flex-1 bg-gradient-to-r from-blue-500 to-purple-500 text-white hover:from-blue-600 hover:to-purple-600 shadow-md">
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
)
}