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