新增后台 AGC 模板管理与模板库内容寻址落库
Project CI / AI game creator shell Rust crates (push) Successful in 2m53s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m42s
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 2m53s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m42s
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
- 后台新增「模板管理」页:按权限查看并编辑模板名称/简介/标签/封面,支持上架与下架;路由、侧栏、tab 权限、API 客户端与 DTO 同步接入 - api-server 新增 /admin/api/agc-templates 读取与更新路由,复用现有后台鉴权与 tab 权限校验,权限不足按既有失败关闭口径拒绝 - module-assets 收敛模板库纯规则(active/inactive 投影、索引 schema 与重复项校验、ZIP 大小与摘要核对),platform-oss 承担对象存储读写、不可变对象复用与发布互斥锁 - shared-contracts 补后台模板 DTO;spacetime-module 账号存储适配保持既有 schema 不变 - AGC 壳内置模板索引与建项读取改为内容寻址对象,补模板安装、越界归档、索引不匹配等用例 - 模板库发布脚本支持定向发布与上架/下架合并、内容地址复用与锁语义,CLI 守卫用例同步;jenkins 两条客户端打包管线统一触发邮件通知 Job 并传递首装包链接 - 内置 5 个 Cocos 官方模板载荷与索引 fixture(内容地址对象,共 90 个文件) - 补「后台模板管理」里程碑与实施计划,更新模板库技术方案、开发运维文档与共享记忆 - 验证:admin-web 39 项用例与 typecheck、发布脚本 25 项用例、api-server 与 AGC 壳编译、模板库定向 Rust 用例(AGC 壳 18 项、module-assets/platform-oss 16 项、后台权限 1 项)、check:encoding / check:doc-index / check:production-ops / rustfmt 全部通过
This commit is contained in:
@@ -3,12 +3,14 @@ import { afterEach, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
createAdminAccount,
|
||||
executeAdminRechargeRefund,
|
||||
getAdminAgcTemplates,
|
||||
getAdminFeatureGateConfig,
|
||||
getAdminUserDetail,
|
||||
listAdminRechargeOrders,
|
||||
reconcileAdminUserConsumption,
|
||||
resolveAdminRechargeRefundManualReview,
|
||||
updateAdminAccount,
|
||||
updateAdminAgcTemplate,
|
||||
uploadAdminEditorShowcaseCampaignImage,
|
||||
upsertAdminFeatureGateConfig,
|
||||
upsertProfileWalletConfig,
|
||||
@@ -18,6 +20,51 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('模板管理读取和更新复用认证封装,提交 revision 和封面但不提交 ZIP 或版本', async () => {
|
||||
const library = { revision: 'revision-new', writable: true, templates: [] };
|
||||
const fetchMock = vi.fn().mockImplementation(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ ok: true, data: library }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const controller = new AbortController();
|
||||
expect(await getAdminAgcTemplates('admin-token', controller.signal)).toEqual(
|
||||
library,
|
||||
);
|
||||
const update = {
|
||||
expectedRevision: 'revision-old',
|
||||
title: '空白模板',
|
||||
summary: '简介',
|
||||
tags: ['2D'],
|
||||
enabled: true,
|
||||
cover: { contentType: 'image/png', dataBase64: 'aW1hZ2U=' },
|
||||
};
|
||||
expect(
|
||||
await updateAdminAgcTemplate('admin-token', 'template/1', update),
|
||||
).toEqual(library);
|
||||
expect(fetchMock.mock.calls[0]).toEqual([
|
||||
'/admin/api/agc-templates',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
|
||||
}),
|
||||
]);
|
||||
expect(fetchMock.mock.calls[1]).toEqual([
|
||||
'/admin/api/agc-templates/template%2F1',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer admin-token',
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: JSON.stringify(update),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('后台账号创建和更新同时携带 Tab 与独立操作权限', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
AdminAccountListResponse,
|
||||
AdminAgcTemplateLibraryResponse,
|
||||
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
|
||||
AdminCreateAccountRequest,
|
||||
AdminCreateAccountResponse,
|
||||
@@ -47,6 +48,7 @@ import type {
|
||||
AdminTrackingEventListResponse,
|
||||
AdminUpdateAccountRequest,
|
||||
AdminUpdateAccountResponse,
|
||||
AdminUpdateAgcTemplateRequest,
|
||||
AdminUploadedEditorShowcaseCampaignImage,
|
||||
AdminUpsertEditorShowcaseCampaignRequest,
|
||||
AdminUpsertFeatureGateConfigRequest,
|
||||
@@ -1176,3 +1178,21 @@ export function saveAgcModelCatalog(
|
||||
{ token, method: 'PUT', body },
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminAgcTemplates(token: string, signal?: AbortSignal) {
|
||||
return request<AdminAgcTemplateLibraryResponse>('/admin/api/agc-templates', {
|
||||
token,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAdminAgcTemplate(
|
||||
token: string,
|
||||
id: string,
|
||||
body: AdminUpdateAgcTemplateRequest,
|
||||
) {
|
||||
return request<AdminAgcTemplateLibraryResponse>(
|
||||
`/admin/api/agc-templates/${encodeURIComponent(id)}`,
|
||||
{ token, method: 'PUT', body },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1033,3 +1033,35 @@ export interface AdminAgcModelCatalog {
|
||||
defaultModelId: string;
|
||||
models: AdminAgcModel[];
|
||||
}
|
||||
|
||||
export interface AdminAgcTemplatePayload {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
tags: string[];
|
||||
runtime: string;
|
||||
engine: string;
|
||||
engineVersion: string;
|
||||
templateVersion: string;
|
||||
enabled: boolean;
|
||||
coverUrl: string;
|
||||
zipSizeBytes: number;
|
||||
}
|
||||
|
||||
export interface AdminAgcTemplateLibraryResponse {
|
||||
revision: string;
|
||||
writable: boolean;
|
||||
templates: AdminAgcTemplatePayload[];
|
||||
}
|
||||
|
||||
export interface AdminUpdateAgcTemplateRequest {
|
||||
expectedRevision: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
tags: string[];
|
||||
enabled: boolean;
|
||||
cover?: {
|
||||
contentType: string;
|
||||
dataBase64: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../auth/adminAuthStore';
|
||||
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
|
||||
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
|
||||
import { AdminAgcTemplatesPage } from '../pages/AdminAgcTemplatesPage';
|
||||
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
|
||||
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
|
||||
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
|
||||
@@ -294,6 +295,12 @@ export function AdminApp() {
|
||||
{activeRouteId === 'agc-models' ? (
|
||||
<AdminAgcModelsPage token={token} onUnauthorized={handleUnauthorized} />
|
||||
) : null}
|
||||
{activeRouteId === 'agc-templates' ? (
|
||||
<AdminAgcTemplatesPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'editor-showcase' ? (
|
||||
<AdminEditorShowcaseReviewPage
|
||||
token={token}
|
||||
|
||||
@@ -53,6 +53,7 @@ const routeIcons = {
|
||||
'project-snapshots': FolderArchive,
|
||||
accounts: Users,
|
||||
'agc-models': ListChecks,
|
||||
'agc-templates': Images,
|
||||
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
|
||||
|
||||
export function AdminShell({
|
||||
|
||||
@@ -147,3 +147,30 @@ test('项目工程入口对 owner 与已授权 member 开放且可分配权限',
|
||||
}),
|
||||
).not.toContainEqual(route);
|
||||
});
|
||||
|
||||
test('模板管理只对 owner 或具有 agc-templates 权限的 member 可见', () => {
|
||||
expect(adminRoutes).toContainEqual({
|
||||
id: 'agc-templates',
|
||||
label: '模板管理',
|
||||
hash: '#agc-templates',
|
||||
});
|
||||
expect(resolveAdminRoute('#agc-templates')).toBe('agc-templates');
|
||||
expect(routeHash('agc-templates')).toBe('#agc-templates');
|
||||
expect(
|
||||
getAccessibleAdminRoutes({ accountRole: 'owner', tabPermissions: [] }).some(
|
||||
(route) => route.id === 'agc-templates',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
tabPermissions: ['agc-templates'],
|
||||
}).map((route) => route.id),
|
||||
).toEqual(['agc-templates']);
|
||||
expect(
|
||||
getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
tabPermissions: ['editor-assets'],
|
||||
}).some((route) => route.id === 'agc-templates'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ export type AdminRouteId =
|
||||
| 'editor-assets'
|
||||
| 'project-snapshots'
|
||||
| 'agc-models'
|
||||
| 'agc-templates'
|
||||
| 'accounts';
|
||||
|
||||
export type AdminTabPermission = Exclude<
|
||||
@@ -53,6 +54,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
hash: '#editor-generation-pricing',
|
||||
},
|
||||
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
|
||||
{ id: 'agc-templates', label: '模板管理', hash: '#agc-templates' },
|
||||
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
|
||||
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
|
||||
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '@genarrative/shared/styles.css';
|
||||
import './styles/admin.css';
|
||||
|
||||
import { StrictMode } from 'react';
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
// @vitest-environment jsdom
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
AdminApiError,
|
||||
getAdminAgcTemplates,
|
||||
updateAdminAgcTemplate,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminAgcTemplateLibraryResponse,
|
||||
AdminAgcTemplatePayload,
|
||||
} from '../api/adminApiTypes';
|
||||
import { AdminAgcTemplatesPage } from './AdminAgcTemplatesPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../api/adminApiClient')>()),
|
||||
getAdminAgcTemplates: vi.fn(),
|
||||
updateAdminAgcTemplate: vi.fn(),
|
||||
}));
|
||||
|
||||
const template: AdminAgcTemplatePayload = {
|
||||
id: 'cocos-empty-2d',
|
||||
title: '空白 2D',
|
||||
summary: '二维项目',
|
||||
tags: ['2D', '入门'],
|
||||
runtime: 'cocos',
|
||||
engine: 'Cocos Creator',
|
||||
engineVersion: '3.8.8',
|
||||
templateVersion: '1',
|
||||
enabled: true,
|
||||
coverUrl: 'https://example.test/cover.png',
|
||||
zipSizeBytes: 2048,
|
||||
};
|
||||
const library: AdminAgcTemplateLibraryResponse = {
|
||||
revision: 'rev-1',
|
||||
writable: true,
|
||||
templates: [
|
||||
template,
|
||||
{
|
||||
...template,
|
||||
id: 'godot-starter',
|
||||
title: 'Godot 起步',
|
||||
runtime: 'godot',
|
||||
engine: 'Godot',
|
||||
engineVersion: '4',
|
||||
tags: ['3D'],
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getAdminAgcTemplates)
|
||||
.mockReset()
|
||||
.mockImplementation(async () => structuredClone(library));
|
||||
vi.mocked(updateAdminAgcTemplate)
|
||||
.mockReset()
|
||||
.mockImplementation(async (_token, id, update) => ({
|
||||
...structuredClone(library),
|
||||
revision: 'rev-2',
|
||||
templates: library.templates.map((entry) =>
|
||||
entry.id === id
|
||||
? {
|
||||
...entry,
|
||||
title: update.title,
|
||||
summary: update.summary,
|
||||
tags: update.tags,
|
||||
enabled: update.enabled,
|
||||
}
|
||||
: entry,
|
||||
),
|
||||
}));
|
||||
vi.stubGlobal(
|
||||
'URL',
|
||||
Object.assign(class extends URL {}, {
|
||||
createObjectURL: vi.fn(() => 'blob:template-cover'),
|
||||
revokeObjectURL: vi.fn(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function openEditor() {
|
||||
fireEvent.click(await screen.findByRole('button', { name: '编辑 空白 2D' }));
|
||||
return screen.getByRole('dialog', { name: '编辑模板' });
|
||||
}
|
||||
|
||||
async function confirmWrite() {
|
||||
const confirmation = await screen.findByRole('dialog', { name: '确认操作' });
|
||||
fireEvent.click(within(confirmation).getByRole('button', { name: '确认' }));
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason: unknown) => void;
|
||||
const promise = new Promise<T>((next, fail) => {
|
||||
resolve = next;
|
||||
reject = fail;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
test('按名称、ID、标签、运行时和上下架状态筛选,展示版本与封面', async () => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByText('空白 2D');
|
||||
expect(screen.getByText('Cocos Creator 3.8.8')).not.toBeNull();
|
||||
expect(screen.getAllByText('模板 1')).toHaveLength(2);
|
||||
expect(screen.getAllByText('2.0 KiB')).toHaveLength(2);
|
||||
expect(
|
||||
screen.getByRole('img', { name: '空白 2D封面' }).getAttribute('src'),
|
||||
).toBe(template.coverUrl);
|
||||
for (const value of ['空白', 'cocos-empty-2d', '入门']) {
|
||||
fireEvent.change(screen.getByLabelText('搜索模板'), { target: { value } });
|
||||
expect(screen.getByText('空白 2D')).not.toBeNull();
|
||||
expect(screen.queryByText('Godot 起步')).toBeNull();
|
||||
}
|
||||
fireEvent.change(screen.getByLabelText('搜索模板'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('运行时'), {
|
||||
target: { value: 'godot' },
|
||||
});
|
||||
expect(screen.queryByText('空白 2D')).toBeNull();
|
||||
fireEvent.change(screen.getByLabelText('上架状态'), {
|
||||
target: { value: 'enabled' },
|
||||
});
|
||||
expect(screen.getByText('没有符合筛选条件的模板')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('独立弹窗保存经写确认,只提交展示字段和冻结 revision', async () => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const editor = await openEditor();
|
||||
expect(editor.closest('.admin-agc-templates')).toBeNull();
|
||||
fireEvent.change(within(editor).getByLabelText('名称'), {
|
||||
target: { value: ' 新名称 ' },
|
||||
});
|
||||
fireEvent.change(within(editor).getByLabelText('简介'), {
|
||||
target: { value: '新简介' },
|
||||
});
|
||||
fireEvent.change(within(editor).getByLabelText('标签'), {
|
||||
target: { value: '2D, 新手,2D' },
|
||||
});
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
|
||||
expect(updateAdminAgcTemplate).not.toHaveBeenCalled();
|
||||
await confirmWrite();
|
||||
await waitFor(() =>
|
||||
expect(updateAdminAgcTemplate).toHaveBeenCalledWith('token', template.id, {
|
||||
expectedRevision: 'rev-1',
|
||||
title: '新名称',
|
||||
summary: '新简介',
|
||||
tags: ['2D', '新手'],
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: '编辑模板' })).toBeNull(),
|
||||
);
|
||||
expect(screen.getByText('新名称')).not.toBeNull();
|
||||
expect(screen.getByText('已保存')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('上下架复用确认且只用服务端结果更新列表', async () => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '下架 空白 2D' }));
|
||||
await confirmWrite();
|
||||
await waitFor(() =>
|
||||
expect(updateAdminAgcTemplate).toHaveBeenCalledWith('token', template.id, {
|
||||
expectedRevision: 'rev-1',
|
||||
title: template.title,
|
||||
summary: template.summary,
|
||||
tags: template.tags,
|
||||
enabled: false,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '上架 空白 2D' }),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
test('只读模式禁用字段、保存与上下架', async () => {
|
||||
vi.mocked(getAdminAgcTemplates).mockResolvedValue({
|
||||
...library,
|
||||
writable: false,
|
||||
});
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
expect(
|
||||
await screen.findByText('当前为只读模式,无法保存或上下架'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '下架 空白 2D' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
const editor = await openEditor();
|
||||
expect(
|
||||
(within(editor).getByLabelText('名称') as HTMLInputElement).disabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(within(editor).getByRole('button', { name: '保存' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
expect(updateAdminAgcTemplate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('409 保留草稿,显式刷新后重新编辑才采用新 revision,不自动重试', async () => {
|
||||
vi.mocked(updateAdminAgcTemplate).mockRejectedValueOnce(
|
||||
new AdminApiError({ message: '模板库已被修改', status: 409 }),
|
||||
);
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
let editor = await openEditor();
|
||||
fireEvent.change(within(editor).getByLabelText('名称'), {
|
||||
target: { value: '未保存草稿' },
|
||||
});
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
|
||||
await confirmWrite();
|
||||
await screen.findByText('模板库已被修改');
|
||||
expect((screen.getByLabelText('名称') as HTMLInputElement).value).toBe(
|
||||
'未保存草稿',
|
||||
);
|
||||
expect(updateAdminAgcTemplate).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '保存' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
vi.mocked(getAdminAgcTemplates).mockResolvedValueOnce({
|
||||
...library,
|
||||
revision: 'rev-3',
|
||||
templates: [{ ...template, title: '最新名称' }],
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新后重新编辑' }));
|
||||
await screen.findByDisplayValue('最新名称');
|
||||
editor = screen.getByRole('dialog', { name: '编辑模板' });
|
||||
fireEvent.change(within(editor).getByLabelText('名称'), {
|
||||
target: { value: '重新编辑' },
|
||||
});
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
|
||||
await confirmWrite();
|
||||
await waitFor(() =>
|
||||
expect(updateAdminAgcTemplate).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
template.id,
|
||||
expect.objectContaining({ expectedRevision: 'rev-3', title: '重新编辑' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('防止重复确认和保存中重复提交', async () => {
|
||||
const pending = deferred<AdminAgcTemplateLibraryResponse>();
|
||||
vi.mocked(updateAdminAgcTemplate).mockReturnValue(pending.promise);
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const editor = await openEditor();
|
||||
const form = within(editor).getByLabelText('名称').closest('form')!;
|
||||
fireEvent.submit(form);
|
||||
fireEvent.submit(form);
|
||||
expect(screen.getAllByRole('dialog', { name: '确认操作' })).toHaveLength(1);
|
||||
await confirmWrite();
|
||||
await waitFor(() => expect(updateAdminAgcTemplate).toHaveBeenCalledTimes(1));
|
||||
fireEvent.submit(form);
|
||||
expect(updateAdminAgcTemplate).toHaveBeenCalledTimes(1);
|
||||
await act(async () => pending.resolve(library));
|
||||
});
|
||||
|
||||
test.each([409, 503])(
|
||||
'滚到表单底部后出现 HTTP %s 写入错误,聚焦并仅滚动弹窗到提示区',
|
||||
async (status) => {
|
||||
vi.mocked(updateAdminAgcTemplate).mockRejectedValueOnce(
|
||||
new AdminApiError({ message: '本次保存失败', status }),
|
||||
);
|
||||
render(
|
||||
<div data-testid="background-page">
|
||||
<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />
|
||||
</div>,
|
||||
);
|
||||
const background = screen.getByTestId('background-page');
|
||||
background.scrollTop = 240;
|
||||
const editor = await openEditor();
|
||||
const viewport = editor.querySelector<HTMLElement>(
|
||||
'.genarrative-ui-modal__body',
|
||||
)!;
|
||||
viewport.scrollTop = 400;
|
||||
const bounds = (top: number, bottom: number) =>
|
||||
({
|
||||
top,
|
||||
bottom,
|
||||
height: bottom - top,
|
||||
left: 0,
|
||||
right: 300,
|
||||
width: 300,
|
||||
x: 0,
|
||||
y: top,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue(
|
||||
bounds(100, 450),
|
||||
);
|
||||
const originalBounds = HTMLElement.prototype.getBoundingClientRect;
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(
|
||||
function (this: HTMLElement) {
|
||||
return this.classList.contains('admin-agc-template-feedback')
|
||||
? bounds(-280, -120)
|
||||
: originalBounds.call(this);
|
||||
},
|
||||
);
|
||||
const windowScroll = vi
|
||||
.spyOn(window, 'scrollTo')
|
||||
.mockImplementation(() => {});
|
||||
const focus = vi.spyOn(HTMLElement.prototype, 'focus');
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
|
||||
await confirmWrite();
|
||||
const message = await screen.findByRole('alert');
|
||||
const feedback = message.parentElement!;
|
||||
expect(document.activeElement).toBe(feedback);
|
||||
expect(feedback.tabIndex).toBe(-1);
|
||||
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
expect(viewport.scrollTop).toBe(20);
|
||||
expect(background.scrollTop).toBe(240);
|
||||
expect(windowScroll).not.toHaveBeenCalled();
|
||||
if (status === 409) {
|
||||
expect(
|
||||
within(feedback).getByRole('button', { name: '刷新后重新编辑' }),
|
||||
).not.toBeNull();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('token 切换后忽略旧列表和未确认写操作', async () => {
|
||||
const pending = deferred<AdminAgcTemplateLibraryResponse>();
|
||||
vi.mocked(getAdminAgcTemplates).mockReturnValueOnce(pending.promise);
|
||||
const props = { onUnauthorized: vi.fn() };
|
||||
const view = render(<AdminAgcTemplatesPage token="old" {...props} />);
|
||||
view.rerender(<AdminAgcTemplatesPage token="new" {...props} />);
|
||||
await screen.findByText('空白 2D');
|
||||
await act(async () =>
|
||||
pending.resolve({
|
||||
...library,
|
||||
templates: [{ ...template, title: '过期列表' }],
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByText('过期列表')).toBeNull();
|
||||
const editor = await openEditor();
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
|
||||
view.rerender(<AdminAgcTemplatesPage token="third" {...props} />);
|
||||
await screen.findByText('空白 2D');
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
expect(updateAdminAgcTemplate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each(['success', 'unauthorized'])(
|
||||
'token 切换后忽略旧保存 %s,不污染新会话',
|
||||
async (result) => {
|
||||
const pending = deferred<AdminAgcTemplateLibraryResponse>();
|
||||
vi.mocked(updateAdminAgcTemplate).mockReturnValueOnce(pending.promise);
|
||||
const onUnauthorized = vi.fn();
|
||||
const view = render(
|
||||
<AdminAgcTemplatesPage token="old" onUnauthorized={onUnauthorized} />,
|
||||
);
|
||||
const editor = await openEditor();
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
|
||||
await confirmWrite();
|
||||
await waitFor(() =>
|
||||
expect(updateAdminAgcTemplate).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
view.rerender(
|
||||
<AdminAgcTemplatesPage token="new" onUnauthorized={onUnauthorized} />,
|
||||
);
|
||||
await screen.findByText('空白 2D');
|
||||
await act(async () => {
|
||||
if (result === 'success') {
|
||||
pending.resolve({
|
||||
...library,
|
||||
templates: [{ ...template, title: '旧保存结果' }],
|
||||
});
|
||||
} else {
|
||||
pending.reject(
|
||||
new AdminApiError({ status: 401, message: '旧会话失效' }),
|
||||
);
|
||||
}
|
||||
});
|
||||
expect(onUnauthorized).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText('旧会话失效')).toBeNull();
|
||||
expect(screen.queryByText('旧保存结果')).toBeNull();
|
||||
expect(screen.queryByText('已保存')).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
test('封面在草稿中转 base64,保存携带,取消释放预览 URL 且不串到下一条', async () => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
let editor = await openEditor();
|
||||
const file = new File(['cover'], 'cover.png', { type: 'image/png' });
|
||||
fireEvent.change(within(editor).getByLabelText('更换封面'), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByText('正在读取封面')).toBeNull());
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
|
||||
await confirmWrite();
|
||||
await waitFor(() =>
|
||||
expect(updateAdminAgcTemplate).toHaveBeenCalledWith(
|
||||
'token',
|
||||
template.id,
|
||||
expect.objectContaining({
|
||||
cover: { contentType: 'image/png', dataBase64: 'Y292ZXI=' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:template-cover'),
|
||||
);
|
||||
editor = await openEditor();
|
||||
fireEvent.change(within(editor).getByLabelText('更换封面'), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '取消' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑 Godot 起步' }));
|
||||
expect(
|
||||
screen.getByRole('img', { name: '模板封面预览' }).getAttribute('src'),
|
||||
).toBe(template.coverUrl);
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['bad.svg', 'image/svg+xml', 1, '封面仅支持 PNG、JPEG 或 WebP'],
|
||||
[
|
||||
'large.png',
|
||||
'image/png',
|
||||
5 * 1024 * 1024 + 1,
|
||||
'封面文件须大于 0 且不超过 5 MiB',
|
||||
],
|
||||
])('拒绝无效封面 %s,不上传', async (name, type, size, message) => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const editor = await openEditor();
|
||||
const file = new File(['x'], name, { type });
|
||||
Object.defineProperty(file, 'size', { value: size });
|
||||
fireEvent.change(within(editor).getByLabelText('更换封面'), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
expect(screen.getByText(message)).not.toBeNull();
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '保存' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
expect(URL.createObjectURL).not.toHaveBeenCalled();
|
||||
expect(updateAdminAgcTemplate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('名称和标签校验失败时不进入写确认', async () => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const editor = await openEditor();
|
||||
fireEvent.change(within(editor).getByLabelText('名称'), {
|
||||
target: { value: ' ' },
|
||||
});
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
|
||||
expect(screen.getByText('名称须为 1–80 个字符')).not.toBeNull();
|
||||
fireEvent.change(within(editor).getByLabelText('名称'), {
|
||||
target: { value: '有效名称' },
|
||||
});
|
||||
fireEvent.change(within(editor).getByLabelText('标签'), {
|
||||
target: {
|
||||
value: Array.from({ length: 17 }, (_, index) => `标签${index}`).join(','),
|
||||
},
|
||||
});
|
||||
fireEvent.click(within(editor).getByRole('button', { name: '保存' }));
|
||||
expect(
|
||||
screen.getByText('最多 16 个标签,每个标签不超过 32 个字符'),
|
||||
).not.toBeNull();
|
||||
expect(updateAdminAgcTemplate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('读取失败显示真实错误,401 交给会话处理且不显示空库', async () => {
|
||||
const onUnauthorized = vi.fn();
|
||||
vi.mocked(getAdminAgcTemplates).mockRejectedValueOnce(
|
||||
new AdminApiError({ status: 503, message: '模板服务不可用' }),
|
||||
);
|
||||
render(
|
||||
<AdminAgcTemplatesPage token="token" onUnauthorized={onUnauthorized} />,
|
||||
);
|
||||
await screen.findByText('模板服务不可用');
|
||||
expect(screen.queryByText('暂无模板')).toBeNull();
|
||||
vi.mocked(getAdminAgcTemplates).mockRejectedValueOnce(
|
||||
new AdminApiError({ status: 401, message: '会话失效' }),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||
await waitFor(() =>
|
||||
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,87 @@
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
.admin-agc-templates {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-agc-template-filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) repeat(2, minmax(130px, 190px));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.admin-agc-template-table {
|
||||
min-width: 850px;
|
||||
}
|
||||
|
||||
.admin-agc-template-table td:nth-child(2) {
|
||||
min-width: 230px;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.admin-agc-template-cover {
|
||||
display: block;
|
||||
width: 88px;
|
||||
height: 62px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
background: #f8efe7;
|
||||
}
|
||||
|
||||
.admin-agc-template-summary {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
margin: 8px 0;
|
||||
color: #866954;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.admin-agc-template-tags,
|
||||
.admin-agc-template-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-agc-template-tags span {
|
||||
padding: 3px 7px;
|
||||
border-radius: 5px;
|
||||
background: #f8efe7;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-agc-template-dialog {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.admin-agc-template-editor,
|
||||
.admin-agc-template-conflict {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.admin-agc-template-cover-preview {
|
||||
display: block;
|
||||
width: min(100%, 300px);
|
||||
max-height: 180px;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
background: #f8efe7;
|
||||
}
|
||||
|
||||
.admin-agc-template-dialog .admin-confirm-backdrop {
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.admin-agc-template-filters {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -938,6 +938,24 @@ pub(crate) fn create_project_from_installed_template_at(
|
||||
enforce_project_permission_policy(&project_root, "project.create")?;
|
||||
let _lock = acquire_project_write_lock(&project_root, "project.create")?;
|
||||
copy_template_project(installed_project_dir, &project_root)?;
|
||||
if discover_local_cocos_project_root(&project_root)?.is_some() {
|
||||
let package_path = project_root.join("package.json");
|
||||
let mut package: serde_json::Value = serde_json::from_slice(
|
||||
&fs::read(&package_path)
|
||||
.map_err(|error| format!("读取 Cocos 项目配置失败:{error}"))?,
|
||||
)
|
||||
.map_err(|error| format!("解析 Cocos 项目配置失败:{error}"))?;
|
||||
package["name"] = serde_json::json!(project_name);
|
||||
package["uuid"] = serde_json::json!(uuid::Uuid::new_v4().to_string());
|
||||
let bytes = serde_json::to_vec_pretty(&package)
|
||||
.map_err(|error| format!("序列化 Cocos 项目配置失败:{error}"))?;
|
||||
write_game_creator_private_file(&package_path, &bytes, "Cocos 项目配置")?;
|
||||
return import_local_cocos_project_at(
|
||||
&project_root,
|
||||
&format!("gameagent-{workspace_id}"),
|
||||
&project_name,
|
||||
);
|
||||
}
|
||||
init_local_game_project_at(
|
||||
&project_root,
|
||||
&format!("gameagent-{workspace_id}"),
|
||||
@@ -1182,6 +1200,39 @@ mod tests {
|
||||
assert!(error.contains("重复模板"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_content_addressed_objects_without_changing_the_template_contract() {
|
||||
let mut index: serde_json::Value =
|
||||
serde_json::from_str(&sample_index_body()).expect("sample index");
|
||||
let keys = [
|
||||
("zipKey", "template.zip", "a"),
|
||||
("coverKey", "cover.png", "b"),
|
||||
("metadataKey", "template.json", "c"),
|
||||
]
|
||||
.map(|(field, file, hash)| {
|
||||
(
|
||||
field,
|
||||
format!(
|
||||
"templates/v1/demo-template/sha256/{}/{file}",
|
||||
hash.repeat(64)
|
||||
),
|
||||
)
|
||||
});
|
||||
for (field, key) in &keys {
|
||||
index["templates"][0][field] = serde_json::json!(key);
|
||||
}
|
||||
let (_, templates) =
|
||||
parse_game_template_library_index(&index.to_string()).expect("content addressed index");
|
||||
let parsed = serde_json::to_value(&templates[0]).expect("serialize template");
|
||||
for (field, key) in &keys {
|
||||
assert_eq!(parsed[field], *key);
|
||||
assert!(template_object_url(key)
|
||||
.expect("trusted URL")
|
||||
.ends_with(key));
|
||||
}
|
||||
assert_eq!(templates[0].template_version, "1.0.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_object_keys_outside_the_templates_prefix() {
|
||||
assert!(validate_template_object_key("agc/templates/v1/demo/template.zip").is_err());
|
||||
@@ -1225,6 +1276,10 @@ mod tests {
|
||||
"blank-2d-canvas",
|
||||
"blank-3d-scene",
|
||||
"blank-web",
|
||||
"cocos-empty-2d",
|
||||
"cocos-empty-3d",
|
||||
"cocos-empty-3d-hq",
|
||||
"cocos-hello-world",
|
||||
"phaser-2d-starter",
|
||||
"threejs-3d-starter",
|
||||
]
|
||||
@@ -1360,6 +1415,142 @@ mod tests {
|
||||
assert!(error.contains("模板尚未安装"), "{error}");
|
||||
}
|
||||
|
||||
fn template_source_files(root: &Path) -> Vec<(String, Vec<u8>)> {
|
||||
fn collect(root: &Path, directory: &Path, files: &mut Vec<(String, Vec<u8>)>) {
|
||||
for entry in fs::read_dir(directory).expect("read template directory") {
|
||||
let path = entry.expect("template entry").path();
|
||||
if path.is_dir() {
|
||||
collect(root, &path, files);
|
||||
} else {
|
||||
files.push((
|
||||
path.strip_prefix(root)
|
||||
.expect("relative path")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/"),
|
||||
fs::read(&path).expect("read template file"),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
collect(root, root, &mut files);
|
||||
files.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
files
|
||||
}
|
||||
|
||||
fn assert_cocos_template_creates_independent_projects(
|
||||
summary: &GameTemplateSummary,
|
||||
archive: &[u8],
|
||||
) {
|
||||
let cache_root = tempfile::tempdir().expect("cache directory");
|
||||
let projects_root = unique_projects_root();
|
||||
let record = install_template_archive(cache_root.path(), summary, archive)
|
||||
.expect("install Cocos template");
|
||||
let installed_root = Path::new(&record.project_dir);
|
||||
let installed_files = template_source_files(installed_root);
|
||||
let original_package: serde_json::Value = serde_json::from_slice(
|
||||
&fs::read(installed_root.join("package.json")).expect("installed package"),
|
||||
)
|
||||
.expect("parse installed package");
|
||||
let mut project_uuids = std::collections::HashSet::new();
|
||||
for name in ["Cocos 模板项目一", "Cocos 模板项目二"] {
|
||||
let created = create_project_from_installed_template_at(
|
||||
&projects_root,
|
||||
installed_root,
|
||||
Some(name),
|
||||
false,
|
||||
)
|
||||
.expect("create Cocos project from template");
|
||||
let root = Path::new(&created.project_path);
|
||||
assert_eq!(created.manifest.name, name);
|
||||
assert_eq!(created.manifest.cocos_project_root.as_deref(), Some("."));
|
||||
assert!(created.manifest.godot_project_root.is_none());
|
||||
assert!(root.join(".agent/manifest.json").is_file());
|
||||
assert!(root.join(".agent/agent.db").is_file());
|
||||
for unexpected in ["game", "memory", "exports", TEMPLATE_INSTALLED_MARKER_FILE] {
|
||||
assert!(!root.join(unexpected).exists(), "unexpected {unexpected}");
|
||||
}
|
||||
let package: serde_json::Value = serde_json::from_slice(
|
||||
&fs::read(root.join("package.json")).expect("created package"),
|
||||
)
|
||||
.expect("parse created package");
|
||||
assert_eq!(package["name"], name);
|
||||
assert_eq!(package["creator"]["version"], "3.8.8");
|
||||
let uuid = package["uuid"].as_str().expect("project uuid");
|
||||
uuid::Uuid::parse_str(uuid).expect("valid project uuid");
|
||||
assert_ne!(package["uuid"], original_package["uuid"]);
|
||||
assert!(project_uuids.insert(uuid.to_string()));
|
||||
for (relative, bytes) in &installed_files {
|
||||
if relative != "package.json" && relative != TEMPLATE_INSTALLED_MARKER_FILE {
|
||||
assert_eq!(&fs::read(root.join(relative)).expect("copied file"), bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(template_source_files(installed_root), installed_files);
|
||||
fs::remove_dir_all(&projects_root).expect("remove test projects");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installs_official_cocos_templates_and_creates_native_projects() {
|
||||
let library = Path::new(env!("CARGO_MANIFEST_DIR")).join("../template-library/v1");
|
||||
for id in [
|
||||
"cocos-empty-2d",
|
||||
"cocos-empty-3d",
|
||||
"cocos-empty-3d-hq",
|
||||
"cocos-hello-world",
|
||||
] {
|
||||
let source = library.join(id).join("project");
|
||||
let source_files = template_source_files(&source);
|
||||
let entries = source_files
|
||||
.iter()
|
||||
.map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
|
||||
.collect::<Vec<_>>();
|
||||
let archive = build_archive(&entries);
|
||||
let mut summary = sample_summary();
|
||||
summary.id = id.to_string();
|
||||
summary.zip_size_bytes = archive.len() as u64;
|
||||
summary.zip_sha256 = sha256_hex(&archive);
|
||||
assert_cocos_template_creates_independent_projects(&summary, &archive);
|
||||
assert_eq!(template_source_files(&source), source_files);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "需要网络:下载线上 Cocos 模板并验证原生建项"]
|
||||
async fn downloads_and_creates_live_cocos_templates() {
|
||||
let base = template_library_base().expect("trusted base");
|
||||
let client = build_template_library_client();
|
||||
let body = fetch_limited_bytes(
|
||||
&client,
|
||||
&format!("{base}/{TEMPLATE_LIBRARY_INDEX_KEY}"),
|
||||
TEMPLATE_LIBRARY_MAX_INDEX_BYTES,
|
||||
)
|
||||
.await
|
||||
.expect("fetch live index");
|
||||
let (_, templates) =
|
||||
parse_game_template_library_index(&String::from_utf8(body).expect("utf-8 index"))
|
||||
.expect("parse live index");
|
||||
for id in [
|
||||
"cocos-empty-2d",
|
||||
"cocos-empty-3d",
|
||||
"cocos-empty-3d-hq",
|
||||
"cocos-hello-world",
|
||||
] {
|
||||
let summary = templates.iter().find(|entry| entry.id == id).expect(id);
|
||||
assert_eq!(summary.runtime, "cocos");
|
||||
assert_eq!(summary.engine_version, "3.8.8");
|
||||
assert_eq!(summary.entry, "package.json");
|
||||
let bytes = fetch_limited_bytes(
|
||||
&client,
|
||||
&template_object_url(&summary.zip_key).expect("trusted zip url"),
|
||||
TEMPLATE_ARCHIVE_MAX_BYTES,
|
||||
)
|
||||
.await
|
||||
.expect("download Cocos template");
|
||||
assert_cocos_template_creates_independent_projects(summary, &bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// 正式构建(未开 feature)必须恒等透传:注入路径不能出现在默认产物里。
|
||||
#[cfg(not(feature = "template-library-fixtures"))]
|
||||
#[test]
|
||||
|
||||
+86
-31
@@ -2,18 +2,13 @@
|
||||
"schemaVersion": "agc-template-library.v1",
|
||||
"library": "agc-game-templates",
|
||||
"libraryVersion": 1,
|
||||
"updatedAt": "2026-09-17T03:22:43Z",
|
||||
"updatedAt": "2026-09-17T13:55:10Z",
|
||||
"templates": [
|
||||
{
|
||||
"id": "blank-2d-canvas",
|
||||
"title": "空白二维画布工程",
|
||||
"summary": "原生 Canvas 二维空白工程:自适应画布、按设备像素比缩放与 requestAnimationFrame 主循环已就绪。",
|
||||
"tags": [
|
||||
"空白",
|
||||
"起步工程",
|
||||
"2d",
|
||||
"canvas"
|
||||
],
|
||||
"tags": ["空白", "起步工程", "2d", "canvas"],
|
||||
"runtime": "html",
|
||||
"engine": "canvas",
|
||||
"engineVersion": "",
|
||||
@@ -33,12 +28,7 @@
|
||||
"id": "blank-3d-scene",
|
||||
"title": "空白三维场景工程",
|
||||
"summary": "Three.js 空白场景:空场景、透视相机、网格地面与自适应视口已就绪,适合从零搭三维玩法。",
|
||||
"tags": [
|
||||
"空白",
|
||||
"起步工程",
|
||||
"3d",
|
||||
"three.js"
|
||||
],
|
||||
"tags": ["空白", "起步工程", "3d", "three.js"],
|
||||
"runtime": "html",
|
||||
"engine": "three.js",
|
||||
"engineVersion": "0.180.0",
|
||||
@@ -58,12 +48,7 @@
|
||||
"id": "blank-web",
|
||||
"title": "空白网页工程",
|
||||
"summary": "最小网页工程(HTML + CSS + 原生 JS + Vite),没有任何引擎依赖,适合从零写玩法。",
|
||||
"tags": [
|
||||
"空白",
|
||||
"起步工程",
|
||||
"网页",
|
||||
"原生"
|
||||
],
|
||||
"tags": ["空白", "起步工程", "网页", "原生"],
|
||||
"runtime": "html",
|
||||
"engine": "none",
|
||||
"engineVersion": "",
|
||||
@@ -79,16 +64,91 @@
|
||||
"coverSha256": "326a2753386618971b1311043effa46c2e74bc1cfb116862c0ee4097dcd5086a",
|
||||
"metadataKey": "templates/v1/blank-web/template.json"
|
||||
},
|
||||
{
|
||||
"id": "cocos-empty-2d",
|
||||
"title": "Cocos 空白 2D 工程",
|
||||
"summary": "官方二维空白模板,保留精灵导入默认值与二维编辑视图。",
|
||||
"tags": ["空白", "起步工程", "2d", "cocos"],
|
||||
"runtime": "cocos",
|
||||
"engine": "cocos-creator",
|
||||
"engineVersion": "3.8.8",
|
||||
"templateVersion": "0.1.0",
|
||||
"updatedAt": "2026-09-17T13:55:10Z",
|
||||
"entry": "package.json",
|
||||
"zipKey": "templates/v1/cocos-empty-2d/template.zip",
|
||||
"zipSizeBytes": 1976,
|
||||
"zipSha256": "e30bd6324fd2eb721c9a9fdcc9d8bb9838fba7b3413d09a890d3152c3576c485",
|
||||
"coverKey": "templates/v1/cocos-empty-2d/cover.svg",
|
||||
"coverWidth": 960,
|
||||
"coverHeight": 540,
|
||||
"coverSha256": "4ca21e32e890e8a3d20bde2b9f1228993cbd770532c496433de0e5ffecc31342",
|
||||
"metadataKey": "templates/v1/cocos-empty-2d/template.json"
|
||||
},
|
||||
{
|
||||
"id": "cocos-empty-3d",
|
||||
"title": "Cocos 空白 3D 工程",
|
||||
"summary": "官方三维空白模板,适合从零搭建 Cocos 场景与玩法。",
|
||||
"tags": ["空白", "起步工程", "3d", "cocos"],
|
||||
"runtime": "cocos",
|
||||
"engine": "cocos-creator",
|
||||
"engineVersion": "3.8.8",
|
||||
"templateVersion": "0.1.0",
|
||||
"updatedAt": "2026-09-17T13:55:10Z",
|
||||
"entry": "package.json",
|
||||
"zipKey": "templates/v1/cocos-empty-3d/template.zip",
|
||||
"zipSizeBytes": 829,
|
||||
"zipSha256": "e4016ed9bcd3d3b09d5d06e776c2cf4d98415473b3747bc4945e06e93967117a",
|
||||
"coverKey": "templates/v1/cocos-empty-3d/cover.svg",
|
||||
"coverWidth": 960,
|
||||
"coverHeight": 540,
|
||||
"coverSha256": "88111f77f6801a77acc05277dc2698378ce9eeb8e9265b7d2dedafde0d893aae",
|
||||
"metadataKey": "templates/v1/cocos-empty-3d/template.json"
|
||||
},
|
||||
{
|
||||
"id": "cocos-empty-3d-hq",
|
||||
"title": "Cocos 高质量 3D 工程",
|
||||
"summary": "官方高质量三维空白模板,保留天空盒、阴影与线性纹理采样预设。",
|
||||
"tags": ["空白", "起步工程", "3d", "cocos"],
|
||||
"runtime": "cocos",
|
||||
"engine": "cocos-creator",
|
||||
"engineVersion": "3.8.8",
|
||||
"templateVersion": "0.1.0",
|
||||
"updatedAt": "2026-09-17T13:55:10Z",
|
||||
"entry": "package.json",
|
||||
"zipKey": "templates/v1/cocos-empty-3d-hq/template.zip",
|
||||
"zipSizeBytes": 1226,
|
||||
"zipSha256": "4b4f12a7dd39be31bc471c7387b272717e9476b1a6eafb21273ce1a3ebc38384",
|
||||
"coverKey": "templates/v1/cocos-empty-3d-hq/cover.svg",
|
||||
"coverWidth": 960,
|
||||
"coverHeight": 540,
|
||||
"coverSha256": "276752f234c1209c182ca79b65d97c1d52d9e22b60be9aaee6cb0b134ab666ea",
|
||||
"metadataKey": "templates/v1/cocos-empty-3d-hq/template.json"
|
||||
},
|
||||
{
|
||||
"id": "cocos-hello-world",
|
||||
"title": "Cocos Hello World",
|
||||
"summary": "官方三维示例场景,包含岛屿、角色、植被、材质与天空盒资源。",
|
||||
"tags": ["示例", "起步工程", "3d", "cocos"],
|
||||
"runtime": "cocos",
|
||||
"engine": "cocos-creator",
|
||||
"engineVersion": "3.8.8",
|
||||
"templateVersion": "0.1.0",
|
||||
"updatedAt": "2026-09-17T13:55:10Z",
|
||||
"entry": "package.json",
|
||||
"zipKey": "templates/v1/cocos-hello-world/template.zip",
|
||||
"zipSizeBytes": 2484948,
|
||||
"zipSha256": "dac8cc2eae2ec9ff3cd429a1be2c2981b06919be720bfa50226619c0a56e00da",
|
||||
"coverKey": "templates/v1/cocos-hello-world/cover.svg",
|
||||
"coverWidth": 960,
|
||||
"coverHeight": 540,
|
||||
"coverSha256": "9363b728a792835f57d22d129c598449801d343f739e85159e83e531ba7d949c",
|
||||
"metadataKey": "templates/v1/cocos-hello-world/template.json"
|
||||
},
|
||||
{
|
||||
"id": "phaser-2d-starter",
|
||||
"title": "Phaser 2D 起步工程",
|
||||
"summary": "AGC 新建项目使用的默认二维起步工程(Phaser 4 + Vite),解压后即为可运行项目根。",
|
||||
"tags": [
|
||||
"起步工程",
|
||||
"2d",
|
||||
"phaser",
|
||||
"像素"
|
||||
],
|
||||
"tags": ["起步工程", "2d", "phaser", "像素"],
|
||||
"runtime": "html",
|
||||
"engine": "phaser",
|
||||
"engineVersion": "4.2.1",
|
||||
@@ -108,12 +168,7 @@
|
||||
"id": "threejs-3d-starter",
|
||||
"title": "Three.js 3D 起步工程",
|
||||
"summary": "网页三维起步工程(Three.js + Vite),自带可旋转立方体场景、方向光与自适应视口。",
|
||||
"tags": [
|
||||
"起步工程",
|
||||
"3d",
|
||||
"three.js",
|
||||
"网页"
|
||||
],
|
||||
"tags": ["起步工程", "3d", "three.js", "网页"],
|
||||
"runtime": "html",
|
||||
"engine": "three.js",
|
||||
"engineVersion": "0.180.0",
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DirectCodexUserItem } from './DirectCodexUserItem';
|
||||
|
||||
export type DirectCodexUserMessageEnvelope = { item: DirectCodexUserItem };
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540" role="img" aria-label="Cocos 空白 2D 工程">
|
||||
<defs><linearGradient id="bg" x2="1" y2="1"><stop stop-color="#12354b"/><stop offset="1" stop-color="#0c1424"/></linearGradient></defs>
|
||||
<rect width="960" height="540" fill="url(#bg)"/>
|
||||
<circle cx="800" cy="140" r="200" fill="#1aa5b8" opacity=".12"/>
|
||||
<path d="M740 235 835 290 835 400 740 455 645 400 645 290Z M645 290 740 345 835 290 M740 345V455" fill="none" stroke="#1aa5b8" stroke-width="3" opacity=".6"/>
|
||||
<rect x="72" y="132" width="6" height="196" rx="3" fill="#1aa5b8"/>
|
||||
<text x="104" y="197" fill="#fff" font-family="Microsoft YaHei, Noto Sans SC, sans-serif" font-size="42" font-weight="700">Cocos 空白 2D 工程</text>
|
||||
<text x="108" y="253" fill="#c9dbed" font-family="Microsoft YaHei, Noto Sans SC, sans-serif" font-size="22">Cocos Creator 3.8.8</text>
|
||||
<text x="108" y="302" fill="#8ca8c5" font-family="Microsoft YaHei, Noto Sans SC, sans-serif" font-size="18">官方起步模板 · 2D</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "cocos-empty-2d",
|
||||
"title": "Cocos 空白 2D 工程",
|
||||
"summary": "官方二维空白模板,保留精灵导入默认值与二维编辑视图。",
|
||||
"tags": [
|
||||
"空白",
|
||||
"起步工程",
|
||||
"2d",
|
||||
"cocos"
|
||||
],
|
||||
"runtime": "cocos",
|
||||
"engine": "cocos-creator",
|
||||
"engineVersion": "3.8.8",
|
||||
"templateVersion": "0.1.0",
|
||||
"entry": "package.json",
|
||||
"coverWidth": 960,
|
||||
"coverHeight": 540
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"image": {
|
||||
"type": "sprite-frame"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
|
||||
#///////////////////////////
|
||||
# Cocos Creator Project
|
||||
#///////////////////////////
|
||||
|
||||
/library/
|
||||
/temp/
|
||||
/local/
|
||||
/build/
|
||||
/profiles/*
|
||||
!/profiles/v2/
|
||||
/profiles/v2/*
|
||||
!/profiles/v2/packages/
|
||||
/profiles/v2/packages/*
|
||||
!/profiles/v2/packages/scene.json
|
||||
/native/engine/android/**/*/assets
|
||||
|
||||
#//////////////////////////
|
||||
# NPM
|
||||
#//////////////////////////
|
||||
node_modules/
|
||||
|
||||
#//////////////////////////
|
||||
# VSCode
|
||||
#//////////////////////////
|
||||
.vscode/
|
||||
|
||||
#//////////////////////////
|
||||
# WebStorm
|
||||
#//////////////////////////
|
||||
.idea/
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "cocos-empty-2d",
|
||||
"uuid": "d58709ec-73d2-4ae8-b5d9-f3e0262bf2ab",
|
||||
"creator": {
|
||||
"version": "3.8.8"
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"gizmos-infos": {
|
||||
"is2D": true
|
||||
}
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
{
|
||||
"__version__": "1.0.11",
|
||||
"modules": {
|
||||
"configs": {
|
||||
"defaultConfig": {
|
||||
"name": "Default Config",
|
||||
"cache": {
|
||||
"base": {
|
||||
"_value": true
|
||||
},
|
||||
"gfx-webgl": {
|
||||
"_value": true
|
||||
},
|
||||
"gfx-webgl2": {
|
||||
"_value": true
|
||||
},
|
||||
"animation": {
|
||||
"_value": true
|
||||
},
|
||||
"skeletal-animation": {
|
||||
"_value": false
|
||||
},
|
||||
"3d": {
|
||||
"_value": false
|
||||
},
|
||||
"2d": {
|
||||
"_value": true
|
||||
},
|
||||
"rich-text": {
|
||||
"_value": true
|
||||
},
|
||||
"mask": {
|
||||
"_value": true
|
||||
},
|
||||
"graphics": {
|
||||
"_value": true
|
||||
},
|
||||
"affine-transform": {
|
||||
"_value": true
|
||||
},
|
||||
"xr": {
|
||||
"_value": false
|
||||
},
|
||||
"ui": {
|
||||
"_value": true
|
||||
},
|
||||
"particle": {
|
||||
"_value": false
|
||||
},
|
||||
"physics": {
|
||||
"_value": false,
|
||||
"_option": "physics-ammo"
|
||||
},
|
||||
"physics-ammo": {
|
||||
"_value": false
|
||||
},
|
||||
"physics-cannon": {
|
||||
"_value": false
|
||||
},
|
||||
"physics-physx": {
|
||||
"_value": false
|
||||
},
|
||||
"physics-builtin": {
|
||||
"_value": false
|
||||
},
|
||||
"physics-2d": {
|
||||
"_value": true,
|
||||
"_option": "physics-2d-box2d"
|
||||
},
|
||||
"physics-2d-box2d": {
|
||||
"_value": false
|
||||
},
|
||||
"physics-2d-builtin": {
|
||||
"_value": false
|
||||
},
|
||||
"intersection-2d": {
|
||||
"_value": true
|
||||
},
|
||||
"primitive": {
|
||||
"_value": false
|
||||
},
|
||||
"profiler": {
|
||||
"_value": true
|
||||
},
|
||||
"occlusion-query": {
|
||||
"_value": false
|
||||
},
|
||||
"geometry-renderer": {
|
||||
"_value": false
|
||||
},
|
||||
"debug-renderer": {
|
||||
"_value": false
|
||||
},
|
||||
"particle-2d": {
|
||||
"_value": true
|
||||
},
|
||||
"audio": {
|
||||
"_value": true
|
||||
},
|
||||
"video": {
|
||||
"_value": true
|
||||
},
|
||||
"webview": {
|
||||
"_value": true
|
||||
},
|
||||
"tween": {
|
||||
"_value": true
|
||||
},
|
||||
"websocket": {
|
||||
"_value": false
|
||||
},
|
||||
"websocket-server": {
|
||||
"_value": false
|
||||
},
|
||||
"terrain": {
|
||||
"_value": false
|
||||
},
|
||||
"light-probe": {
|
||||
"_value": false
|
||||
},
|
||||
"tiled-map": {
|
||||
"_value": true
|
||||
},
|
||||
"spine": {
|
||||
"_value": true,
|
||||
"_option": "spine-3.8"
|
||||
},
|
||||
"dragon-bones": {
|
||||
"_value": true
|
||||
},
|
||||
"marionette": {
|
||||
"_value": false
|
||||
},
|
||||
"render-pipeline": {
|
||||
"_option": "custom-pipeline"
|
||||
}
|
||||
},
|
||||
"includeModules": [
|
||||
"2d",
|
||||
"rich-text",
|
||||
"mask",
|
||||
"graphics",
|
||||
"affine-transform",
|
||||
"animation",
|
||||
"audio",
|
||||
"base",
|
||||
"dragon-bones",
|
||||
"gfx-webgl",
|
||||
"gfx-webgl2",
|
||||
"intersection-2d",
|
||||
"particle-2d",
|
||||
"physics-2d-box2d",
|
||||
"profiler",
|
||||
"spine-3.8",
|
||||
"tiled-map",
|
||||
"tween",
|
||||
"ui",
|
||||
"video",
|
||||
"webview",
|
||||
"custom-pipeline"
|
||||
],
|
||||
"noDeprecatedFeatures": {
|
||||
"value": false,
|
||||
"version": ""
|
||||
},
|
||||
"flags": {}
|
||||
}
|
||||
},
|
||||
"globalConfigKey": "defaultConfig"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
/* Base configuration. Do not edit this field. */
|
||||
"extends": "./temp/tsconfig.cocos.json",
|
||||
|
||||
/* Add your custom configuration here. */
|
||||
"compilerOptions": {
|
||||
"strict": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540" role="img" aria-label="Cocos 高质量 3D 工程">
|
||||
<defs><linearGradient id="bg" x2="1" y2="1"><stop stop-color="#292044"/><stop offset="1" stop-color="#0c1424"/></linearGradient></defs>
|
||||
<rect width="960" height="540" fill="url(#bg)"/>
|
||||
<circle cx="800" cy="140" r="200" fill="#9565df" opacity=".12"/>
|
||||
<path d="M740 235 835 290 835 400 740 455 645 400 645 290Z M645 290 740 345 835 290 M740 345V455" fill="none" stroke="#9565df" stroke-width="3" opacity=".6"/>
|
||||
<rect x="72" y="132" width="6" height="196" rx="3" fill="#9565df"/>
|
||||
<text x="104" y="197" fill="#fff" font-family="Microsoft YaHei, Noto Sans SC, sans-serif" font-size="42" font-weight="700">Cocos 高质量 3D 工程</text>
|
||||
<text x="108" y="253" fill="#c9dbed" font-family="Microsoft YaHei, Noto Sans SC, sans-serif" font-size="22">Cocos Creator 3.8.8</text>
|
||||
<text x="108" y="302" fill="#8ca8c5" font-family="Microsoft YaHei, Noto Sans SC, sans-serif" font-size="18">官方起步模板 · 3D</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "cocos-empty-3d-hq",
|
||||
"title": "Cocos 高质量 3D 工程",
|
||||
"summary": "官方高质量三维空白模板,保留天空盒、阴影与线性纹理采样预设。",
|
||||
"tags": [
|
||||
"空白",
|
||||
"起步工程",
|
||||
"3d",
|
||||
"cocos"
|
||||
],
|
||||
"runtime": "cocos",
|
||||
"engine": "cocos-creator",
|
||||
"engineVersion": "3.8.8",
|
||||
"templateVersion": "0.1.0",
|
||||
"entry": "package.json",
|
||||
"coverWidth": 960,
|
||||
"coverHeight": 540
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user