2e23a54211
Project CI / AI game creator shell Rust crates (push) Successful in 3m2s
Project CI / AI game creator shell Rust smoke (push) Successful in 4m5s
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
- 新增上传模型:文件名派生模板 ID、按同名匹配封面、逐行字段校验与 multipart 组装(标签去重与上限) - 模板管理页新增「上传模板」入口与弹窗:多选 ZIP、按 ID 同名多选封面、逐行编辑 ID/名称/版本/运行时/entry、逐行错误、沿用写入确认与防重复提交 - 失败只在弹窗内交代并区分 409 刷新引导与 503 锁占用提示,401 仍交由会话处理;成功后用服务端快照刷新列表并展示逐条导入结果 - admin API client 支持 multipart 请求(不预设 JSON Content-Type),新增 importAdminAgcTemplates 与对应类型 - 主规范新增「后台模板上传」章节并收口原「不上传 ZIP」表述;决策记录补一条 - 验证:admin-web typecheck、页面/模型/客户端 40 项 vitest、check:doc-index、check:encoding、cargo fmt --check、git diff --check
192 lines
5.6 KiB
TypeScript
192 lines
5.6 KiB
TypeScript
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<string, File>();
|
||
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<string>();
|
||
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<string, string> {
|
||
const errors: Record<string, string> = {};
|
||
const seen = new Set<string>();
|
||
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;
|
||
}
|