perf: 支持删除关卡

This commit is contained in:
richarjiang
2026-04-05 20:31:56 +08:00
parent f8f6f17bd4
commit 6e19bfa661
3 changed files with 167 additions and 29 deletions

View File

@@ -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>
) )

View 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 }
)
}
}

View File

@@ -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>
)} )}