284 lines
10 KiB
TypeScript
284 lines
10 KiB
TypeScript
'use client'
|
||
|
||
import { useState } from 'react'
|
||
import Image from 'next/image'
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
import { Header } from '@/components/layout/header'
|
||
import { Spinner } from '@/components/ui/spinner'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Input } from '@/components/ui/input'
|
||
import { WxUserDetailDialog } from '@/components/wx-users/wx-user-detail-dialog'
|
||
import { apiFetch } from '@/lib/api'
|
||
import { WxUser, WxUserDetailResponse } from '@/types'
|
||
import { Search } from 'lucide-react'
|
||
|
||
interface UsersResponse {
|
||
users: WxUser[]
|
||
meta: { total: number }
|
||
}
|
||
|
||
export default function WxUsersPage() {
|
||
const queryClient = useQueryClient()
|
||
const [search, setSearch] = useState('')
|
||
const [selectedUser, setSelectedUser] = useState<WxUser | null>(null)
|
||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||
|
||
const { data, isLoading, error } = useQuery<UsersResponse>({
|
||
queryKey: ['wx-users', search],
|
||
queryFn: async () => {
|
||
const res = await apiFetch(`/api/wx-users?search=${encodeURIComponent(search)}`)
|
||
|
||
if (!res.ok) {
|
||
const payload = await res.json().catch(() => null)
|
||
throw new Error(payload?.error || 'Failed to fetch wx users')
|
||
}
|
||
|
||
return res.json()
|
||
},
|
||
})
|
||
|
||
const { data: userDetails, isLoading: isDetailLoading } = useQuery<WxUserDetailResponse>({
|
||
queryKey: ['wx-user-detail', selectedUser?.id],
|
||
queryFn: async () => {
|
||
const res = await apiFetch(`/api/wx-users/${selectedUser?.id}`)
|
||
|
||
if (!res.ok) {
|
||
const payload = await res.json().catch(() => null)
|
||
throw new Error(payload?.error || 'Failed to fetch wx user details')
|
||
}
|
||
|
||
return res.json()
|
||
},
|
||
enabled: !!selectedUser && isDialogOpen,
|
||
})
|
||
|
||
const syncProgressMutation = useMutation({
|
||
mutationFn: async ({ userId, levelIds }: { userId: string; levelIds: string[] }) => {
|
||
const res = await apiFetch('/api/wx-users/level-progress', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ userId, levelIds }),
|
||
})
|
||
|
||
if (!res.ok) {
|
||
const payload = await res.json().catch(() => null)
|
||
throw new Error(payload?.error || 'Failed to update level progress')
|
||
}
|
||
|
||
return res.json()
|
||
},
|
||
onSuccess: async () => {
|
||
await Promise.all([
|
||
queryClient.invalidateQueries({ queryKey: ['wx-users'] }),
|
||
queryClient.invalidateQueries({ queryKey: ['wx-user-detail', selectedUser?.id] }),
|
||
])
|
||
},
|
||
})
|
||
|
||
const clearProgressMutation = useMutation({
|
||
mutationFn: async (userId: string) => {
|
||
const res = await apiFetch('/api/wx-users/level-progress', {
|
||
method: 'DELETE',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ userId }),
|
||
})
|
||
|
||
if (!res.ok) {
|
||
const payload = await res.json().catch(() => null)
|
||
throw new Error(payload?.error || 'Failed to clear level progress')
|
||
}
|
||
|
||
return res.json()
|
||
},
|
||
onSuccess: async () => {
|
||
await Promise.all([
|
||
queryClient.invalidateQueries({ queryKey: ['wx-users'] }),
|
||
queryClient.invalidateQueries({ queryKey: ['wx-user-detail', selectedUser?.id] }),
|
||
])
|
||
},
|
||
})
|
||
|
||
const handleOpenDetail = (user: WxUser) => {
|
||
setSelectedUser(user)
|
||
setIsDialogOpen(true)
|
||
}
|
||
|
||
const handleDialogChange = (open: boolean) => {
|
||
setIsDialogOpen(open)
|
||
if (!open) {
|
||
setSelectedUser(null)
|
||
}
|
||
}
|
||
|
||
const handleSaveProgress = async (levelIds: string[]) => {
|
||
if (!selectedUser) return
|
||
await syncProgressMutation.mutateAsync({ userId: selectedUser.id, levelIds })
|
||
}
|
||
|
||
const handleClearAll = async () => {
|
||
if (!selectedUser) return
|
||
await clearProgressMutation.mutateAsync(selectedUser.id)
|
||
}
|
||
|
||
const formatDate = (date: Date | string) => {
|
||
return new Date(date).toLocaleString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})
|
||
}
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="h-screen flex flex-col">
|
||
<Header />
|
||
<div className="flex-1 flex items-center justify-center">
|
||
<Spinner size="lg" />
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="h-screen flex flex-col">
|
||
<Header />
|
||
<div className="flex-1 flex items-center justify-center">
|
||
<div className="text-center">
|
||
<p className="text-red-600">{error.message || '加载失败'}</p>
|
||
<Button className="mt-4" onClick={() => queryClient.invalidateQueries({ queryKey: ['wx-users'] })}>
|
||
重试
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="h-screen flex flex-col">
|
||
<Header />
|
||
<div className="flex-1 overflow-auto bg-slate-50 p-6">
|
||
<div className="mx-auto max-w-7xl">
|
||
<div className="mb-6 flex items-center justify-between gap-4">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-slate-900">微信小游戏用户</h1>
|
||
<p className="mt-1 text-slate-500">
|
||
展示用户列表,并维护每个用户的通关关卡配置。当前共 {data?.meta.total || 0} 个用户。
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="relative mb-6 max-w-sm">
|
||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||
<Input
|
||
type="text"
|
||
placeholder="搜索昵称或 OpenID"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
className="border-slate-200 bg-white pl-10"
|
||
/>
|
||
</div>
|
||
|
||
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||
<table className="min-w-full divide-y divide-slate-200">
|
||
<thead className="bg-slate-50">
|
||
<tr>
|
||
<th className="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||
用户
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||
OpenID
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||
已通关关卡数
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||
注册时间
|
||
</th>
|
||
<th className="px-6 py-3 text-right text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||
操作
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-200 bg-white">
|
||
{data?.users.map((user) => (
|
||
<tr key={user.id} className="hover:bg-slate-50">
|
||
<td className="px-6 py-4">
|
||
<div className="flex items-center gap-3">
|
||
{user.avatarUrl ? (
|
||
<div className="relative h-10 w-10 overflow-hidden rounded-full bg-slate-100">
|
||
<Image
|
||
src={user.avatarUrl}
|
||
alt={user.nickname || 'User'}
|
||
fill
|
||
sizes="40px"
|
||
className="object-cover"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-slate-200 font-medium text-slate-600">
|
||
{user.nickname?.[0]?.toUpperCase() || 'U'}
|
||
</div>
|
||
)}
|
||
<div className="min-w-0">
|
||
<div className="truncate font-medium text-slate-900">
|
||
{user.nickname || '匿名用户'}
|
||
</div>
|
||
<div className="text-sm text-slate-500">体力 {user.stamina}</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 align-top">
|
||
<code className="text-xs text-slate-500">{user.openid}</code>
|
||
</td>
|
||
<td className="px-6 py-4 align-top">
|
||
<span className="inline-flex rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-medium text-emerald-700">
|
||
{user.completedLevelCount} 关
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 align-top text-sm text-slate-500">
|
||
{formatDate(user.createdAt)}
|
||
</td>
|
||
<td className="px-6 py-4 text-right align-top">
|
||
<Button size="sm" variant="outline" onClick={() => handleOpenDetail(user)}>
|
||
配置通关关卡
|
||
</Button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{data?.users.length === 0 && (
|
||
<tr>
|
||
<td colSpan={5} className="px-6 py-16 text-center text-sm text-slate-500">
|
||
暂无用户数据
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{isDialogOpen && selectedUser && isDetailLoading && !userDetails ? (
|
||
<div className="pointer-events-none fixed inset-0 z-40 flex items-center justify-center bg-black/20">
|
||
<div className="rounded-full bg-white p-4 shadow-lg">
|
||
<Spinner size="lg" />
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<WxUserDetailDialog
|
||
open={isDialogOpen}
|
||
onOpenChange={handleDialogChange}
|
||
user={userDetails}
|
||
isSaving={syncProgressMutation.isPending || clearProgressMutation.isPending}
|
||
onSave={handleSaveProgress}
|
||
onClearAll={handleClearAll}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|