Files
MemeStudio/app/(dashboard)/wx-users/page.tsx
2026-04-19 14:28:36 +08:00

284 lines
10 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 } 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>
)
}