Files
2026-04-25 21:48:57 +08:00

315 lines
10 KiB
TypeScript

"use client"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { EmptyState } from "@/components/ui/empty-state"
import { Input } from "@/components/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
inviteShopPlayer,
listPlayers,
listPlayersByShop,
listShopInvitations,
removeShopPlayer,
} from "@/lib/api"
import { toApiError } from "@/lib/errors"
import { useMyShop } from "@/lib/hooks/use-my-shop"
import { notifyInfo, notifySuccess } from "@/lib/toast"
import type { Player } from "@/lib/types"
import { AlertCircle, MoreHorizontal, Star, UserPlus } from "lucide-react"
import Link from "next/link"
import { useCallback, useEffect, useMemo, useState } from "react"
const statusLabels: Record<string, string> = {
available: "在线",
busy: "忙碌",
offline: "离线",
}
const statusVariants: Record<string, "default" | "secondary" | "outline"> = {
available: "default",
busy: "secondary",
offline: "outline",
}
export default function EmployeesPage() {
const [search, setSearch] = useState("")
const { shop, loading, error, refreshShop } = useMyShop()
const [players, setPlayers] = useState<Player[]>([])
const [shopPlayers, setShopPlayers] = useState<Player[]>([])
const [pendingPlayerIds, setPendingPlayerIds] = useState<Set<string>>(new Set())
const [dataLoading, setDataLoading] = useState(true)
const [saving, setSaving] = useState(false)
const loadData = useCallback(async () => {
if (!shop) return
setDataLoading(true)
try {
const [nextPlayers, nextShopPlayers, invitations] = await Promise.all([
listPlayers(),
listPlayersByShop(shop.id),
listShopInvitations(shop.id),
])
setPlayers(nextPlayers)
setShopPlayers(nextShopPlayers)
setPendingPlayerIds(
new Set(
invitations
.filter((invitation) => invitation.status === "pending")
.map((invitation) => String(invitation.playerId)),
),
)
} catch (error) {
notifyInfo(toApiError(error).msg)
} finally {
setDataLoading(false)
}
}, [shop])
useEffect(() => {
if (!shop) return
let cancelled = false
Promise.all([listPlayers(), listPlayersByShop(shop.id), listShopInvitations(shop.id)])
.then(([nextPlayers, nextShopPlayers, invitations]) => {
if (cancelled) return
setPlayers(nextPlayers)
setShopPlayers(nextShopPlayers)
setPendingPlayerIds(
new Set(
invitations
.filter((invitation) => invitation.status === "pending")
.map((invitation) => String(invitation.playerId)),
),
)
})
.catch((error) => {
if (cancelled) return
notifyInfo(toApiError(error).msg)
})
.finally(() => {
if (cancelled) return
setDataLoading(false)
})
return () => {
cancelled = true
}
}, [shop])
const filteredPlayers = useMemo(() => {
if (!shop) return []
return shopPlayers.filter(
(player) =>
player.shopId === shop.id &&
player.user.nickname.toLowerCase().includes(search.trim().toLowerCase()),
)
}, [search, shop, shopPlayers])
const inviteCandidate = useMemo(
() =>
shop
? players.find((player) => player.shopId !== shop.id && !pendingPlayerIds.has(player.id))
: undefined,
[pendingPlayerIds, players, shop],
)
if (loading) {
return (
<div className="container mx-auto max-w-6xl px-4 py-8">
<EmptyState title="员工信息加载中" icon={UserPlus} />
</div>
)
}
if (error) {
return (
<div className="container mx-auto max-w-6xl px-4 py-8">
<EmptyState title="员工信息加载失败" description={error} icon={AlertCircle} />
</div>
)
}
if (!shop) {
return (
<div className="container mx-auto max-w-6xl px-4 py-8">
<EmptyState title="当前账号没有可管理的店铺" icon={UserPlus} />
</div>
)
}
const handleInvite = async () => {
if (!inviteCandidate) return
setSaving(true)
try {
await inviteShopPlayer(shop.id, inviteCandidate.id)
await loadData()
notifySuccess("邀请已发送")
} catch (error) {
notifyInfo(toApiError(error).msg)
} finally {
setSaving(false)
}
}
const handleRemove = async (player: Player) => {
setSaving(true)
try {
await removeShopPlayer(shop.id, player.id)
await Promise.all([loadData(), refreshShop()])
notifySuccess("打手已移除")
} catch (error) {
notifyInfo(toApiError(error).msg)
} finally {
setSaving(false)
}
}
return (
<div className="container mx-auto max-w-6xl px-4 py-8 space-y-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1>
<Button
onClick={() => {
void handleInvite()
}}
disabled={saving || !inviteCandidate}
>
<UserPlus className="mr-1 h-4 w-4" />
</Button>
</div>
<Card className="border-border/80 shadow-sm">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base"> ({filteredPlayers.length})</CardTitle>
<Input
placeholder="搜索打手..."
className="max-w-xs"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dataLoading ? (
<TableRow>
<TableCell colSpan={6} className="py-6">
<EmptyState title="员工加载中" icon={UserPlus} className="min-h-[180px]" />
</TableCell>
</TableRow>
) : filteredPlayers.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-6">
<EmptyState
title="暂无签约打手"
description="邀请打手加入后会出现在这里。"
icon={UserPlus}
className="min-h-[180px] border-dashed"
/>
</TableCell>
</TableRow>
) : (
filteredPlayers.map((player) => (
<TableRow key={player.id}>
<TableCell>
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarImage src={player.user.avatar} />
<AvatarFallback>{player.user.nickname[0]}</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium">{player.user.nickname}</p>
<p className="text-xs text-muted-foreground">{player.totalOrders} </p>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex gap-1 flex-wrap">
{player.games.map((game) => (
<Badge key={game} variant="secondary" className="text-xs">
{game}
</Badge>
))}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Star className="h-3.5 w-3.5 fill-warning text-warning" />
{player.rating}
</div>
</TableCell>
<TableCell>{(player.completionRate * 100).toFixed(0)}%</TableCell>
<TableCell>
<Badge variant={statusVariants[player.status]}>
{statusLabels[player.status]}
</Badge>
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/player/${player.id}`}></Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/dashboard/shop/rules"></Link>
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => {
void handleRemove(player)
}}
disabled={saving}
>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}