import type { AdminImportAgcTemplateItemPayload, AdminImportAgcTemplatesManifest, } from '../api/adminApiTypes'; /** 与服务端 module-assets 的导入上限保持一致;超出请分批或改走 CLI。 */ export const TEMPLATE_IMPORT_MAX_BATCH = 20; export const TEMPLATE_UPLOAD_RUNTIMES = [ 'html', 'unity', 'godot', 'cocos', ] as const; const TEMPLATE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/u; const TEMPLATE_VERSION_PATTERN = /^[a-z0-9][a-z0-9._-]{0,31}$/u; const ENTRY_PATTERN = /^(?![\\/:])(?!.*\.\.)[^\s\\:]+$/u; const COVER_EXTENSION_PATTERN = /\.(png|jpe?g|webp)$/iu; export interface TemplateUploadRow { /** 稳定行标识:用 ZIP 文件名,重选文件后不会串行。 */ key: string; zipFile: File; coverFile: File | null; id: string; title: string; summary: string; tags: string; runtime: string; engine: string; engineVersion: string; templateVersion: string; entry: string; } /** 文件名 → 模板 ID:小写、只留白名单字符,并去掉 `..` 与开头的非字母数字。 */ export function sanitizeTemplateId(value: string) { return value .replace(/\.zip$/iu, '') .trim() .toLowerCase() .replace(/\.{2,}/gu, '.') .replace(/[^a-z0-9._-]+/gu, '-') .replace(/^[^a-z0-9]+/u, '') .slice(0, 64); } export function deriveTemplateUploadRow(zipFile: File): TemplateUploadRow { const id = sanitizeTemplateId(zipFile.name); return { key: zipFile.name, zipFile, coverFile: null, id, title: id, summary: '', tags: '', runtime: 'html', engine: '', engineVersion: '', templateVersion: '0.1.0', entry: 'index.html', }; } /** 封面按「与模板 ID 同名的图片」匹配,一次多选即可覆盖整批。 */ export function attachCoverFiles( rows: TemplateUploadRow[], coverFiles: File[], ): TemplateUploadRow[] { const covers = new Map(); for (const file of coverFiles) { if (!COVER_EXTENSION_PATTERN.test(file.name)) continue; const stem = sanitizeTemplateId( file.name.replace(COVER_EXTENSION_PATTERN, ''), ); if (!covers.has(stem)) covers.set(stem, file); } return rows.map((row) => { const matched = covers.get(row.id.trim().toLowerCase()) ?? null; return matched ? { ...row, coverFile: matched } : row; }); } export function parseTemplateTags(value: string) { const seen = new Set(); const tags: string[] = []; for (const raw of value.split(/[,,\s]+/u)) { const tag = raw.trim(); if (!tag || seen.has(tag)) continue; seen.add(tag); tags.push(tag); } return tags.slice(0, 16); } /** 逐行校验:返回 row.key → 错误文案;空对象表示整批可以提交。 */ export function validateTemplateUploadRows( rows: TemplateUploadRow[], ): Record { const errors: Record = {}; const seen = new Set(); const fail = (row: TemplateUploadRow, message: string) => { if (!errors[row.key]) errors[row.key] = message; }; if (rows.length === 0) return errors; if (rows.length > TEMPLATE_IMPORT_MAX_BATCH) { for (const row of rows) { fail(row, `单批最多上传 ${TEMPLATE_IMPORT_MAX_BATCH} 个模板`); } return errors; } for (const row of rows) { const id = row.id.trim(); if (!TEMPLATE_ID_PATTERN.test(id)) { fail( row, 'ID 必须是 1-64 位小写字母、数字、点、下划线或连字符,且以字母数字开头', ); } else if (seen.has(id)) { fail(row, 'ID 在本批次内重复'); } else { seen.add(id); } if (!row.title.trim() || row.title.trim().length > 80) { fail(row, '名称必须是 1-80 个字符'); } if (row.summary.trim().length > 1000) { fail(row, '简介最多 1000 个字符'); } if (!TEMPLATE_VERSION_PATTERN.test(row.templateVersion.trim())) { fail(row, '版本号必须是 1-32 位小写字母、数字、点、下划线或连字符'); } if ( !TEMPLATE_UPLOAD_RUNTIMES.includes( row.runtime as (typeof TEMPLATE_UPLOAD_RUNTIMES)[number], ) ) { fail(row, `运行时只能是 ${TEMPLATE_UPLOAD_RUNTIMES.join(' / ')}`); } if (!ENTRY_PATTERN.test(row.entry.trim())) { fail(row, 'entry 必须是模板包内的相对路径'); } if (!row.coverFile) { fail(row, '缺少封面:请上传与模板 ID 同名的 PNG / JPEG / WebP'); } } return errors; } export function buildTemplateImportManifest( expectedRevision: string, rows: TemplateUploadRow[], ): AdminImportAgcTemplatesManifest { return { expectedRevision, templates: rows.map((row, index) => { const item: AdminImportAgcTemplateItemPayload = { id: row.id.trim(), title: row.title.trim(), summary: row.summary.trim(), tags: parseTemplateTags(row.tags), runtime: row.runtime, engine: row.engine.trim(), engineVersion: row.engineVersion.trim(), templateVersion: row.templateVersion.trim(), entry: row.entry.trim(), zipField: `zip_${index}`, coverField: `cover_${index}`, }; return item; }), }; } export function buildTemplateImportFormData( expectedRevision: string, rows: TemplateUploadRow[], ): FormData { const form = new FormData(); form.append( 'manifest', JSON.stringify(buildTemplateImportManifest(expectedRevision, rows)), ); rows.forEach((row, index) => { form.append(`zip_${index}`, row.zipFile, row.zipFile.name); if (row.coverFile) { form.append(`cover_${index}`, row.coverFile, row.coverFile.name); } }); return form; }