perf: 优化批量上传
This commit is contained in:
@@ -60,18 +60,133 @@ interface RiddleMetadata {
|
||||
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<string, FileWithPath> {
|
||||
const fileByPath = new Map<string, FileWithPath>()
|
||||
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<string, FileWithPath>
|
||||
): Promise<ParsedItem[]> {
|
||||
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<ParsedItem[]> {
|
||||
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<string, FileWithPath[]>()
|
||||
for (let i = 0; i < files.length; i += 1) {
|
||||
@@ -121,17 +236,17 @@ async function parseFolders(files: FileList): Promise<ParsedItem[]> {
|
||||
const plan = json.plan || {}
|
||||
item.anchorText = truncate(plan.anchor_text, 500)
|
||||
item.answer = (plan.answer || '').trim()
|
||||
item.punchline = (plan.riddle?.homophone_explanation || '').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()
|
||||
|
||||
if (!item.answer) {
|
||||
item.parseError = '未解析到 answer'
|
||||
} else if (item.answer.length > 4) {
|
||||
item.parseError = `答案超过 4 字(${item.answer.length})`
|
||||
}
|
||||
validateItem(item)
|
||||
} catch (e) {
|
||||
item.parseError = `解析 metadata.json 失败: ${
|
||||
e instanceof Error ? e.message : String(e)
|
||||
@@ -198,7 +313,7 @@ export function BatchImportDialog({
|
||||
const parsed = await parseFolders(files)
|
||||
if (parsed.length === 0) {
|
||||
setGlobalError(
|
||||
'未在所选目录中找到任何有效关卡。请确保每个子目录同时包含 metadata.json、reference.webp、riddle.webp'
|
||||
'未在所选目录中找到任何有效关卡。请选择包含 riddles.json 和 riddle_images 的目录,或旧格式的 metadata.json、reference.webp、riddle.webp 子目录'
|
||||
)
|
||||
}
|
||||
setItems(parsed)
|
||||
@@ -221,8 +336,8 @@ export function BatchImportDialog({
|
||||
// 每次编辑后重新评估答案的校验
|
||||
if (patch.answer !== undefined) {
|
||||
if (!next.answer) next.parseError = '未填写答案'
|
||||
else if (next.answer.length > 4)
|
||||
next.parseError = `答案超过 4 字(${next.answer.length})`
|
||||
else if (next.answer.length > ANSWER_MAX_LENGTH)
|
||||
next.parseError = `答案超过 ${ANSWER_MAX_LENGTH} 字(${next.answer.length})`
|
||||
else next.parseError = undefined
|
||||
}
|
||||
return next
|
||||
@@ -303,8 +418,7 @@ export function BatchImportDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>批量导入关卡</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择包含多个关卡子文件夹的目录。每个子文件夹须含 metadata.json、
|
||||
reference.webp、riddle.webp。
|
||||
选择包含 riddles.json 和 riddle_images 的目录;也兼容旧格式的多个关卡子文件夹。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -516,7 +630,7 @@ function ItemCard({ index, item, disabled, onChange, onRemove }: ItemCardProps)
|
||||
<Input
|
||||
value={item.answer}
|
||||
onChange={(e) => onChange({ answer: e.target.value })}
|
||||
maxLength={4}
|
||||
maxLength={ANSWER_MAX_LENGTH}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -226,12 +226,12 @@ export function LevelDialog({
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, answer: e.target.value }))
|
||||
}
|
||||
placeholder="请输入答案(最多4个字)"
|
||||
maxLength={4}
|
||||
placeholder="请输入答案(最多8个字)"
|
||||
maxLength={8}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{formData.answer.length}/4
|
||||
{formData.answer.length}/8
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user