Files
MemeStudio/components/levels/batch-import-dialog.tsx
2026-05-01 08:44:56 +08:00

564 lines
17 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 { 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>
)
}