Files
MemeStudio/components/wx-users/wx-user-detail-dialog.tsx
2026-04-19 14:28:36 +08:00

301 lines
11 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 { useEffect, useMemo, useState } from 'react'
import Image from 'next/image'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { WxUserDetailResponse } from '@/types'
interface WxUserDetailDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
user: WxUserDetailResponse | null | undefined
isSaving?: boolean
onSave: (levelIds: string[]) => Promise<void>
onClearAll: () => Promise<void>
}
export function WxUserDetailDialog({
open,
onOpenChange,
user,
isSaving = false,
onSave,
onClearAll,
}: WxUserDetailDialogProps) {
const [selectedLevelIds, setSelectedLevelIds] = useState<Set<string>>(new Set())
const [keyword, setKeyword] = useState('')
useEffect(() => {
if (!open || !user) {
setSelectedLevelIds(new Set())
setKeyword('')
return
}
setSelectedLevelIds(
new Set(
user.assignedLevels
.filter((level) => level.completed)
.map((level) => level.id)
)
)
}, [open, user])
const filteredLevels = useMemo(() => {
if (!user) return []
const search = keyword.trim().toLowerCase()
if (!search) return user.assignedLevels
return user.assignedLevels.filter((level) => {
return (
String(level.sortOrder).includes(search) ||
level.answer.toLowerCase().includes(search) ||
level.id.toLowerCase().includes(search)
)
})
}, [keyword, user])
const selectedCount = selectedLevelIds.size
const totalLevels = user?.assignedLevels.length || 0
const completedCount = user?.assignedLevels.filter((level) => level.completed).length || 0
const hasChanges = useMemo(() => {
if (!user) return false
const original = new Set(
user.assignedLevels.filter((level) => level.completed).map((level) => level.id)
)
if (original.size !== selectedLevelIds.size) return true
for (const id of Array.from(selectedLevelIds)) {
if (!original.has(id)) return true
}
return false
}, [selectedLevelIds, user])
if (!user) return null
const toggleLevel = (levelId: string, checked: boolean) => {
const next = new Set(selectedLevelIds)
if (checked) {
next.add(levelId)
} else {
next.delete(levelId)
}
setSelectedLevelIds(next)
}
const handleSelectAllVisible = () => {
const next = new Set(selectedLevelIds)
for (const level of filteredLevels) {
next.add(level.id)
}
setSelectedLevelIds(next)
}
const handleClearVisible = () => {
const next = new Set(selectedLevelIds)
for (const level of filteredLevels) {
next.delete(level.id)
}
setSelectedLevelIds(next)
}
const handleSave = async () => {
await onSave(Array.from(selectedLevelIds))
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-5xl h-[85vh] overflow-hidden p-0">
<div className="grid h-full md:grid-cols-[320px_1fr]">
<div className="border-b bg-slate-50 p-6 md:border-b-0 md:border-r">
<DialogHeader className="text-left">
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="mt-6 space-y-5">
<div className="flex items-center gap-4">
<div className="relative h-16 w-16 overflow-hidden rounded-full bg-slate-200">
{user.avatarUrl ? (
<Image
src={user.avatarUrl}
alt={user.nickname || 'User'}
fill
sizes="64px"
className="object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-xl font-semibold text-slate-600">
{user.nickname?.[0]?.toUpperCase() || 'U'}
</div>
)}
</div>
<div className="min-w-0">
<h2 className="truncate text-xl font-semibold text-slate-900">
{user.nickname || '匿名用户'}
</h2>
<p className="mt-1 text-sm text-slate-500">
{new Date(user.createdAt).toLocaleString('zh-CN')}
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-xl border bg-white p-4">
<p className="text-xs uppercase tracking-wide text-slate-500"></p>
<p className="mt-2 text-2xl font-semibold text-emerald-600">{completedCount}</p>
</div>
<div className="rounded-xl border bg-white p-4">
<p className="text-xs uppercase tracking-wide text-slate-500"></p>
<p className="mt-2 text-2xl font-semibold text-sky-600">{selectedCount}</p>
</div>
</div>
<div className="space-y-3 rounded-xl border bg-white p-4">
<div>
<p className="text-xs uppercase tracking-wide text-slate-500">OpenID</p>
<code className="mt-2 block break-all rounded-md bg-slate-100 p-3 text-xs text-slate-700">
{user.openid}
</code>
</div>
<div className="grid grid-cols-2 gap-3 text-sm text-slate-600">
<div>
<p className="text-xs uppercase tracking-wide text-slate-500"></p>
<p className="mt-1 font-medium text-slate-900">{user.stamina}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-slate-500"></p>
<p className="mt-1 font-medium text-slate-900">{totalLevels}</p>
</div>
</div>
</div>
<div className="space-y-2">
<Button
className="w-full"
onClick={handleSave}
disabled={isSaving || !hasChanges}
>
{isSaving ? '保存中...' : '保存通关配置'}
</Button>
<Button
variant="outline"
className="w-full"
onClick={onClearAll}
disabled={isSaving || completedCount === 0}
>
</Button>
</div>
</div>
</div>
<div className="flex min-h-0 flex-col p-6">
<div className="flex flex-col gap-3 border-b pb-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-base font-semibold text-slate-900"></h3>
<p className="text-sm text-slate-500"></p>
</div>
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索关卡序号、答案或 ID"
className="sm:max-w-xs"
/>
</div>
<div className="flex items-center gap-2 py-4">
<Button variant="outline" size="sm" onClick={handleSelectAllVisible} disabled={filteredLevels.length === 0 || isSaving}>
</Button>
<Button variant="outline" size="sm" onClick={handleClearVisible} disabled={filteredLevels.length === 0 || isSaving}>
</Button>
</div>
<div className="min-h-0 flex-1 overflow-auto rounded-xl border">
{filteredLevels.length === 0 ? (
<div className="flex h-full items-center justify-center p-8 text-sm text-slate-500">
</div>
) : (
<table className="min-w-full divide-y divide-slate-200 text-sm">
<thead className="sticky top-0 bg-slate-50">
<tr>
<th className="w-14 px-4 py-3 text-left font-medium text-slate-500"></th>
<th className="w-24 px-4 py-3 text-left font-medium text-slate-500"></th>
<th className="px-4 py-3 text-left font-medium text-slate-500"></th>
<th className="px-4 py-3 text-left font-medium text-slate-500"></th>
<th className="px-4 py-3 text-left font-medium text-slate-500"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 bg-white">
{filteredLevels.map((level) => {
const checked = selectedLevelIds.has(level.id)
return (
<tr key={level.id} className={checked ? 'bg-emerald-50/60' : ''}>
<td className="px-4 py-3 align-top">
<input
type="checkbox"
checked={checked}
onChange={(e) => toggleLevel(level.id, e.target.checked)}
disabled={isSaving}
className="h-4 w-4 rounded border-slate-300"
/>
</td>
<td className="px-4 py-3 align-top font-mono text-slate-500">
#{level.sortOrder}
</td>
<td className="px-4 py-3 align-top">
<div className="font-medium text-slate-900">{level.answer}</div>
<div className="mt-1 font-mono text-xs text-slate-400">{level.id}</div>
</td>
<td className="px-4 py-3 align-top">
<span
className={checked
? 'inline-flex rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-medium text-emerald-700'
: 'inline-flex rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-500'}
>
{checked ? '已通关' : '未通关'}
</span>
</td>
<td className="px-4 py-3 align-top text-slate-500">
{level.completedAt
? new Date(level.completedAt).toLocaleString('zh-CN')
: '-'}
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}