'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[] homophone_explanation?: string riddle?: { homophone_explanation?: string } } } interface RiddleConfigItem { riddle_id?: string answer?: string hints?: string[] homophone_explanation?: string anchor_text?: string reference_image?: string riddle_image?: string } function truncate(s: string | undefined, max: number): string { if (!s) return '' return s.length > max ? s.slice(0, max) : s } const ANSWER_MAX_LENGTH = 8 function normalizePath(path: string): string { return path.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+/g, '/') } function joinPath(base: string, path: string): string { const cleanPath = normalizePath(path) if (!base) return cleanPath return normalizePath(`${base}/${cleanPath}`) } function getRelativePathWithoutRoot(file: FileWithPath): string { const rel = normalizePath(file.webkitRelativePath) const [, ...rest] = rel.split('/') return rest.join('/') } function buildFilePathMap(files: FileList): Map { const fileByPath = new Map() for (let i = 0; i < files.length; i += 1) { const file = files[i] as FileWithPath const rel = normalizePath(file.webkitRelativePath) if (!rel) continue fileByPath.set(rel, file) const withoutRoot = getRelativePathWithoutRoot(file) if (withoutRoot) fileByPath.set(withoutRoot, file) } return fileByPath } function validateItem(item: ParsedItem) { if (!item.answer) { item.parseError = '未解析到 answer' } else if (item.answer.length > ANSWER_MAX_LENGTH) { item.parseError = `答案超过 ${ANSWER_MAX_LENGTH} 字(${item.answer.length})` } else if (!item.referenceFile || !item.riddleFile) { item.parseError = '未匹配到 reference_image 或 riddle_image 对应图片' } } async function parseConfigJson( jsonFile: FileWithPath, fileByPath: Map ): Promise { const text = await jsonFile.text() const json = JSON.parse(text) as unknown if (!Array.isArray(json)) return [] const jsonPath = normalizePath(jsonFile.webkitRelativePath) const jsonDir = jsonPath.split('/').slice(0, -1).join('/') const items: ParsedItem[] = [] json.forEach((raw, index) => { const config = raw as RiddleConfigItem if (!config || typeof config !== 'object') return const referencePath = config.reference_image || '' const riddlePath = config.riddle_image || '' const reference = fileByPath.get(joinPath(jsonDir, referencePath)) || fileByPath.get(normalizePath(referencePath)) const riddle = fileByPath.get(joinPath(jsonDir, riddlePath)) || fileByPath.get(normalizePath(riddlePath)) const id = config.riddle_id || `${jsonFile.name}-${index + 1}` const folderName = config.riddle_id || referencePath.split('/').slice(-2, -1)[0] || `第 ${index + 1} 关` const hints = Array.isArray(config.hints) ? config.hints : [] const item: ParsedItem = { id, folderName, referenceFile: reference, riddleFile: riddle, referencePreview: reference ? URL.createObjectURL(reference) : undefined, riddlePreview: riddle ? URL.createObjectURL(riddle) : undefined, anchorText: truncate(config.anchor_text, 500), answer: (config.answer || '').trim(), punchline: (config.homophone_explanation || '').trim(), hint1: (hints[0] || '').trim(), hint2: (hints[1] || '').trim(), hint3: (hints[2] || '').trim(), status: 'pending', } validateItem(item) items.push(item) }) return items } async function parseFolders(files: FileList): Promise { const fileByPath = buildFilePathMap(files) const jsonFiles = Array.from(fileByPath.values()).filter( (file, index, arr) => file.name.toLowerCase().endsWith('.json') && arr.indexOf(file) === index ) for (const jsonFile of jsonFiles) { try { const configItems = await parseConfigJson(jsonFile, fileByPath) if (configItems.length > 0) { return configItems } } catch { // Not the new array-based config. Fall back to legacy per-folder metadata. } } // 按第一层目录名分组 const groups = new Map() 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.homophone_explanation || 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() validateItem(item) } 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(null) const [items, setItems] = useState([]) 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) => { 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( '未在所选目录中找到任何有效关卡。请选择包含 riddles.json 和 riddle_images 的目录,或旧格式的 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) => { 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 > ANSWER_MAX_LENGTH) next.parseError = `答案超过 ${ANSWER_MAX_LENGTH} 字(${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 ( 批量导入关卡 选择包含 riddles.json 和 riddle_images 的目录;也兼容旧格式的多个关卡子文件夹。 {globalError && (
{globalError}
)}
{isParsing && ( 解析中… )} {items.length > 0 && ( 共 {stats.total} 个关卡 · 成功 {stats.done} · 失败 {stats.error} {stats.invalid > 0 && ` · 待修正 ${stats.invalid}`} )} {/* 隐藏的文件夹选择 input:webkitdirectory 让浏览器选择目录 */} )} />
{items.length === 0 ? (
{isParsing ? '解析中…' : '尚未选择目录'}
) : (
{items.map((it, idx) => ( updateItem(it.id, patch)} onRemove={() => removeItem(it.id)} /> ))}
)}
) } interface ItemCardProps { index: number item: ParsedItem disabled: boolean onChange: (patch: Partial) => void onRemove: () => void } function ItemCard({ index, item, disabled, onChange, onRemove }: ItemCardProps) { const statusNode = (() => { switch (item.status) { case 'uploading': case 'creating': return ( {item.statusMessage} ) case 'done': return ( 已创建 ) case 'error': return ( {item.statusMessage} ) default: if (item.parseError) { return ( {item.parseError} ) } return ( 待创建 ) } })() return (
#{index} {item.folderName} {statusNode}
{/* 左侧:图片预览 */}
{item.referencePreview && ( 图片1 )}

图1 · reference

{item.riddlePreview && ( 图片2 )}

图2 · riddle

{/* 右侧:可编辑字段 */}