perf: 支持删除关卡
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Header } from '@/components/layout/header'
|
import { Header } from '@/components/layout/header'
|
||||||
@@ -20,6 +20,7 @@ export default function WxUsersPage() {
|
|||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [selectedUser, setSelectedUser] = useState<WxUser | null>(null)
|
const [selectedUser, setSelectedUser] = useState<WxUser | null>(null)
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery<UsersResponse>({
|
const { data, isLoading, error } = useQuery<UsersResponse>({
|
||||||
queryKey: ['wx-users', search],
|
queryKey: ['wx-users', search],
|
||||||
@@ -52,6 +53,10 @@ export default function WxUsersPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDeleted = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['wx-users'] })
|
||||||
|
}
|
||||||
|
|
||||||
const formatDate = (date: Date | string) => {
|
const formatDate = (date: Date | string) => {
|
||||||
return new Date(date).toLocaleDateString('zh-CN', {
|
return new Date(date).toLocaleDateString('zh-CN', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -189,6 +194,7 @@ export default function WxUsersPage() {
|
|||||||
open={isDialogOpen}
|
open={isDialogOpen}
|
||||||
onOpenChange={handleDialogOpenChange}
|
onOpenChange={handleDialogOpenChange}
|
||||||
user={userDetails}
|
user={userDetails}
|
||||||
|
onDeleted={handleDeleted}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
45
app/api/wx-users/level-progress/route.ts
Normal file
45
app/api/wx-users/level-progress/route.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
// DELETE /api/wx-users/level-progress - Batch delete level progress
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: request.headers,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ids } = await request.json()
|
||||||
|
|
||||||
|
if (!Array.isArray(ids) || ids.length === 0 || !ids.every((id) => typeof id === 'string')) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'ids must be a non-empty array of strings' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prisma.wxUserLevelProgress.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
in: ids,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ deleted: result.count })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting level progress:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to delete level progress' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import Image from 'next/image'
|
||||||
import { WxUserWithProgress } from '@/types'
|
import { WxUserWithProgress } from '@/types'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -8,38 +10,91 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
interface WxUserDetailDialogProps {
|
interface WxUserDetailDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
user: WxUserWithProgress | null | undefined
|
user: WxUserWithProgress | null | undefined
|
||||||
|
onDeleted?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WxUserDetailDialog({
|
export function WxUserDetailDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
user,
|
user,
|
||||||
|
onDeleted,
|
||||||
}: WxUserDetailDialogProps) {
|
}: WxUserDetailDialogProps) {
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
if (!user) return null
|
if (!user) return null
|
||||||
|
|
||||||
|
const handleSelectAll = (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedIds(new Set(user.levelProgress.map((p) => p.id)))
|
||||||
|
} else {
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectOne = (id: string, checked: boolean) => {
|
||||||
|
const newSelected = new Set(selectedIds)
|
||||||
|
if (checked) {
|
||||||
|
newSelected.add(id)
|
||||||
|
} else {
|
||||||
|
newSelected.delete(id)
|
||||||
|
}
|
||||||
|
setSelectedIds(newSelected)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBatchDelete = async () => {
|
||||||
|
if (selectedIds.size === 0) return
|
||||||
|
|
||||||
|
setIsDeleting(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/wx-users/level-progress', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ids: Array.from(selectedIds) }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
onDeleted?.()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSelected =
|
||||||
|
user.levelProgress.length > 0 &&
|
||||||
|
selectedIds.size === user.levelProgress.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>用户详情</DialogTitle>
|
<DialogTitle>用户详情</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>完整信息及关卡进度</DialogDescription>
|
||||||
完整信息及关卡进度
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="w-16 h-16 flex-shrink-0">
|
<div className="w-16 h-16 flex-shrink-0 relative">
|
||||||
{user.avatarUrl ? (
|
{user.avatarUrl ? (
|
||||||
<img
|
<Image
|
||||||
src={user.avatarUrl}
|
src={user.avatarUrl}
|
||||||
alt={user.nickname || 'User'}
|
alt={user.nickname || 'User'}
|
||||||
className="w-16 h-16 rounded-full object-cover"
|
fill
|
||||||
|
className="rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-16 h-16 rounded-full bg-gray-200 flex items-center justify-center text-xl font-medium text-gray-600">
|
<div className="w-16 h-16 rounded-full bg-gray-200 flex items-center justify-center text-xl font-medium text-gray-600">
|
||||||
@@ -77,28 +132,60 @@ export function WxUserDetailDialog({
|
|||||||
|
|
||||||
{user.levelProgress.length > 0 && (
|
{user.levelProgress.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-2">关卡进度</p>
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="space-y-2">
|
<p className="text-xs text-gray-500 uppercase tracking-wider">关卡进度</p>
|
||||||
{user.levelProgress.map((progress) => (
|
{selectedIds.size > 0 && (
|
||||||
<div
|
<Button
|
||||||
key={progress.id}
|
variant="destructive"
|
||||||
className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2"
|
size="sm"
|
||||||
|
onClick={handleBatchDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
{isDeleting ? '删除中...' : `删除已选 (${selectedIds.size})`}
|
||||||
<span className="text-xs text-gray-400 font-mono">
|
</Button>
|
||||||
{progress.levelId}
|
)}
|
||||||
</span>
|
</div>
|
||||||
{progress.level && (
|
<div className="border rounded-lg overflow-hidden">
|
||||||
<span className="text-sm font-medium text-gray-900">
|
<table className="w-full text-sm">
|
||||||
{progress.level.answer}
|
<thead className="bg-gray-50 border-b">
|
||||||
</span>
|
<tr>
|
||||||
)}
|
<th className="w-10 px-3 py-2 text-left">
|
||||||
</div>
|
<input
|
||||||
<span className="text-xs text-gray-400">
|
type="checkbox"
|
||||||
{new Date(progress.completedAt).toLocaleDateString('zh-CN')}
|
checked={allSelected}
|
||||||
</span>
|
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||||
</div>
|
className="rounded border-gray-300"
|
||||||
))}
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs text-gray-500 uppercase">关卡ID</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs text-gray-500 uppercase">关卡答案</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs text-gray-500 uppercase">通关时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{user.levelProgress.map((progress) => (
|
||||||
|
<tr key={progress.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIds.has(progress.id)}
|
||||||
|
onChange={(e) => handleSelectOne(progress.id, e.target.checked)}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs text-gray-400">
|
||||||
|
{progress.levelId}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 font-medium text-gray-900">
|
||||||
|
{progress.level?.answer || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-gray-500">
|
||||||
|
{new Date(progress.completedAt).toLocaleDateString('zh-CN')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user