后台模板上传(页面):模板管理页支持批量选择 ZIP 与封面并整批提交
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
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
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
getAdminAgcTemplates,
|
||||
getAdminFeatureGateConfig,
|
||||
getAdminUserDetail,
|
||||
importAdminAgcTemplates,
|
||||
listAdminRechargeOrders,
|
||||
reconcileAdminUserConsumption,
|
||||
resolveAdminRechargeRefundManualReview,
|
||||
@@ -65,6 +66,50 @@ test('模板管理读取和更新复用认证封装,提交 revision 和封面
|
||||
]);
|
||||
});
|
||||
|
||||
test('模板批量导入走 multipart,不预设 JSON Content-Type', async () => {
|
||||
const imported = {
|
||||
revision: 'rev-2',
|
||||
writable: true,
|
||||
templates: [],
|
||||
imported: [
|
||||
{
|
||||
id: 'alpha',
|
||||
templateVersion: '0.1.0',
|
||||
zipSizeBytes: 4,
|
||||
zipSha256: 'a'.repeat(64),
|
||||
reusedObjects: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ ok: true, data: imported }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
'manifest',
|
||||
JSON.stringify({ expectedRevision: 'rev-1', templates: [] }),
|
||||
);
|
||||
form.append(
|
||||
'zip_0',
|
||||
new File([new Uint8Array([1])], 'alpha.zip', { type: 'application/zip' }),
|
||||
);
|
||||
|
||||
expect(await importAdminAgcTemplates('admin-token', form)).toEqual(imported);
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toBe('/admin/api/agc-templates/import');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.body).toBe(form);
|
||||
expect(init.headers).not.toHaveProperty('Content-Type');
|
||||
expect(init.headers.Authorization).toBe('Bearer admin-token');
|
||||
});
|
||||
|
||||
test('后台账号创建和更新同时携带 Tab 与独立操作权限', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
AdminExternalApiKeyListQuery,
|
||||
AdminExternalApiKeyListResponse,
|
||||
AdminFeatureGateConfigResponse,
|
||||
AdminImportAgcTemplatesResponse,
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
AdminOverviewResponse,
|
||||
@@ -87,6 +88,8 @@ interface AdminRequestOptions {
|
||||
method?: string;
|
||||
token?: string;
|
||||
body?: unknown;
|
||||
/** multipart 表单:交给浏览器自己带 boundary,不能预设 Content-Type。 */
|
||||
formData?: FormData;
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
@@ -174,6 +177,8 @@ export async function request<T>(
|
||||
if (typeof options.body !== 'undefined') {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
init.body = JSON.stringify(options.body);
|
||||
} else if (options.formData) {
|
||||
init.body = options.formData;
|
||||
}
|
||||
|
||||
const response = await fetch(buildRequestUrl(path), init);
|
||||
@@ -1196,3 +1201,15 @@ export function updateAdminAgcTemplate(
|
||||
{ token, method: 'PUT', body },
|
||||
);
|
||||
}
|
||||
|
||||
/** 批量导入模板包:manifest 与 zip_N / cover_N 一起走 multipart,一批一次锁一次提交。 */
|
||||
export function importAdminAgcTemplates(token: string, formData: FormData) {
|
||||
return request<AdminImportAgcTemplatesResponse>(
|
||||
'/admin/api/agc-templates/import',
|
||||
{
|
||||
token,
|
||||
method: 'POST',
|
||||
formData,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1065,3 +1065,35 @@ export interface AdminUpdateAgcTemplateRequest {
|
||||
dataBase64: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AdminImportAgcTemplateItemPayload {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
tags: string[];
|
||||
runtime: string;
|
||||
engine: string;
|
||||
engineVersion: string;
|
||||
templateVersion: string;
|
||||
entry: string;
|
||||
zipField: string;
|
||||
coverField: string;
|
||||
}
|
||||
|
||||
export interface AdminImportAgcTemplatesManifest {
|
||||
expectedRevision: string;
|
||||
templates: AdminImportAgcTemplateItemPayload[];
|
||||
}
|
||||
|
||||
export interface AdminImportAgcTemplateResult {
|
||||
id: string;
|
||||
templateVersion: string;
|
||||
zipSizeBytes: number;
|
||||
zipSha256: string;
|
||||
reusedObjects: boolean;
|
||||
}
|
||||
|
||||
export interface AdminImportAgcTemplatesResponse
|
||||
extends AdminAgcTemplateLibraryResponse {
|
||||
imported: AdminImportAgcTemplateResult[];
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
AdminApiError,
|
||||
getAdminAgcTemplates,
|
||||
importAdminAgcTemplates,
|
||||
updateAdminAgcTemplate,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
@@ -24,6 +25,7 @@ import { AdminAgcTemplatesPage } from './AdminAgcTemplatesPage';
|
||||
vi.mock('../api/adminApiClient', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../api/adminApiClient')>()),
|
||||
getAdminAgcTemplates: vi.fn(),
|
||||
importAdminAgcTemplates: vi.fn(),
|
||||
updateAdminAgcTemplate: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -79,6 +81,21 @@ beforeEach(() => {
|
||||
: entry,
|
||||
),
|
||||
}));
|
||||
vi.mocked(importAdminAgcTemplates)
|
||||
.mockReset()
|
||||
.mockImplementation(async () => ({
|
||||
...structuredClone(library),
|
||||
revision: 'rev-imported',
|
||||
imported: [
|
||||
{
|
||||
id: 'alpha',
|
||||
templateVersion: '0.1.0',
|
||||
zipSizeBytes: 4,
|
||||
zipSha256: 'a'.repeat(64),
|
||||
reusedObjects: false,
|
||||
},
|
||||
],
|
||||
}));
|
||||
vi.stubGlobal(
|
||||
'URL',
|
||||
Object.assign(class extends URL {}, {
|
||||
@@ -496,3 +513,133 @@ test('读取失败显示真实错误,401 交给会话处理且不显示空库'
|
||||
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
|
||||
);
|
||||
});
|
||||
|
||||
function uploadZipFile(name: string) {
|
||||
return new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], name, {
|
||||
type: 'application/zip',
|
||||
});
|
||||
}
|
||||
|
||||
function uploadCoverFile(name: string) {
|
||||
return new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], name, {
|
||||
type: 'image/png',
|
||||
});
|
||||
}
|
||||
|
||||
async function openUploadDialog() {
|
||||
fireEvent.click(await screen.findByRole('button', { name: '上传模板' }));
|
||||
return screen.getByRole('dialog', { name: '上传模板' });
|
||||
}
|
||||
|
||||
function pickUploadFiles(dialog: HTMLElement, accept: string, files: File[]) {
|
||||
const input = dialog.querySelector<HTMLInputElement>(
|
||||
`input[accept="${accept}"]`,
|
||||
);
|
||||
if (!input) throw new Error(`missing file input for ${accept}`);
|
||||
fireEvent.change(input, { target: { files } });
|
||||
}
|
||||
|
||||
test('批量上传:多选 ZIP 生成行,封面匹配齐了才能提交', async () => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const dialog = await openUploadDialog();
|
||||
|
||||
pickUploadFiles(dialog, '.zip,application/zip', [
|
||||
uploadZipFile('alpha.zip'),
|
||||
uploadZipFile('beta.zip'),
|
||||
]);
|
||||
expect(within(dialog).getByText('alpha.zip')).not.toBeNull();
|
||||
expect(within(dialog).getByText('beta.zip')).not.toBeNull();
|
||||
expect(
|
||||
within(dialog)
|
||||
.getByRole('button', { name: /上传 2 个模板/ })
|
||||
.hasAttribute('disabled'),
|
||||
).toBe(true);
|
||||
expect(within(dialog).getAllByText('未匹配封面')).toHaveLength(2);
|
||||
|
||||
pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [
|
||||
uploadCoverFile('alpha.png'),
|
||||
uploadCoverFile('beta.png'),
|
||||
]);
|
||||
expect(within(dialog).getByText('alpha.png')).not.toBeNull();
|
||||
expect(
|
||||
within(dialog)
|
||||
.getByRole('button', { name: /上传 2 个模板/ })
|
||||
.hasAttribute('disabled'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('批量上传:确认后提交 manifest 与文件,成功后刷新列表', async () => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const dialog = await openUploadDialog();
|
||||
pickUploadFiles(dialog, '.zip,application/zip', [uploadZipFile('alpha.zip')]);
|
||||
pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [
|
||||
uploadCoverFile('alpha.png'),
|
||||
]);
|
||||
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: /上传 1 个模板/ }),
|
||||
);
|
||||
await confirmWrite();
|
||||
|
||||
await waitFor(() => expect(importAdminAgcTemplates).toHaveBeenCalledTimes(1));
|
||||
const formData = vi.mocked(importAdminAgcTemplates).mock.calls[0]![1];
|
||||
const manifest = JSON.parse(String(formData.get('manifest')));
|
||||
expect(manifest.expectedRevision).toBe('rev-1');
|
||||
expect(manifest.templates[0]).toMatchObject({
|
||||
id: 'alpha',
|
||||
zipField: 'zip_0',
|
||||
coverField: 'cover_0',
|
||||
});
|
||||
expect((formData.get('zip_0') as File).name).toBe('alpha.zip');
|
||||
expect((formData.get('cover_0') as File).name).toBe('alpha.png');
|
||||
expect(
|
||||
(await screen.findAllByText(/已导入 1 个模板/)).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('批量上传:冲突给出刷新引导', async () => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const dialog = await openUploadDialog();
|
||||
pickUploadFiles(dialog, '.zip,application/zip', [uploadZipFile('alpha.zip')]);
|
||||
pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [
|
||||
uploadCoverFile('alpha.png'),
|
||||
]);
|
||||
|
||||
vi.mocked(importAdminAgcTemplates).mockRejectedValueOnce(
|
||||
new AdminApiError({
|
||||
status: 409,
|
||||
message: '模板库已更新,请刷新后重新编辑',
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: /上传 1 个模板/ }),
|
||||
);
|
||||
await confirmWrite();
|
||||
expect(await screen.findByText(/请刷新列表后重试/)).not.toBeNull();
|
||||
expect(
|
||||
within(dialog)
|
||||
.getByRole('button', { name: /上传 1 个模板/ })
|
||||
.hasAttribute('disabled'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('批量上传:服务端拒绝时展示真实原因', async () => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const dialog = await openUploadDialog();
|
||||
pickUploadFiles(dialog, '.zip,application/zip', [uploadZipFile('alpha.zip')]);
|
||||
pickUploadFiles(dialog, 'image/png,image/jpeg,image/webp', [
|
||||
uploadCoverFile('alpha.png'),
|
||||
]);
|
||||
|
||||
vi.mocked(importAdminAgcTemplates).mockRejectedValueOnce(
|
||||
new AdminApiError({
|
||||
status: 400,
|
||||
message: 'alpha:模板包缺少清单声明的 entry:index.html',
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: /上传 1 个模板/ }),
|
||||
);
|
||||
await confirmWrite();
|
||||
expect(await screen.findByText(/模板包缺少清单声明的 entry/)).not.toBeNull();
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
TableRow,
|
||||
TextField,
|
||||
} from '@genarrative/shared/components';
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
import { RefreshCcw, Upload } from 'lucide-react';
|
||||
import {
|
||||
type FormEvent,
|
||||
useCallback,
|
||||
@@ -21,16 +21,28 @@ import {
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
formatAdminApiError,
|
||||
getAdminAgcTemplates,
|
||||
importAdminAgcTemplates,
|
||||
isAdminApiError,
|
||||
updateAdminAgcTemplate,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminAgcTemplateLibraryResponse,
|
||||
AdminAgcTemplatePayload,
|
||||
AdminImportAgcTemplateResult,
|
||||
AdminUpdateAgcTemplateRequest,
|
||||
} from '../api/adminApiTypes';
|
||||
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
||||
import {
|
||||
attachCoverFiles,
|
||||
buildTemplateImportFormData,
|
||||
deriveTemplateUploadRow,
|
||||
TEMPLATE_IMPORT_MAX_BATCH,
|
||||
TEMPLATE_UPLOAD_RUNTIMES,
|
||||
type TemplateUploadRow,
|
||||
validateTemplateUploadRows,
|
||||
} from './adminAgcTemplateUploadModel';
|
||||
import { handlePageError, splitLines } from './pageUtils';
|
||||
|
||||
type PageProps = { token: string; onUnauthorized: (message?: string) => void };
|
||||
@@ -39,6 +51,14 @@ type EditingTemplate = {
|
||||
revision: string;
|
||||
key: number;
|
||||
};
|
||||
|
||||
type TemplateUploadState = {
|
||||
rows: TemplateUploadRow[];
|
||||
errors: Record<string, string>;
|
||||
submitting: boolean;
|
||||
error: string;
|
||||
results: AdminImportAgcTemplateResult[] | null;
|
||||
};
|
||||
const runtimeLabels: Record<string, string> = {
|
||||
html: 'HTML',
|
||||
cocos: 'Cocos',
|
||||
@@ -63,6 +83,7 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {
|
||||
const [notice, setNotice] = useState('');
|
||||
const [conflict, setConflict] = useState(false);
|
||||
const [editing, setEditing] = useState<EditingTemplate | null>(null);
|
||||
const [upload, setUpload] = useState<TemplateUploadState | null>(null);
|
||||
const mounted = useRef(false);
|
||||
const readGeneration = useRef(0);
|
||||
const readController = useRef<AbortController | null>(null);
|
||||
@@ -165,6 +186,96 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {
|
||||
setEditing(null);
|
||||
}
|
||||
|
||||
function openUpload() {
|
||||
if (writesDisabled || !snapshot) return;
|
||||
setUpload({
|
||||
rows: [],
|
||||
errors: {},
|
||||
submitting: false,
|
||||
error: '',
|
||||
results: null,
|
||||
});
|
||||
}
|
||||
|
||||
function closeUpload() {
|
||||
if (upload?.submitting) return;
|
||||
setUpload(null);
|
||||
}
|
||||
|
||||
async function submitUpload(rows: TemplateUploadRow[]) {
|
||||
const revision = snapshot?.revision;
|
||||
if (
|
||||
!revision ||
|
||||
writing.current ||
|
||||
loading ||
|
||||
conflict ||
|
||||
!snapshot?.writable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const errors = validateTemplateUploadRows(rows);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setUpload((current) => (current ? { ...current, errors } : current));
|
||||
return;
|
||||
}
|
||||
writing.current = true;
|
||||
setUpload((current) =>
|
||||
current
|
||||
? { ...current, submitting: true, errors: {}, error: '', results: null }
|
||||
: current,
|
||||
);
|
||||
setError('');
|
||||
setNotice('');
|
||||
try {
|
||||
const confirmed = await confirmWrite({
|
||||
action: '上传模板',
|
||||
target: `${rows.length} 个模板(发布后不可删除,只能下架)`,
|
||||
});
|
||||
if (!confirmed || !mounted.current) return;
|
||||
const response = await importAdminAgcTemplates(
|
||||
token,
|
||||
buildTemplateImportFormData(revision, rows),
|
||||
);
|
||||
if (!mounted.current) return;
|
||||
readGeneration.current += 1;
|
||||
readController.current?.abort();
|
||||
setSnapshot({
|
||||
revision: response.revision,
|
||||
writable: response.writable,
|
||||
templates: response.templates,
|
||||
});
|
||||
setConflict(false);
|
||||
setNotice(`已导入 ${response.imported.length} 个模板`);
|
||||
setUpload((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
submitting: false,
|
||||
rows: [],
|
||||
results: response.imported,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!mounted.current) return;
|
||||
const conflictNow = isAdminApiError(error) && error.status === 409;
|
||||
setConflict(conflictNow);
|
||||
const message = formatUploadError(error);
|
||||
setUpload((current) =>
|
||||
current ? { ...current, submitting: false, error: message } : current,
|
||||
);
|
||||
// 上传失败只在弹窗里交代:401 仍要交给会话处理,其余错误不要同时打到列表页。
|
||||
if (isAdminApiError(error) && error.status === 401) {
|
||||
handlePageError(error, unauthorized.current, setError);
|
||||
}
|
||||
} finally {
|
||||
writing.current = false;
|
||||
setUpload((current) =>
|
||||
current ? { ...current, submitting: false } : current,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const terms = query.trim().toLocaleLowerCase().split(/\s+/u).filter(Boolean);
|
||||
const entries = (snapshot?.templates ?? []).filter((entry) => {
|
||||
const searchable = [entry.id, entry.title, ...entry.tags]
|
||||
@@ -186,14 +297,24 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {
|
||||
<section className="admin-page admin-page-wide admin-agc-templates genarrative-ui">
|
||||
<div className="admin-page-heading">
|
||||
<h2>模板管理</h2>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={loading || busy}
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
<RefreshCcw size={16} aria-hidden="true" />
|
||||
刷新
|
||||
</Button>
|
||||
<div className="admin-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={writesDisabled || !snapshot}
|
||||
onClick={openUpload}
|
||||
>
|
||||
<Upload size={16} aria-hidden="true" />
|
||||
上传模板
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={loading || busy}
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
<RefreshCcw size={16} aria-hidden="true" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{readOnly ? (
|
||||
<Status tone="warning">当前为只读模式,无法保存或上下架</Status>
|
||||
@@ -378,6 +499,41 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {
|
||||
) : (
|
||||
confirmDialog
|
||||
)}
|
||||
{upload ? (
|
||||
<Modal
|
||||
open
|
||||
title="上传模板"
|
||||
description={`一批最多 ${TEMPLATE_IMPORT_MAX_BATCH} 个模板;发布后不可删除,只能下架`}
|
||||
closeLabel="关闭模板上传"
|
||||
onClose={closeUpload}
|
||||
closeOnEscape={!upload.submitting}
|
||||
closeOnBackdrop={!upload.submitting}
|
||||
className="admin-agc-template-upload-dialog genarrative-ui"
|
||||
>
|
||||
<TemplateUploadDialog
|
||||
state={upload}
|
||||
disabled={writesDisabled}
|
||||
readOnly={readOnly}
|
||||
busy={busy}
|
||||
conflict={conflict}
|
||||
onRowsChange={(rows) =>
|
||||
setUpload((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
rows,
|
||||
errors: validateTemplateUploadRows(rows),
|
||||
}
|
||||
: current,
|
||||
)
|
||||
}
|
||||
onSubmit={() => void submitUpload(upload.rows)}
|
||||
onClose={closeUpload}
|
||||
onRefresh={() => void refresh()}
|
||||
/>
|
||||
{confirmDialog}
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -627,3 +783,266 @@ function formatTemplateSize(bytes: number) {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
function formatUploadError(error: unknown) {
|
||||
const message = formatAdminApiError(error);
|
||||
if (isAdminApiError(error) && error.status === 409) {
|
||||
return `${message}(模板库已更新,请刷新列表后重试)`;
|
||||
}
|
||||
if (isAdminApiError(error) && error.status === 503) {
|
||||
return `${message}(发布锁可能仍被占用,请联系运维核对)`;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
function TemplateUploadDialog({
|
||||
state,
|
||||
disabled,
|
||||
readOnly,
|
||||
busy,
|
||||
conflict,
|
||||
onRowsChange,
|
||||
onSubmit,
|
||||
onClose,
|
||||
onRefresh,
|
||||
}: {
|
||||
state: TemplateUploadState;
|
||||
disabled: boolean;
|
||||
readOnly: boolean;
|
||||
busy: boolean;
|
||||
conflict: boolean;
|
||||
onRowsChange: (rows: TemplateUploadRow[]) => void;
|
||||
onSubmit: () => void;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const uploadBusy = state.submitting;
|
||||
const rowErrorCount = Object.keys(state.errors).length;
|
||||
|
||||
function appendZipFiles(files: File[]) {
|
||||
const known = new Set(state.rows.map((row) => row.key));
|
||||
const next = [...state.rows];
|
||||
for (const file of files) {
|
||||
if (!/\.zip$/iu.test(file.name) || known.has(file.name)) continue;
|
||||
known.add(file.name);
|
||||
next.push(deriveTemplateUploadRow(file));
|
||||
}
|
||||
onRowsChange(next);
|
||||
}
|
||||
|
||||
function updateRow(key: string, patch: Partial<TemplateUploadRow>) {
|
||||
onRowsChange(
|
||||
state.rows.map((row) => (row.key === key ? { ...row, ...patch } : row)),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{readOnly ? (
|
||||
<Status tone="warning">当前为只读模式,无法上传</Status>
|
||||
) : null}
|
||||
<div className="admin-agc-template-upload-pickers">
|
||||
<label className="admin-agc-template-upload-picker">
|
||||
<span>模板包(可多选 .zip)</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
multiple
|
||||
disabled={disabled || uploadBusy}
|
||||
onChange={(event) => {
|
||||
appendZipFiles([...(event.currentTarget.files ?? [])]);
|
||||
event.currentTarget.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-agc-template-upload-picker">
|
||||
<span>封面(可多选,按同名模板 ID 匹配 PNG / JPEG / WebP)</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
disabled={disabled || uploadBusy}
|
||||
onChange={(event) => {
|
||||
onRowsChange(
|
||||
attachCoverFiles(state.rows, [
|
||||
...(event.currentTarget.files ?? []),
|
||||
]),
|
||||
);
|
||||
event.currentTarget.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{state.rows.length === 0 ? (
|
||||
<Status tone="info">
|
||||
还没有选择模板包。简介、标签与引擎可在上传后用「编辑」补齐。
|
||||
</Status>
|
||||
) : (
|
||||
<div className="admin-agc-template-upload-scroll">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>模板包</TableHeader>
|
||||
<TableHeader>ID</TableHeader>
|
||||
<TableHeader>名称</TableHeader>
|
||||
<TableHeader>版本</TableHeader>
|
||||
<TableHeader>运行时</TableHeader>
|
||||
<TableHeader>entry</TableHeader>
|
||||
<TableHeader>封面</TableHeader>
|
||||
<TableHeader>操作</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{state.rows.map((row) => {
|
||||
const rowError = state.errors[row.key];
|
||||
const rowDisabled = disabled || uploadBusy;
|
||||
return (
|
||||
<TableRow key={row.key}>
|
||||
<TableCell>
|
||||
<div className="admin-agc-template-upload-file">
|
||||
{row.zipFile.name}
|
||||
</div>
|
||||
{rowError ? (
|
||||
<div
|
||||
className="admin-agc-template-upload-row-error"
|
||||
role="alert"
|
||||
>
|
||||
{rowError}
|
||||
</div>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
label="ID"
|
||||
value={row.id}
|
||||
disabled={rowDisabled}
|
||||
onChange={(event) =>
|
||||
updateRow(row.key, { id: event.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
label="名称"
|
||||
value={row.title}
|
||||
disabled={rowDisabled}
|
||||
onChange={(event) =>
|
||||
updateRow(row.key, {
|
||||
title: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
label="版本"
|
||||
value={row.templateVersion}
|
||||
disabled={rowDisabled}
|
||||
onChange={(event) =>
|
||||
updateRow(row.key, {
|
||||
templateVersion: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SelectField
|
||||
label="运行时"
|
||||
value={row.runtime}
|
||||
disabled={rowDisabled}
|
||||
onChange={(event) =>
|
||||
updateRow(row.key, {
|
||||
runtime: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
{TEMPLATE_UPLOAD_RUNTIMES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{runtimeLabels[value] ?? value}
|
||||
</option>
|
||||
))}
|
||||
</SelectField>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TextField
|
||||
label="entry"
|
||||
value={row.entry}
|
||||
disabled={rowDisabled}
|
||||
onChange={(event) =>
|
||||
updateRow(row.key, {
|
||||
entry: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.coverFile ? (
|
||||
<span className="admin-agc-template-upload-file">
|
||||
{row.coverFile.name}
|
||||
</span>
|
||||
) : (
|
||||
<Status tone="warning">未匹配封面</Status>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={rowDisabled}
|
||||
onClick={() =>
|
||||
onRowsChange(
|
||||
state.rows.filter((entry) => entry.key !== row.key),
|
||||
)
|
||||
}
|
||||
>
|
||||
移除
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
{state.error ? (
|
||||
<Status tone="error" role="alert">
|
||||
{state.error}
|
||||
</Status>
|
||||
) : null}
|
||||
{conflict ? (
|
||||
<Button variant="secondary" disabled={busy} onClick={onRefresh}>
|
||||
刷新列表后重试
|
||||
</Button>
|
||||
) : null}
|
||||
{state.results ? (
|
||||
<Status tone="success">
|
||||
{`已导入 ${state.results.length} 个模板:${state.results
|
||||
.map((result) => `${result.id}@${result.templateVersion}`)
|
||||
.join('、')}`}
|
||||
</Status>
|
||||
) : null}
|
||||
<div className="admin-agc-template-dialog-actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={uploadBusy}
|
||||
onClick={onClose}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
disabled ||
|
||||
uploadBusy ||
|
||||
state.rows.length === 0 ||
|
||||
rowErrorCount > 0
|
||||
}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{uploadBusy ? '上传中…' : `上传 ${state.rows.length} 个模板`}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
attachCoverFiles,
|
||||
buildTemplateImportFormData,
|
||||
deriveTemplateUploadRow,
|
||||
parseTemplateTags,
|
||||
sanitizeTemplateId,
|
||||
TEMPLATE_IMPORT_MAX_BATCH,
|
||||
type TemplateUploadRow,
|
||||
validateTemplateUploadRows,
|
||||
} from './adminAgcTemplateUploadModel';
|
||||
|
||||
function zipFile(name: string) {
|
||||
return new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], name, {
|
||||
type: 'application/zip',
|
||||
});
|
||||
}
|
||||
|
||||
function coverFile(name: string) {
|
||||
return new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], name, {
|
||||
type: 'image/png',
|
||||
});
|
||||
}
|
||||
|
||||
function row(zipName: string, patch: Partial<TemplateUploadRow> = {}) {
|
||||
return { ...deriveTemplateUploadRow(zipFile(zipName)), ...patch };
|
||||
}
|
||||
|
||||
describe('sanitizeTemplateId', () => {
|
||||
it('lowercases, keeps whitelisted characters and drops traversal', () => {
|
||||
expect(sanitizeTemplateId('Cocos Empty 2D.zip')).toBe('cocos-empty-2d');
|
||||
expect(sanitizeTemplateId('../../evil.zip')).toBe('evil');
|
||||
expect(sanitizeTemplateId('a..b.zip')).toBe('a.b');
|
||||
expect(sanitizeTemplateId('__hidden__')).toBe('hidden__');
|
||||
expect(sanitizeTemplateId(`${'x'.repeat(90)}.zip`)).toHaveLength(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveTemplateUploadRow', () => {
|
||||
it('starts from safe defaults so a batch only needs covers attached', () => {
|
||||
const derived = row('my-template.zip');
|
||||
expect(derived.id).toBe('my-template');
|
||||
expect(derived.title).toBe('my-template');
|
||||
expect(derived.runtime).toBe('html');
|
||||
expect(derived.templateVersion).toBe('0.1.0');
|
||||
expect(derived.entry).toBe('index.html');
|
||||
expect(derived.coverFile).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachCoverFiles', () => {
|
||||
it('matches covers by template id and ignores non-image files', () => {
|
||||
const rows = [row('alpha.zip'), row('beta.zip')];
|
||||
const covers = [
|
||||
coverFile('alpha.png'),
|
||||
coverFile('beta.webp'),
|
||||
coverFile('notes.txt'),
|
||||
];
|
||||
|
||||
const next = attachCoverFiles(rows, covers);
|
||||
|
||||
expect(next[0]?.coverFile?.name).toBe('alpha.png');
|
||||
expect(next[1]?.coverFile?.name).toBe('beta.webp');
|
||||
});
|
||||
|
||||
it('keeps an already matched cover when the new selection has no partner', () => {
|
||||
const rows = [row('alpha.zip', { coverFile: coverFile('alpha.png') })];
|
||||
const next = attachCoverFiles(rows, [coverFile('other.png')]);
|
||||
expect(next[0]?.coverFile?.name).toBe('alpha.png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateTemplateUploadRows', () => {
|
||||
it('accepts a complete batch', () => {
|
||||
const rows = [
|
||||
row('alpha.zip', { coverFile: coverFile('alpha.png') }),
|
||||
row('beta.zip', { coverFile: coverFile('beta.png'), runtime: 'cocos' }),
|
||||
];
|
||||
expect(validateTemplateUploadRows(rows)).toEqual({});
|
||||
});
|
||||
|
||||
it('reports missing cover, duplicate id and invalid fields per row', () => {
|
||||
const rows = [
|
||||
row('alpha.zip'),
|
||||
row('alpha.zip', { coverFile: coverFile('alpha.png'), key: 'second' }),
|
||||
row('gamma.zip', {
|
||||
coverFile: coverFile('gamma.png'),
|
||||
runtime: 'docker',
|
||||
templateVersion: 'Bad Version',
|
||||
entry: '../escape.js',
|
||||
title: ' ',
|
||||
}),
|
||||
];
|
||||
const errors = validateTemplateUploadRows(rows);
|
||||
expect(errors['alpha.zip']).toContain('缺少封面');
|
||||
expect(errors.second).toContain('ID 在本批次内重复');
|
||||
expect(errors['gamma.zip']).toContain('名称必须是 1-80 个字符');
|
||||
});
|
||||
|
||||
it('rejects a batch larger than the server limit', () => {
|
||||
const rows = Array.from(
|
||||
{ length: TEMPLATE_IMPORT_MAX_BATCH + 1 },
|
||||
(_, index) =>
|
||||
row(`template-${index}.zip`, {
|
||||
coverFile: coverFile(`template-${index}.png`),
|
||||
}),
|
||||
);
|
||||
const errors = validateTemplateUploadRows(rows);
|
||||
expect(Object.keys(errors)).toHaveLength(TEMPLATE_IMPORT_MAX_BATCH + 1);
|
||||
expect(errors['template-0.zip']).toContain('单批最多上传');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTemplateImportFormData', () => {
|
||||
it('writes manifest field names and attaches zip / cover per row', () => {
|
||||
const rows = [
|
||||
row('alpha.zip', {
|
||||
coverFile: coverFile('alpha.png'),
|
||||
tags: '起步, 起步 2d',
|
||||
title: ' Alpha ',
|
||||
}),
|
||||
row('beta.zip', { coverFile: coverFile('beta.png') }),
|
||||
];
|
||||
|
||||
const form = buildTemplateImportFormData('a'.repeat(64), rows);
|
||||
const manifest = JSON.parse(String(form.get('manifest')));
|
||||
|
||||
expect(manifest.expectedRevision).toBe('a'.repeat(64));
|
||||
expect(manifest.templates).toHaveLength(2);
|
||||
expect(manifest.templates[0]).toMatchObject({
|
||||
id: 'alpha',
|
||||
title: 'Alpha',
|
||||
tags: ['起步', '2d'],
|
||||
zipField: 'zip_0',
|
||||
coverField: 'cover_0',
|
||||
});
|
||||
expect(manifest.templates[1]).toMatchObject({
|
||||
id: 'beta',
|
||||
zipField: 'zip_1',
|
||||
coverField: 'cover_1',
|
||||
});
|
||||
expect((form.get('zip_0') as File).name).toBe('alpha.zip');
|
||||
expect((form.get('cover_1') as File).name).toBe('beta.png');
|
||||
});
|
||||
|
||||
it('parses tags with dedupe and caps them at the server limit', () => {
|
||||
expect(parseTemplateTags(' a, a,b c ')).toEqual(['a', 'b', 'c']);
|
||||
const many = Array.from({ length: 20 }, (_, index) => `t${index}`).join(
|
||||
',',
|
||||
);
|
||||
expect(parseTemplateTags(many)).toHaveLength(16);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
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;
|
||||
}
|
||||
@@ -88,10 +88,60 @@
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
.admin-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-dialog {
|
||||
border-radius: 10px;
|
||||
max-width: min(1180px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-pickers {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-picker {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-picker input[type='file'] {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 上传行数多时表格自己滚动,窄屏不撑坏整页布局。 */
|
||||
.admin-agc-template-upload-scroll {
|
||||
max-height: min(52vh, 520px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-file {
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-row-error {
|
||||
margin-top: 4px;
|
||||
color: #b3261e;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.admin-agc-template-filters {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.admin-agc-template-upload-dialog {
|
||||
max-width: calc(100vw - 12px);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-09-21 后台模板上传:成品 ZIP + 每模板封面,批量全有或全无
|
||||
|
||||
- 背景:模板发布此前只有本地 CLI(源目录 + 确定性打包 + `--only`),后台上传需要一条不依赖本地仓库的通道,并支持一次提交多个模板。
|
||||
- 决策(输入形态):后台上传**成品 ZIP**(库内字节原样发布,不重新打包),每个模板必须同时给一张 PNG/JPEG/WebP 封面(与 CLI 源布局 `v1/<id>/cover.*` 一致),`id/title/templateVersion/runtime/entry` 由页面逐行确认;简介、标签与引擎上传后用编辑接口补齐。服务端按字节嗅探封面格式,不信任 multipart 声明的 content-type。
|
||||
- 决策(批量语义):一批 = 一把发布锁 + 一次清单提交,**全有或全无**;任一模板的 manifest/归档/封面不合法都在写入前整批拒绝并逐项给出原因。单批上限 20 个模板、单包 64 MiB、单封面 5 MiB、请求体 200 MiB。
|
||||
- 决策(校验与安全):归档只校验不落盘——合法 zip、无符号链接、无绝对路径 / `..` / 盘符条目、必须包含声明的 `entry`、条目数 ≤ 4096 且解压后 ≤ 512 MiB。
|
||||
- 决策(版本与保留):新 ID 默认上架;已存在 ID 就地更新并保留 `enabled` 分组、其它条目与未知扩展字段;同一 ID 同一 `templateVersion` 的 ZIP 字节不同时拒绝并要求递增版本,字节一致时按内容复用(`reusedObjects`)。
|
||||
- 决策(存储契约):`platform-oss` 的 `put_immutable` 为 `application/zip` 单独放宽到 64 MiB(此前只有 json/图片的 5 MiB),图片与元数据上限不变。
|
||||
- 影响范围:`server-rs/crates/{shared-contracts/api-server/module-assets/platform-oss}`、`apps/admin-web/src/{api,pages,styles}`、模板库技术方案与 `docs/project-memory/plans/【里程碑】后台模板上传-2026-09-21.md`。
|
||||
- 验证方式:`cargo test`(module-assets 11、platform-oss 12、api-server 定向 4)、`npm run admin-web:typecheck`、后台页面与模型 40 项 vitest、`check:encoding`、`check:doc-index`、`git diff --check`;真实 dev bucket 写入验证需用户显式确认。
|
||||
|
||||
## 2026-09-21 模板库线上产物对齐仓库源:递增版本重发 + 说明文档纳入发布
|
||||
|
||||
- 背景:`agc-dev` 上的 `templates/` 产物停在 2026-09-17 发布的那一版,仓库源在那之后改过(`5e4ff54a9` 删掉模板内嵌 `package-lock.json`、`game.js` / `main.js` 等内容调整),9 个模板里 7 个的 ZIP 与线上不一致;dry-run 被「同一 `templateVersion` 的 ZIP 不得变」门禁拒绝,发布器因此无法把仓库状态发上去。
|
||||
|
||||
@@ -22,7 +22,7 @@ AGC 客户端接入公共 OSS 上的**游戏模板库**(真·游戏模板,
|
||||
## 后台模板管理
|
||||
|
||||
- 后台新增 `#agc-templates`「模板管理」页签,复用现有后台布局、列表、公共表单/独立弹窗及写入确认。提供名称/ID/标签搜索、运行时和上下架筛选,展示封面、名称、简介、标签、引擎/版本、包大小与上架状态。
|
||||
- 本轮只允许编辑名称、简介、标签、封面和上架状态;不新增模板、不上传 ZIP、不修改 ID、运行时、引擎或模板版本,不删除包、历史对象或已建项目。
|
||||
- 编辑接口只允许改名称、简介、标签、封面和上架状态,不修改 ID、运行时、引擎或模板版本;**模板新增与 ZIP 上传见「后台模板上传」章节**。两条路径都不删除包、历史对象或已建项目。
|
||||
- 唯一数据真相仍是 OSS `templates/index.json`:`templates` 保存上架条目,新增可选 `inactiveTemplates` 保存下架条目,两个数组之间 ID 唯一。后台合并展示两组;AGC 客户端仍只读取 `templates`,刷新后不展示下架项。下架不是资源访问撤销,旧清单缓存和已下载项目不受影响。下架条目元数据位于公开清单,不承载私密草稿。
|
||||
- 后台读取/写入分别为 `GET /admin/api/agc-templates`、`PUT /admin/api/agc-templates/{id}`,均经过现有后台认证及 `agc-templates` 页签权限。owner 默认可用,member 需显式分配该权限;不新增数据库表或 schema。部署时后台、API 与引用权限白名单的 SpacetimeDB 模块需同步更新,成员账号才可保存新页签权限。
|
||||
- GET 返回 `{ revision, writable, templates }`:revision 是完整原始清单字节的 SHA-256;每条包含 `id/title/summary/tags/runtime/engine/engineVersion/templateVersion/enabled/coverUrl/zipSizeBytes`。不可用或格式错误返回可诊断错误,不能当作空模板库。
|
||||
@@ -33,6 +33,18 @@ AGC 客户端接入公共 OSS 上的**游戏模板库**(真·游戏模板,
|
||||
- CLI 与后台共享锁和清单合同。CLI 合并保留未选条目与 `inactiveTemplates`,更新下架模板仍保持下架;新增模板默认上架,CLI 不承担删除或上下架。显式发布选中 ID 时,展示字段按该模板源更新,后台编辑结果持续有效直到下一次显式发布该 ID。两端都必须保留另一组条目,不能因本地模板源较旧而抹掉后台记录。
|
||||
- 验收包含权限、入口挂载、过滤、编辑与图片校验、上下架往返、未知字段保留、过期 revision/锁争用、上传失败与未知提交、CLI 对下架项的更新/保留,以及桌面/窄屏真实浏览器验证。真实 OSS 写入和生产部署不在本轮验证范围,使用隔离存储替身。
|
||||
|
||||
## 后台模板上传
|
||||
|
||||
- 后台「模板管理」页提供「上传模板」入口:一次可多选 `.zip`,再按模板 ID 同名多选封面,逐行确认 ID / 名称 / 版本 / 运行时 / entry 后整批提交。简介、标签与引擎可在上传后用编辑接口补齐。
|
||||
- 接口 `POST /admin/api/agc-templates/import`(`multipart/form-data`):`manifest` 文本字段 + `zip_<index>` / `cover_<index>` 文件字段,下标与 manifest 条目顺序一一对应。manifest 为 `{ expectedRevision, templates: [{ id, title, summary, tags, runtime, engine, engineVersion, entry, templateVersion, zipField, coverField }] }`,禁止未知字段。
|
||||
- 限制:单批最多 20 个模板;单个 ZIP ≤ 64 MiB;单张封面 ≤ 5 MiB;请求体 ≤ 200 MiB;`runtime` 仅接受 `html / unity / godot / cocos`,`id` / `templateVersion` / `entry` 走既有标识符与相对路径白名单。存储层为 `application/zip` 单独放宽单对象上限到 64 MiB,图片与元数据仍是 5 MiB。
|
||||
- 语义:一批**全有或全无**。任一模板的 manifest 字段、归档或封面不合法,都在任何写入之前整批拒绝,并逐项给出模板 ID 与原因。
|
||||
- 归档校验不落盘:合法 zip、无符号链接、无绝对路径 / `..` / 盘符 / 反斜杠条目、必须包含 manifest 声明的 `entry`、条目数 ≤ 4096 且解压后总大小 ≤ 512 MiB。
|
||||
- 封面每个模板必填,按字节嗅探格式(只接受真实 PNG / JPEG / WebP),不信任 multipart 声明的 content-type;上限与编辑路径相同(5 MiB、单边 4096、1600 万像素)。SVG 仍只可能来自 CLI 历史发布。
|
||||
- 发布复用既有协议:ZIP 按上传字节原样发布(不重新打包、不删除历史对象),对象键为 `templates/v1/<id>/sha256/<摘要>/{template.zip,cover.*,template.json}`;在发布锁内先 CAS 校验 `expectedRevision`(过期返回 409),写入后逐个回读校验,最后提交一次清单;清单写入结果不明时保留锁并返回 503。断连由独立任务持有,不会在清单 PUT 在途时提前解锁。
|
||||
- 版本与保留语义:新 ID 默认上架;已存在 ID 就地更新并保留 `enabled` 分组、其它条目与未知扩展字段(含下架条目);同一 ID 同一 `templateVersion` 的 ZIP 字节不同时拒绝并要求递增版本(与 CLI 同一句文案),字节完全一致时按内容复用,响应里以 `reusedObjects` 标出。
|
||||
- 后台页面沿用既有写入确认、防重复提交与刷新语义:409 提示刷新后重试;上传失败只在弹窗内交代,401 仍交由会话处理。
|
||||
|
||||
## OSS 契约
|
||||
|
||||
```text
|
||||
|
||||
Reference in New Issue
Block a user