feat: 支持批量上传关卡

This commit is contained in:
richarjiang
2026-05-01 08:44:56 +08:00
parent f3f27def2b
commit 66a9ee2950
27 changed files with 5262 additions and 515 deletions

View File

@@ -0,0 +1,563 @@
'use client'
import { useState, useRef, useEffect, useMemo } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Spinner } from '@/components/ui/spinner'
import { FolderOpen, Trash2, CheckCircle2, XCircle, AlertTriangle } from 'lucide-react'
import Image from 'next/image'
import { uploadToCos } from '@/lib/cos-client'
import { apiFetch } from '@/lib/api'
interface BatchImportDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSuccess: () => void
}
type ItemStatus = 'pending' | 'uploading' | 'creating' | 'done' | 'error'
interface ParsedItem {
id: string // riddle_id 或文件夹名,仅用于 React key
folderName: string
// 本地文件引用
referenceFile?: File
riddleFile?: File
// 预览 URL本地 blob URL
referencePreview?: string
riddlePreview?: string
// 从 metadata.json 提取
anchorText: string
answer: string
punchline: string
hint1: string
hint2: string
hint3: string
// 解析警告/错误信息
parseError?: string
// 上传/创建过程状态
status: ItemStatus
statusMessage?: string
}
interface FileWithPath extends File {
webkitRelativePath: string
}
interface RiddleMetadata {
plan?: {
riddle_id?: string
anchor_text?: string
answer?: string
hints?: string[]
riddle?: {
homophone_explanation?: string
}
}
}
function truncate(s: string | undefined, max: number): string {
if (!s) return ''
return s.length > max ? s.slice(0, max) : s
}
async function parseFolders(files: FileList): Promise<ParsedItem[]> {
// 按第一层目录名分组
const groups = new Map<string, FileWithPath[]>()
for (let i = 0; i < files.length; i += 1) {
const f = files[i] as FileWithPath
const rel = f.webkitRelativePath
if (!rel) continue
const parts = rel.split('/')
if (parts.length < 2) continue
// 用倒数第二层作为关卡目录名e.g. riddles/68f1.../metadata.json
// 但通常用户选的根目录就是 riddles第一层就是关卡目录
// 我们取「包含 metadata.json 的那个目录」作为分组 key
const folderKey = parts.slice(0, -1).join('/')
if (!groups.has(folderKey)) groups.set(folderKey, [])
groups.get(folderKey)!.push(f)
}
const items: ParsedItem[] = []
for (const [folderKey, groupFiles] of Array.from(groups.entries())) {
const metadata = groupFiles.find((f) => f.name === 'metadata.json')
const reference = groupFiles.find((f) => f.name === 'reference.webp')
const riddle = groupFiles.find((f) => f.name === 'riddle.webp')
// 只有同时存在 metadata.json + reference.webp + riddle.webp 的目录才视为关卡
if (!metadata || !reference || !riddle) continue
const folderName = folderKey.split('/').pop() || folderKey
const item: ParsedItem = {
id: folderKey,
folderName,
referenceFile: reference,
riddleFile: riddle,
referencePreview: URL.createObjectURL(reference),
riddlePreview: URL.createObjectURL(riddle),
anchorText: '',
answer: '',
punchline: '',
hint1: '',
hint2: '',
hint3: '',
status: 'pending',
}
try {
const text = await metadata.text()
const json = JSON.parse(text) as RiddleMetadata
const plan = json.plan || {}
item.anchorText = truncate(plan.anchor_text, 500)
item.answer = (plan.answer || '').trim()
item.punchline = (plan.riddle?.homophone_explanation || '').trim()
const hints = Array.isArray(plan.hints) ? plan.hints : []
item.hint1 = (hints[0] || '').trim()
item.hint2 = (hints[1] || '').trim()
item.hint3 = (hints[2] || '').trim()
if (!item.answer) {
item.parseError = '未解析到 answer'
} else if (item.answer.length > 4) {
item.parseError = `答案超过 4 字(${item.answer.length}`
}
} catch (e) {
item.parseError = `解析 metadata.json 失败: ${
e instanceof Error ? e.message : String(e)
}`
}
items.push(item)
}
// 稳定排序:按文件夹名
items.sort((a, b) => a.folderName.localeCompare(b.folderName))
return items
}
export function BatchImportDialog({
open,
onOpenChange,
onSuccess,
}: BatchImportDialogProps) {
const folderInputRef = useRef<HTMLInputElement>(null)
const [items, setItems] = useState<ParsedItem[]>([])
const [isParsing, setIsParsing] = useState(false)
const [isRunning, setIsRunning] = useState(false)
const [globalError, setGlobalError] = useState('')
// 关闭后清理预览 URL 并重置
useEffect(() => {
if (!open) {
items.forEach((it) => {
if (it.referencePreview) URL.revokeObjectURL(it.referencePreview)
if (it.riddlePreview) URL.revokeObjectURL(it.riddlePreview)
})
setItems([])
setGlobalError('')
setIsRunning(false)
setIsParsing(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
const stats = useMemo(() => {
const total = items.length
const done = items.filter((i) => i.status === 'done').length
const error = items.filter((i) => i.status === 'error').length
const invalid = items.filter((i) => i.parseError).length
return { total, done, error, invalid }
}, [items])
const canSubmit =
items.length > 0 &&
!isRunning &&
items.some((i) => i.status !== 'done' && !i.parseError)
const handleSelectFolder = () => {
folderInputRef.current?.click()
}
const handleFolderChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files || files.length === 0) return
setIsParsing(true)
setGlobalError('')
try {
const parsed = await parseFolders(files)
if (parsed.length === 0) {
setGlobalError(
'未在所选目录中找到任何有效关卡。请确保每个子目录同时包含 metadata.json、reference.webp、riddle.webp'
)
}
setItems(parsed)
} catch (err) {
setGlobalError(
err instanceof Error ? err.message : '解析失败'
)
} finally {
setIsParsing(false)
// 允许重新选择同一目录
if (folderInputRef.current) folderInputRef.current.value = ''
}
}
const updateItem = (id: string, patch: Partial<ParsedItem>) => {
setItems((prev) =>
prev.map((it) => {
if (it.id !== id) return it
const next = { ...it, ...patch }
// 每次编辑后重新评估答案的校验
if (patch.answer !== undefined) {
if (!next.answer) next.parseError = '未填写答案'
else if (next.answer.length > 4)
next.parseError = `答案超过 4 字(${next.answer.length}`
else next.parseError = undefined
}
return next
})
)
}
const removeItem = (id: string) => {
setItems((prev) => {
const target = prev.find((i) => i.id === id)
if (target) {
if (target.referencePreview) URL.revokeObjectURL(target.referencePreview)
if (target.riddlePreview) URL.revokeObjectURL(target.riddlePreview)
}
return prev.filter((i) => i.id !== id)
})
}
const handleRun = async () => {
setIsRunning(true)
setGlobalError('')
// 使用函数式 setItems 取到最新列表
const snapshot = items
for (const it of snapshot) {
if (it.status === 'done') continue
if (it.parseError) continue
if (!it.referenceFile || !it.riddleFile) continue
try {
updateItem(it.id, { status: 'uploading', statusMessage: '上传图片…' })
const [image1Url, image2Url] = await Promise.all([
uploadToCos(it.referenceFile, it.referenceFile.name),
uploadToCos(it.riddleFile, it.riddleFile.name),
])
updateItem(it.id, { status: 'creating', statusMessage: '创建关卡…' })
const payload = {
image1Url,
image1Description: it.anchorText || '',
image2Url,
image2Description: '',
answer: it.answer,
punchline: it.punchline || '',
hint1: it.hint1 || '',
hint2: it.hint2 || '',
hint3: it.hint3 || '',
}
const res = await apiFetch('/api/levels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.error || `HTTP ${res.status}`)
}
updateItem(it.id, { status: 'done', statusMessage: '已创建' })
} catch (e) {
updateItem(it.id, {
status: 'error',
statusMessage: e instanceof Error ? e.message : '失败',
})
}
}
setIsRunning(false)
// 触发列表刷新
onSuccess()
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-5xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
metadata.json
reference.webpriddle.webp
</DialogDescription>
</DialogHeader>
{globalError && (
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
{globalError}
</div>
)}
<div className="flex items-center gap-3">
<Button
type="button"
variant="outline"
onClick={handleSelectFolder}
disabled={isParsing || isRunning}
>
<FolderOpen className="h-4 w-4 mr-2" />
{items.length > 0 ? '重新选择目录' : '选择目录'}
</Button>
{isParsing && (
<span className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner size="sm" />
</span>
)}
{items.length > 0 && (
<span className="text-sm text-muted-foreground">
{stats.total} · {stats.done} · {stats.error}
{stats.invalid > 0 && ` · 待修正 ${stats.invalid}`}
</span>
)}
{/* 隐藏的文件夹选择 inputwebkitdirectory 让浏览器选择目录 */}
<input
ref={folderInputRef}
type="file"
className="hidden"
multiple
onChange={handleFolderChange}
{...({
webkitdirectory: '',
directory: '',
} as Record<string, string>)}
/>
</div>
<div className="flex-1 overflow-y-auto -mx-6 px-6">
{items.length === 0 ? (
<div className="text-center py-16 text-muted-foreground text-sm">
{isParsing ? '解析中…' : '尚未选择目录'}
</div>
) : (
<div className="space-y-4">
{items.map((it, idx) => (
<ItemCard
key={it.id}
index={idx + 1}
item={it}
disabled={isRunning}
onChange={(patch) => updateItem(it.id, patch)}
onRemove={() => removeItem(it.id)}
/>
))}
</div>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isRunning}
>
{stats.done > 0 ? '关闭' : '取消'}
</Button>
<Button type="button" onClick={handleRun} disabled={!canSubmit}>
{isRunning ? (
<>
<Spinner size="sm" className="mr-2" />
</>
) : (
`确认创建 (${items.filter((i) => i.status !== 'done' && !i.parseError).length})`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
interface ItemCardProps {
index: number
item: ParsedItem
disabled: boolean
onChange: (patch: Partial<ParsedItem>) => void
onRemove: () => void
}
function ItemCard({ index, item, disabled, onChange, onRemove }: ItemCardProps) {
const statusNode = (() => {
switch (item.status) {
case 'uploading':
case 'creating':
return (
<span className="flex items-center gap-1 text-blue-600 text-xs">
<Spinner size="sm" /> {item.statusMessage}
</span>
)
case 'done':
return (
<span className="flex items-center gap-1 text-green-600 text-xs">
<CheckCircle2 className="h-4 w-4" />
</span>
)
case 'error':
return (
<span className="flex items-center gap-1 text-red-600 text-xs">
<XCircle className="h-4 w-4" /> {item.statusMessage}
</span>
)
default:
if (item.parseError) {
return (
<span className="flex items-center gap-1 text-amber-600 text-xs">
<AlertTriangle className="h-4 w-4" /> {item.parseError}
</span>
)
}
return (
<span className="text-muted-foreground text-xs"></span>
)
}
})()
return (
<div className="border rounded-lg p-4 space-y-3 bg-card">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3 min-w-0">
<span className="text-sm font-medium text-muted-foreground">
#{index}
</span>
<span className="text-sm truncate" title={item.folderName}>
{item.folderName}
</span>
{statusNode}
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-red-600 hover:bg-red-50"
onClick={onRemove}
disabled={disabled || item.status === 'done'}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-[auto,1fr] gap-4">
{/* 左侧:图片预览 */}
<div className="flex gap-2">
<div className="space-y-1">
<div className="relative w-32 h-32 rounded border bg-gray-50 overflow-hidden">
{item.referencePreview && (
<Image
src={item.referencePreview}
alt="图片1"
fill
className="object-contain"
sizes="128px"
unoptimized
/>
)}
</div>
<p className="text-[11px] text-center text-muted-foreground">1 · reference</p>
</div>
<div className="space-y-1">
<div className="relative w-32 h-32 rounded border bg-gray-50 overflow-hidden">
{item.riddlePreview && (
<Image
src={item.riddlePreview}
alt="图片2"
fill
className="object-contain"
sizes="128px"
unoptimized
/>
)}
</div>
<p className="text-[11px] text-center text-muted-foreground">2 · riddle</p>
</div>
</div>
{/* 右侧:可编辑字段 */}
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 space-y-1">
<Label className="text-xs">1 anchor_text</Label>
<Textarea
value={item.anchorText}
onChange={(e) => onChange({ anchorText: e.target.value })}
rows={2}
disabled={disabled}
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> *</Label>
<Input
value={item.answer}
onChange={(e) => onChange({ answer: e.target.value })}
maxLength={4}
disabled={disabled}
/>
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
value={item.punchline}
onChange={(e) => onChange({ punchline: e.target.value })}
disabled={disabled}
/>
</div>
<div className="col-span-2 grid grid-cols-3 gap-2">
<div className="space-y-1">
<Label className="text-xs"> 1</Label>
<Input
value={item.hint1}
onChange={(e) => onChange({ hint1: e.target.value })}
disabled={disabled}
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> 2</Label>
<Input
value={item.hint2}
onChange={(e) => onChange({ hint2: e.target.value })}
disabled={disabled}
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> 3</Label>
<Input
value={item.hint3}
onChange={(e) => onChange({ hint3: e.target.value })}
disabled={disabled}
/>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,156 @@
'use client'
import Image from 'next/image'
import { AlertTriangle, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Spinner } from '@/components/ui/spinner'
import { Level } from '@/types'
interface DeleteConfirmDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
level: Level | null
/** 列表里的行号0-based用于展示 #序号 */
index?: number
isLoading?: boolean
onConfirm: () => void
}
/**
* 关卡删除二次确认弹窗。
* 展示关卡预览(双图 + 答案)让用户确认删除对象,而不是盲点。
* - 确认按钮 autoFocusEnter 直接确认Esc 由 Radix Dialog 默认关闭
* - 删除中禁用所有交互并显示 Spinner
*/
export function DeleteConfirmDialog({
open,
onOpenChange,
level,
index,
isLoading = false,
onConfirm,
}: DeleteConfirmDialogProps) {
return (
<Dialog
open={open}
onOpenChange={(next) => {
// 删除进行中不允许关闭,避免中途点取消造成状态不一致
if (isLoading && !next) return
onOpenChange(next)
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center sm:items-center sm:text-center">
{/* 红色警告图标 + 软光晕,视觉焦点 */}
<div className="relative mx-auto mb-2 flex h-14 w-14 items-center justify-center">
<div className="absolute inset-0 rounded-full bg-red-500/10" />
<div className="absolute inset-1.5 rounded-full bg-red-500/15" />
<AlertTriangle
className="relative h-7 w-7 text-red-600"
strokeWidth={2.25}
/>
</div>
<DialogTitle className="text-base"></DialogTitle>
<DialogDescription>
线
<span className="font-medium text-red-600"> </span>
</DialogDescription>
</DialogHeader>
{/* 关卡预览卡片 */}
{level && (
<div className="rounded-md border border-dashed bg-muted/40 p-3">
<div className="mb-2 flex items-center justify-between text-xs text-muted-foreground">
<span></span>
{typeof index === 'number' && index >= 0 && (
<span className="rounded bg-background px-1.5 py-0.5 font-mono text-[11px] tabular-nums">
#{index + 1}
</span>
)}
</div>
<div className="flex items-center gap-3">
<div className="flex flex-shrink-0 gap-1.5">
<div className="relative h-14 w-14 overflow-hidden rounded-md bg-background ring-1 ring-border">
{level.image1Url ? (
<Image
src={level.image1Url}
alt="图片1"
fill
className="object-cover"
sizes="56px"
/>
) : null}
</div>
<div className="relative h-14 w-14 overflow-hidden rounded-md bg-background ring-1 ring-border">
{level.image2Url ? (
<Image
src={level.image2Url}
alt="图片2"
fill
className="object-cover"
sizes="56px"
/>
) : null}
</div>
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-base font-semibold leading-tight">
{level.answer}
</div>
{level.punchline ? (
<div className="mt-1 truncate text-xs text-orange-600">
{level.punchline}
</div>
) : (
<div className="mt-1 text-xs text-muted-foreground">
</div>
)}
</div>
</div>
</div>
)}
<DialogFooter className="gap-2 sm:gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
</Button>
<Button
type="button"
variant="destructive"
onClick={onConfirm}
disabled={isLoading}
autoFocus
>
{isLoading ? (
<>
<Spinner size="sm" className="mr-2" />
...
</>
) : (
<>
<Trash2 className="mr-2 h-4 w-4" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -5,8 +5,7 @@ import { Button } from '@/components/ui/button'
import { X, Image as ImageIcon } from 'lucide-react'
import Image from 'next/image'
import { Spinner } from '@/components/ui/spinner'
import COS from 'cos-js-sdk-v5'
import { apiFetch } from '@/lib/api'
import { uploadToCos } from '@/lib/cos-client'
interface ImageUploaderProps {
value: string
@@ -38,52 +37,7 @@ export function ImageUploader({ value, onChange }: ImageUploaderProps) {
setIsUploading(true)
try {
// Get temp key
const keyRes = await apiFetch('/api/cos/temp-key')
if (!keyRes.ok) {
throw new Error('获取上传凭证失败')
}
const keyData = await keyRes.json()
// Generate unique filename
const ext = file.name.split('.').pop() || 'jpg'
const timestamp = Date.now()
const randomStr = Math.random().toString(36).substring(2, 8)
const filename = `mini_game/images/${timestamp}_${randomStr}.${ext}`
// Initialize COS with temp credentials
const cos = new COS({
getAuthorization: (_options, callback) => {
callback({
TmpSecretId: keyData.credentials.tmpSecretId,
TmpSecretKey: keyData.credentials.tmpSecretKey,
SecurityToken: keyData.credentials.sessionToken,
StartTime: keyData.startTime,
ExpiredTime: keyData.expiredTime,
})
},
})
// Upload file
const uploadUrl = await new Promise<string>((resolve, reject) => {
cos.putObject(
{
Bucket: keyData.bucket,
Region: keyData.region,
Key: filename,
Body: file,
},
(err, data) => {
if (err) {
reject(new Error(err.message || '上传失败'))
return
}
const url = `https://${keyData.bucket}.cos.${keyData.region}.myqcloud.com/${filename}`
resolve(url)
}
)
})
const uploadUrl = await uploadToCos(file, file.name)
onChange(uploadUrl)
} catch (err) {
console.error('Upload error:', err)

View File

@@ -1,156 +0,0 @@
'use client'
import { ColumnDef } from '@tanstack/react-table'
import { Level } from '@/types'
import { Button } from '@/components/ui/button'
import { Pencil, Trash2 } from 'lucide-react'
import Image from 'next/image'
interface ColumnCallbacks {
onEdit: (level: Level) => void
onDelete: (id: string) => void
deleteConfirmId: string | null
}
export function createColumns({
onEdit,
onDelete,
deleteConfirmId,
}: ColumnCallbacks): ColumnDef<Level>[] {
return [
{
accessorKey: 'sortOrder',
header: '序号',
cell: ({ row }) => (
<span className="text-muted-foreground">{row.original.sortOrder + 1}</span>
),
size: 60,
},
{
id: 'images',
header: '图片',
cell: ({ row }) => {
const { image1Url, image2Url } = row.original
return (
<div className="flex gap-1.5">
<div className="relative w-12 h-12 rounded overflow-hidden bg-gray-100 flex-shrink-0">
{image1Url ? (
<Image
src={image1Url}
alt="图片1"
fill
className="object-cover"
sizes="48px"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400 text-[10px]">
</div>
)}
</div>
<div className="relative w-12 h-12 rounded overflow-hidden bg-gray-100 flex-shrink-0">
{image2Url ? (
<Image
src={image2Url}
alt="图片2"
fill
className="object-cover"
sizes="48px"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400 text-[10px]">
</div>
)}
</div>
</div>
)
},
size: 120,
},
{
accessorKey: 'answer',
header: '答案',
cell: ({ row }) => (
<span className="font-medium">{row.original.answer}</span>
),
},
{
accessorKey: 'punchline',
header: '谐音梗',
cell: ({ row }) => {
const punchline = row.original.punchline
return punchline ? (
<span className="text-orange-600">{punchline}</span>
) : (
<span className="text-muted-foreground"></span>
)
},
},
{
id: 'hints',
header: '提示',
cell: ({ row }) => {
const { hint1, hint2, hint3 } = row.original
const hints = [hint1, hint2, hint3].filter(Boolean)
return hints.length > 0 ? (
<span className="text-sm text-muted-foreground truncate max-w-[200px] block">
{hints.join('、')}
</span>
) : (
<span className="text-muted-foreground"></span>
)
},
},
{
accessorKey: 'createdAt',
header: '创建时间',
cell: ({ row }) => {
const date = new Date(row.original.createdAt)
return (
<span className="text-sm text-muted-foreground whitespace-nowrap">
{date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})}
</span>
)
},
size: 120,
},
{
id: 'actions',
header: '操作',
cell: ({ row }) => {
const level = row.original
const isConfirming = deleteConfirmId === level.id
return (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => onEdit(level)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
size="icon"
className={`h-8 w-8 ${
isConfirming
? 'bg-red-600 text-white hover:bg-red-700 border-red-600'
: 'text-red-600 hover:text-red-700 hover:bg-red-50'
}`}
onClick={() => onDelete(level.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)
},
size: 100,
},
]
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -20,10 +20,19 @@ interface LevelDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
level?: Level | null
/** 当前关卡总数(含正在编辑的这条) */
totalCount: number
/** 编辑时当前行的 0-based index创建时传 null */
currentIndex: number | null
onSubmit: (data: LevelFormData) => Promise<void>
}
const defaultFormData: LevelFormData = {
type FormState = Omit<LevelFormData, 'position'> & {
/** 位置字段以字符串保存,便于处理"空输入"状态;提交时解析为数字 */
position: string
}
const defaultFormState: FormState = {
image1Url: '',
image1Description: '',
image2Url: '',
@@ -33,13 +42,35 @@ const defaultFormData: LevelFormData = {
hint1: '',
hint2: '',
hint3: '',
position: '',
}
export function LevelDialog({ open, onOpenChange, level, onSubmit }: LevelDialogProps) {
const [formData, setFormData] = useState<LevelFormData>(defaultFormData)
export function LevelDialog({
open,
onOpenChange,
level,
totalCount,
currentIndex,
onSubmit,
}: LevelDialogProps) {
const [formData, setFormData] = useState<FormState>(defaultFormState)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const isEdit = !!level
// 位置的合法范围与默认值
const { minPos, maxPos, defaultPos } = useMemo(() => {
if (isEdit) {
const min = 1
const max = Math.max(totalCount, 1)
const def = typeof currentIndex === 'number' ? currentIndex + 1 : max
return { minPos: min, maxPos: max, defaultPos: def }
}
const max = totalCount + 1
return { minPos: 1, maxPos: max, defaultPos: max }
}, [isEdit, totalCount, currentIndex])
// Reset form when dialog opens/closes or level changes
useEffect(() => {
if (open) {
@@ -54,13 +85,14 @@ export function LevelDialog({ open, onOpenChange, level, onSubmit }: LevelDialog
hint1: level.hint1 || '',
hint2: level.hint2 || '',
hint3: level.hint3 || '',
position: String(defaultPos),
})
} else {
setFormData(defaultFormData)
setFormData({ ...defaultFormState, position: String(defaultPos) })
}
setError('')
}
}, [open, level])
}, [open, level, defaultPos])
const handleImage1Upload = (url: string) => {
setFormData((prev) => ({ ...prev, image1Url: url }))
@@ -94,9 +126,39 @@ export function LevelDialog({ open, onOpenChange, level, onSubmit }: LevelDialog
return
}
// 位置:留空视为"不改位置"(编辑)/ "追加末尾"(创建)
let position: number | undefined
const raw = formData.position.trim()
if (raw !== '') {
const n = Number(raw)
if (!Number.isInteger(n) || n < minPos || n > maxPos) {
setError(`位置必须是 ${minPos}-${maxPos} 之间的整数`)
return
}
// 编辑场景:值没变则不上传 position避免后端无谓重算 sortKey
if (isEdit && typeof currentIndex === 'number' && n === currentIndex + 1) {
position = undefined
} else {
position = n
}
}
const payload: LevelFormData = {
image1Url: formData.image1Url,
image1Description: formData.image1Description,
image2Url: formData.image2Url,
image2Description: formData.image2Description,
answer: formData.answer,
punchline: formData.punchline,
hint1: formData.hint1,
hint2: formData.hint2,
hint3: formData.hint3,
...(position !== undefined ? { position } : {}),
}
setIsLoading(true)
try {
await onSubmit(formData)
await onSubmit(payload)
onOpenChange(false)
} catch (err) {
setError(err instanceof Error ? err.message : '操作失败,请稍后重试')
@@ -173,6 +235,39 @@ export function LevelDialog({ open, onOpenChange, level, onSubmit }: LevelDialog
</p>
</div>
<div className="space-y-2">
<Label htmlFor="position">
<span className="text-muted-foreground font-normal">*</span>
</Label>
<div className="flex items-center gap-3">
<Input
id="position"
type="number"
min={minPos}
max={maxPos}
step={1}
inputMode="numeric"
value={formData.position}
onChange={(e) =>
setFormData((prev) => ({ ...prev, position: e.target.value }))
}
className="w-32 font-mono tabular-nums"
placeholder={String(defaultPos)}
/>
<p className="text-xs text-muted-foreground">
<span className="font-mono">{minPos}</span> {' '}
<span className="font-mono">{maxPos}</span>
{isEdit && typeof currentIndex === 'number' && (
<>
{' · 当前第 '}
<span className="font-mono">{currentIndex + 1}</span>
</>
)}
{!isEdit && ' · 留空则追加到末尾'}
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="punchline"> ()</Label>
<Input

View File

@@ -0,0 +1,119 @@
'use client'
import { Level } from '@/types'
import { Button } from '@/components/ui/button'
import { Pencil, Trash2 } from 'lucide-react'
import Image from 'next/image'
// 列宽定义header 和 row 必须保持一致。用 grid-template-columns 统一控制。
// 序号 | 图片 | 答案 | 谐音梗 | 提示 | 创建时间 | 操作
export const GRID_TEMPLATE =
'minmax(60px,60px) minmax(120px,120px) minmax(80px,1fr) minmax(100px,1fr) minmax(160px,2fr) minmax(100px,100px) minmax(100px,100px)'
interface LevelRowProps {
level: Level
index: number
onEdit: (level: Level) => void
onDelete: (id: string) => void
}
export function LevelRow({ level, index, onEdit, onDelete }: LevelRowProps) {
return (
<div
style={{ gridTemplateColumns: GRID_TEMPLATE }}
className="grid items-center gap-3 px-3 border-b bg-background text-sm min-h-[64px] hover:bg-muted/30 transition-colors"
>
{/* 序号:用数组 index + 1而不是 DB 里已弃用的 sortOrder */}
<span className="font-mono tabular-nums text-muted-foreground">
{index + 1}
</span>
{/* 图片 */}
<div className="flex gap-1.5">
<div className="relative w-12 h-12 rounded overflow-hidden bg-gray-100 flex-shrink-0">
{level.image1Url ? (
<Image
src={level.image1Url}
alt="图片1"
fill
className="object-cover"
sizes="48px"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400 text-[10px]">
</div>
)}
</div>
<div className="relative w-12 h-12 rounded overflow-hidden bg-gray-100 flex-shrink-0">
{level.image2Url ? (
<Image
src={level.image2Url}
alt="图片2"
fill
className="object-cover"
sizes="48px"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400 text-[10px]">
</div>
)}
</div>
</div>
{/* 答案 */}
<span className="font-medium truncate">{level.answer}</span>
{/* 谐音梗 */}
<span className="truncate">
{level.punchline ? (
<span className="text-orange-600">{level.punchline}</span>
) : (
<span className="text-muted-foreground"></span>
)}
</span>
{/* 提示 */}
<span className="truncate text-muted-foreground">
{(() => {
const hints = [level.hint1, level.hint2, level.hint3].filter(Boolean)
return hints.length > 0 ? hints.join('、') : '—'
})()}
</span>
{/* 创建时间 */}
<span className="text-muted-foreground whitespace-nowrap">
{new Date(level.createdAt).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})}
</span>
{/* 操作 */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => onEdit(level)}
aria-label="编辑关卡"
title="编辑"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8 border-red-200 text-red-600 hover:border-red-600 hover:bg-red-600 hover:text-white focus-visible:ring-red-500"
onClick={() => onDelete(level.id)}
aria-label="删除关卡"
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)
}

View File

@@ -1,61 +1,40 @@
'use client'
import { useMemo } from 'react'
import {
useReactTable,
getCoreRowModel,
getPaginationRowModel,
flexRender,
} from '@tanstack/react-table'
import { useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { Level } from '@/types'
import { createColumns } from './level-columns'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
} from 'lucide-react'
import { GRID_TEMPLATE, LevelRow } from './level-row'
interface LevelTableProps {
levels: Level[]
onEdit: (level: Level) => void
onDelete: (id: string) => void
deleteConfirmId: string | null
}
export function LevelTable({
levels,
onEdit,
onDelete,
deleteConfirmId,
}: LevelTableProps) {
const columns = useMemo(
() => createColumns({ onEdit, onDelete, deleteConfirmId }),
[onEdit, onDelete, deleteConfirmId]
)
const HEADER_COLUMNS = [
'序号',
'图片',
'答案',
'谐音梗',
'提示',
'创建时间',
'操作',
]
const table = useReactTable({
data: levels,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: {
pagination: {
pageSize: 10,
},
},
export function LevelTable({ levels, onEdit, onDelete }: LevelTableProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const items = levels
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 64,
overscan: 10,
})
if (levels.length === 0) {
if (items.length === 0) {
return (
<div className="text-center py-12 text-gray-500">
<p></p>
@@ -67,105 +46,60 @@ export function LevelTable({
}
return (
<div className="space-y-4">
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="space-y-2">
<div className="rounded-md border overflow-hidden bg-background">
{/* 表头 */}
<div
className="grid items-center gap-3 px-3 py-2 bg-muted/40 text-xs font-medium text-muted-foreground border-b"
style={{ gridTemplateColumns: GRID_TEMPLATE }}
>
{HEADER_COLUMNS.map((h, i) => (
<span key={i}>{h}</span>
))}
</div>
{/* 分页控制栏 */}
<div className="flex items-center justify-between px-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span></span>
<select
className="h-8 rounded-md border border-input bg-background px-2 text-sm"
value={table.getState().pagination.pageSize}
onChange={(e) => {
table.setPageSize(Number(e.target.value))
{/* 虚拟滚动 */}
<div
ref={scrollRef}
className="overflow-auto"
style={{ height: 'calc(100vh - 260px)' }}
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
position: 'relative',
width: '100%',
}}
>
{[10, 20, 50].map((pageSize) => (
<option key={pageSize} value={pageSize}>
{pageSize}
</option>
))}
</select>
<span></span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{table.getState().pagination.pageIndex + 1} /{' '}
{table.getPageCount()} {levels.length}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<ChevronsRight className="h-4 w-4" />
</Button>
{rowVirtualizer.getVirtualItems().map((v) => {
const level = items[v.index]
return (
<div
key={level.id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${v.start}px)`,
}}
>
<LevelRow
level={level}
index={v.index}
onEdit={onEdit}
onDelete={onDelete}
/>
</div>
)
})}
</div>
</div>
</div>
<div className="px-2 text-xs text-muted-foreground">
{items.length} · /
</div>
</div>
)
}