Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27e3d9bfd0 | |||
| 4d313a44eb | |||
| 01ed9fadc6 | |||
| 02a7abeb3f | |||
| 6845cdcbc5 | |||
| 4044ffd89b | |||
| 8d0c021126 | |||
| c6f90c5b22 | |||
| 1cb91f9444 | |||
| 2e31a485d8 | |||
| 4ad131d74d | |||
| 562e1ba586 | |||
| b4ce6877c8 | |||
| 7b57cba14d | |||
| beebcca4de | |||
| 3b137ca369 | |||
| 3da0936c1f | |||
| c8311a47b0 | |||
| 1c1deff732 | |||
| 0459109897 | |||
| 6318baf7c6 | |||
| 28e6e67e98 | |||
| 4415723f7f | |||
| fa84a364d6 | |||
| bd0a06e3d3 | |||
| 939c35a9b9 | |||
| 42fdb57efe | |||
| 0c54cca81c | |||
| 1a2e60bf05 | |||
| 5ce267ae85 | |||
| 8924bf335e | |||
| ae257a6ff8 | |||
| ff4f42d2f2 | |||
| 2e2ed47bec | |||
| dedc58cce1 | |||
| c86ac04220 | |||
| 0817ea8158 | |||
| e393a803f2 | |||
| 89587c9e9e | |||
| 5cba2dbeb7 | |||
| d3407e8910 | |||
| 4888a0eb01 | |||
| 90b77d6bd0 | |||
| 1c950df116 | |||
| 6c33459b90 | |||
| 874d061cb9 | |||
| fa2b36654b | |||
| 345c9c3a99 | |||
| 99081455ee | |||
| 5b1b0ddb9a | |||
| f4315ffd23 | |||
| beb3c99240 | |||
| 1574f206a2 | |||
| 1367e9c04e | |||
| b294cbc3db | |||
| c34d24c2c8 | |||
| 0dbc34d13c |
@@ -102,7 +102,7 @@ Use OpenAPI as the final authority; these common values are a routing aid:
|
||||
|
||||
- Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it.
|
||||
- External v1 currently has no structured game-scene generation operation. Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing.
|
||||
- Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`.
|
||||
- Image `model`: `gpt-image-2.5`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`. Persisted `gpt-image-2` / `gpt-image-2-c` are legacy values resolved only when submitting a new task.
|
||||
- Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`.
|
||||
- Image `imageSize`: `0.5K`, `1K`, `2K`.
|
||||
- Video `model`: `seedance2.0`, `seedance2.0-fast`, `kling3.0`, `kling3.0-omni`, `veo3.1`, `veo3.1-fast`.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: gpt-image-2-apimart
|
||||
description: Generate or inspect project image assets through this repository's VectorEngine gpt-image-2 workflow with gpt-image-2-c fallback. Use when Codex needs to create puzzle template sample images, reproduce the server-rs image request body, dry-run image prompts, batch-generate local project thumbnails, or debug VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY image-generation configuration without exposing secrets. The directory name is historical.
|
||||
description: Generate or inspect project image assets through this repository's image workflow using the GPT Image 2.5 business model. Use when Codex needs to create puzzle template sample images, reproduce the server-rs image request body, dry-run image prompts, batch-generate local project thumbnails, or debug VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY image-generation configuration without exposing secrets. The directory name is historical.
|
||||
---
|
||||
|
||||
# gpt-image-2 VectorEngine
|
||||
# GPT Image 2.5 project image workflow
|
||||
|
||||
Use this skill for project-local image asset generation that must match the repository's `server-rs` VectorEngine image path. Keep the product/price model identifier and primary provider request as `gpt-image-2`, then fall back once to `gpt-image-2-c` for eligible provider failures. The folder still contains `apimart` in its name for compatibility with existing local plugin references.
|
||||
Use this skill for project-local image asset generation that must match the repository's image request contract. Use the business model identifier `gpt-image-2.5`; provider concrete model routing is owned by `server-rs`, and this client must not perform a cross-model fallback. The folder still contains `apimart` in its name for compatibility with existing local plugin references.
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -40,7 +40,7 @@ Default body:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "gpt-image-2",
|
||||
"model": "gpt-image-2.5",
|
||||
"prompt": "<prompt>",
|
||||
"n": 1,
|
||||
"size": "1024x1024"
|
||||
@@ -58,14 +58,14 @@ Content-Type: multipart/form-data
|
||||
Multipart fields:
|
||||
|
||||
```text
|
||||
model=gpt-image-2
|
||||
model=gpt-image-2.5
|
||||
prompt=<prompt>
|
||||
n=1
|
||||
size=1024x1024
|
||||
image=@reference.png
|
||||
```
|
||||
|
||||
In this repository, calls with no reference images use `POST /v1/images/generations`; calls with any reference image use `POST /v1/images/edits` and pass references as one or more `image` form parts. Both paths prefer `gpt-image-2`; on an eligible upstream/model failure they retry with `gpt-image-2-c`. Do not fall back for authentication, local validation, request-budget exhaustion, uncertain send/connection failure, content-safety rejection, or a generated image URL download failure. Match3D container UI generation embeds `public/match3d-background-references/pot-fused-reference.png` into the edit request as an `image` part.
|
||||
In this repository, calls with no reference images use `POST /v1/images/generations`; calls with any reference image use `POST /v1/images/edits` and pass references as one or more `image` form parts. Both paths send the business model `gpt-image-2.5`; provider routing and retry policy remain server-owned. Match3D container UI generation embeds `public/match3d-background-references/pot-fused-reference.png` into the edit request as an `image` part.
|
||||
|
||||
Accept image output from `data[].url`, `data[].b64_json`, or direct nested `url` fields. VectorEngine image generation currently returns synchronously; do not poll APIMart task endpoints.
|
||||
|
||||
|
||||
@@ -9,8 +9,7 @@ const skillRoot = path.resolve(__dirname, '..');
|
||||
const repoRoot = path.resolve(skillRoot, '..', '..', '..');
|
||||
const defaultOutDir = path.join(repoRoot, 'public', 'anthro-cat-illustrations');
|
||||
const defaultTimeoutMs = 1000000;
|
||||
const preferredImageModel = 'gpt-image-2';
|
||||
const fallbackImageModel = 'gpt-image-2-c';
|
||||
const preferredImageModel = 'gpt-image-2.5';
|
||||
|
||||
const prompts = [
|
||||
{
|
||||
@@ -256,41 +255,8 @@ async function fetchJson(url, options, timeoutMs) {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldFallbackImageModel(error) {
|
||||
const raw =
|
||||
`${error?.message || ''}\n${error?.vectorEngineBody || ''}`.toLowerCase();
|
||||
if (error?.vectorEngineResponseParse) {
|
||||
return !containsContentRejection(raw);
|
||||
}
|
||||
const status = Number(error?.vectorEngineStatus || 0);
|
||||
if (status === 408 || status >= 500) {
|
||||
return true;
|
||||
}
|
||||
if (status === 429) {
|
||||
return !containsContentRejection(raw);
|
||||
}
|
||||
const mentionsImageModel =
|
||||
raw.includes('model') ||
|
||||
raw.includes('模型') ||
|
||||
raw.includes(preferredImageModel) ||
|
||||
raw.includes(fallbackImageModel);
|
||||
return (
|
||||
[400, 404, 422].includes(status) &&
|
||||
mentionsImageModel &&
|
||||
/(not found|not supported|unsupported|unavailable|does not exist|invalid model|unknown model|不存在|不支持|不可用|未开通)/u.test(
|
||||
raw,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function containsContentRejection(raw) {
|
||||
return /(invalid_prompt|safety|content[_ ]policy|moderation|prompt rejected|content rejected|prompt refusal|content refusal|rejected by safety|rejected by moderation|敏感|违规|安全策略|内容审核|提示词拒绝|内容拒绝)/u.test(
|
||||
raw,
|
||||
);
|
||||
}
|
||||
|
||||
async function requestImagePayload(env, entry) {
|
||||
for (const model of [preferredImageModel, fallbackImageModel]) {
|
||||
for (const model of [preferredImageModel]) {
|
||||
const requestBody = {
|
||||
model,
|
||||
prompt: buildPrompt(entry),
|
||||
@@ -322,12 +288,7 @@ async function requestImagePayload(env, entry) {
|
||||
error.vectorEngineBody = JSON.stringify(payload).slice(0, 600);
|
||||
throw error;
|
||||
} catch (error) {
|
||||
if (model !== preferredImageModel || !shouldFallbackImageModel(error)) {
|
||||
throw error;
|
||||
}
|
||||
console.warn(
|
||||
`VectorEngine ${preferredImageModel} failed, retrying with ${fallbackImageModel}: ${error.message}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw new Error(`VectorEngine returned no image for ${entry.id}`);
|
||||
@@ -408,7 +369,7 @@ if (dryRun) {
|
||||
requests: selectedPrompts.map((entry) => ({
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
fallbackModel: fallbackImageModel,
|
||||
fallbackModel: null,
|
||||
body: {
|
||||
model: preferredImageModel,
|
||||
prompt: buildPrompt(entry),
|
||||
|
||||
@@ -18,8 +18,7 @@ const defaultOutDir = path.join(
|
||||
'puzzle-creation-templates',
|
||||
);
|
||||
const defaultTimeoutMs = 1000000;
|
||||
const preferredImageModel = 'gpt-image-2';
|
||||
const fallbackImageModel = 'gpt-image-2-c';
|
||||
const preferredImageModel = 'gpt-image-2.5';
|
||||
|
||||
const args = new Map();
|
||||
for (let index = 2; index < process.argv.length; index += 1) {
|
||||
@@ -226,41 +225,8 @@ async function fetchJson(url, options, timeoutMs) {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldFallbackImageModel(error) {
|
||||
const raw =
|
||||
`${error?.message || ''}\n${error?.vectorEngineBody || ''}`.toLowerCase();
|
||||
if (error?.vectorEngineResponseParse) {
|
||||
return !containsContentRejection(raw);
|
||||
}
|
||||
const status = Number(error?.vectorEngineStatus || 0);
|
||||
if (status === 408 || status >= 500) {
|
||||
return true;
|
||||
}
|
||||
if (status === 429) {
|
||||
return !containsContentRejection(raw);
|
||||
}
|
||||
const mentionsImageModel =
|
||||
raw.includes('model') ||
|
||||
raw.includes('模型') ||
|
||||
raw.includes(preferredImageModel) ||
|
||||
raw.includes(fallbackImageModel);
|
||||
return (
|
||||
[400, 404, 422].includes(status) &&
|
||||
mentionsImageModel &&
|
||||
/(not found|not supported|unsupported|unavailable|does not exist|invalid model|unknown model|不存在|不支持|不可用|未开通)/u.test(
|
||||
raw,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function containsContentRejection(raw) {
|
||||
return /(invalid_prompt|safety|content[_ ]policy|moderation|prompt rejected|content rejected|prompt refusal|content refusal|rejected by safety|rejected by moderation|敏感|违规|安全策略|内容审核|提示词拒绝|内容拒绝)/u.test(
|
||||
raw,
|
||||
);
|
||||
}
|
||||
|
||||
async function requestImagePayload(env, template) {
|
||||
for (const model of [preferredImageModel, fallbackImageModel]) {
|
||||
for (const model of [preferredImageModel]) {
|
||||
const requestBody = {
|
||||
model,
|
||||
prompt: buildPrompt(template),
|
||||
@@ -294,12 +260,7 @@ async function requestImagePayload(env, template) {
|
||||
error.vectorEngineBody = JSON.stringify(payload).slice(0, 600);
|
||||
throw error;
|
||||
} catch (error) {
|
||||
if (model !== preferredImageModel || !shouldFallbackImageModel(error)) {
|
||||
throw error;
|
||||
}
|
||||
console.warn(
|
||||
`VectorEngine ${preferredImageModel} failed, retrying with ${fallbackImageModel}: ${error.message}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw new Error(`VectorEngine returned no image for ${template.id}`);
|
||||
@@ -384,7 +345,7 @@ if (dryRun) {
|
||||
requests: selectedTemplates.map((template) => ({
|
||||
id: template.id,
|
||||
title: template.title,
|
||||
fallbackModel: fallbackImageModel,
|
||||
fallbackModel: null,
|
||||
body: {
|
||||
model: preferredImageModel,
|
||||
prompt: buildPrompt(template),
|
||||
|
||||
+29
@@ -44,6 +44,35 @@ _Avoid_: 把同一资源的全局元数据和某一次摆放坐标混在同一
|
||||
由图片生成或图片修改流程产生的画布资源,必须记录来源资源、提示词、实际提示词、模型、provider、任务 ID 和生成时间;本期 `/editor` 的生成修改先允许 mock 生成资源,但仍按生成资源元数据形状保存。
|
||||
_Avoid_: 无来源的静态素材、只显示在 UI 但不落工程资源记录的生成结果
|
||||
|
||||
**图片模型历史值与使用端解析**:
|
||||
图片资源中已持久化的 `gpt-image-2` 是历史业务事实,读回时保持原值;新任务使用业务模型值 `gpt-image-2.5`。当用户基于历史资源再次发起生成或编辑任务时,服务端只在新任务的使用端把历史值解析为当前业务模型,不改写历史资源。provider route 属于服务端执行与审计边界,前端不接收、不持久化、不展示,也不据此分支。
|
||||
_Avoid_: 读取数据库时改写历史模型值、把 provider route 暴露为前端模型选项或公开 DTO
|
||||
|
||||
**图片 provider 显式路由**:
|
||||
api-server 在任务入口按业务语义显式选择具体 provider model name(生成或编辑),并把同一具体名传给图片平台适配器和后台定价解析;图片平台适配器不从参考图数量或前端字段猜测任务。具体 provider model name 只存在于服务端调用、定价配置和审计边界。
|
||||
后台管理 Web/API 是明确例外,可以查看和编辑两个具体定价 key;主站普通前端与公开定价 API 不接收这些 key。
|
||||
_Avoid_: 让图片适配器隐式猜路由、让主站前端携带 provider model name
|
||||
|
||||
**业务模型**:
|
||||
面向任务与产品契约的稳定模型值;当前 GPT 图片新任务的业务模型是 `gpt-image-2.5`。业务模型不等同于 provider 的具体计费/请求 model,也不暴露 provider 凭证或 endpoint。
|
||||
_Avoid_: 把 provider concrete model 当作前端业务选项、用业务模型值直接推断 provider 凭证
|
||||
|
||||
**具体模型**:
|
||||
服务端发送请求和定价使用的 concrete model name。GPT Image 2.5 生成与编辑分别是 `gpt-image-2.5-flare-c` 和 `gpt-image-2.5-sunburst-c`;nanobanana 仍使用 `gemini-3.1-flash-image-preview`。具体模型只在服务端执行、定价和审计边界出现。
|
||||
_Avoid_: 把具体模型写入普通前端 DTO、让未知字符串自动选择 provider
|
||||
|
||||
**provider client**:
|
||||
按具体模型选出的外部图片 provider 连接配置,包含 provider identity、base URL 和 API key;VectorEngine 与 Tiantoken client 共享图片协议执行器,不复制请求/响应业务逻辑。两套 required client 在 api-server 启动时构造。
|
||||
_Avoid_: 在首次请求时才创建 client、在 provider client 中复制尺寸/重试/审计逻辑、跨 provider credential fallback
|
||||
|
||||
**历史模型值**:
|
||||
已持久化的 `gpt-image-2` 或 `gpt-image-2-c` 字符串,只作为历史事实原样读取和审计;基于历史资源提交新任务时,在使用端解析为当前 GPT Image 2.5 业务任务,不回写历史记录,也不把旧值作为现役 provider route。
|
||||
_Avoid_: 数据库批量改写历史值、把历史值重新路由到 VectorEngine、把兼容解析扩散到普通前端
|
||||
|
||||
**GPT Image 2.5 新生成展示名**:
|
||||
`GPT Image 2.5` 是新生成任务的产品展示名;历史资源与既有编辑上下文不因新模型上线而改写展示语义。
|
||||
_Avoid_: 把新生成展示名扩散到历史记录、历史生成器或旧编辑上下文
|
||||
|
||||
**系列素材图集生成**:
|
||||
一组同类素材的统一批量生成方式,采用批量规划、sheet 生图、后端切图、透明化、OSS 持久化和局部重生成的通用流水线。
|
||||
_Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成任一玩法专属 DTO
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -185,7 +185,12 @@ test('nextVersion 只在 patch 位递增', () => {
|
||||
|
||||
test('ossutil 参数默认使用 v1 签名,并可按需带 region 与 v4', () => {
|
||||
const base = {
|
||||
args: ['cp', '--force', '/tmp/a.json', 'oss://agc-dev/agc/global-version.json'],
|
||||
args: [
|
||||
'cp',
|
||||
'--force',
|
||||
'/tmp/a.json',
|
||||
'oss://agc-dev/agc/global-version.json',
|
||||
],
|
||||
endpoint: 'oss-rg-china-mainland.aliyuncs.com',
|
||||
accessKeyId: 'id',
|
||||
accessKeySecret: 'secret',
|
||||
|
||||
@@ -287,6 +287,13 @@ try {
|
||||
]) {
|
||||
assert.ok(fs.existsSync(path.join(plugin, file)), file);
|
||||
}
|
||||
// 随包 Node 的 npm 是包里唯一允许出现的 node_modules:除了 npm 目录自身与它的子项,
|
||||
// 还要放行它的上级目录 `game-runtime/node/node_modules`(recursive readdir 会列出目录项,
|
||||
// 少了这一条会让整个门禁对合法包失败——#439 引入后一直没被跑到,直到 2026-09-21 才暴露)。
|
||||
const allowedNodeModules = (file) =>
|
||||
file === 'game-runtime/node/node_modules' ||
|
||||
file === 'game-runtime/node/node_modules/npm' ||
|
||||
file.startsWith('game-runtime/node/node_modules/npm/');
|
||||
const packageFiles = fs.readdirSync(resources, { recursive: true });
|
||||
assert.ok(
|
||||
!packageFiles.some(
|
||||
@@ -294,8 +301,7 @@ try {
|
||||
/(^|\/)(\.env[^/]*|auth\.json|target|\.git)(\/|$)|\.(exe|dll)$/.test(
|
||||
file,
|
||||
) ||
|
||||
(/(^|\/)node_modules(\/|$)/.test(file) &&
|
||||
!file.startsWith('game-runtime/node/node_modules/npm')),
|
||||
(/(^|\/)node_modules(\/|$)/.test(file) && !allowedNodeModules(file)),
|
||||
),
|
||||
);
|
||||
assert.equal(run(executable, ['--version']).stdout.trim(), manifest.version);
|
||||
|
||||
@@ -1192,7 +1192,7 @@ async function main() {
|
||||
function isDirectModuleExecution() {
|
||||
return Boolean(
|
||||
process.argv[1] &&
|
||||
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
|
||||
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1006,16 +1006,58 @@ pub(crate) async fn external_editor_json_request(
|
||||
let response = crate::http_client::with_agc_main_site_marker(request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("{action}失败:{error}"))?;
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"{action}失败:{}",
|
||||
describe_external_request_failure(&error)
|
||||
)
|
||||
})?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format_external_http_error(action, status, &body));
|
||||
}
|
||||
response
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.map_err(|error| format!("解析{action}响应失败:{error}"))
|
||||
response.json::<serde_json::Value>().await.map_err(|error| {
|
||||
format!(
|
||||
"解析{action}响应失败:{}",
|
||||
describe_external_request_failure(&error)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// 把 reqwest 失败翻译成可现场定责的文案。
|
||||
///
|
||||
/// `reqwest::Error` 的 `Display` 只输出 kind:客户端预算内没读完正文(总超时)、
|
||||
/// 正文被提前截断和正文不是合法 JSON 都会显示成同一句 `error decoding response body`,
|
||||
/// 现场无法区分是平台慢、链接慢还是响应被截断。这里补上 kind 语义与底层因链;
|
||||
/// 因链只取错误文本,不拼接 URL,避免把绝对地址写进日志。
|
||||
fn describe_external_request_failure(error: &reqwest::Error) -> String {
|
||||
let kind = if error.is_timeout() {
|
||||
"请求超时(连接、响应头或响应正文未在客户端预算内完成)"
|
||||
} else if error.is_decode() {
|
||||
"响应正文未完整返回或不是合法 JSON"
|
||||
} else if error.is_body() {
|
||||
"响应正文读取失败"
|
||||
} else if error.is_connect() {
|
||||
"连接失败"
|
||||
} else if error.is_request() {
|
||||
"请求发送失败"
|
||||
} else {
|
||||
"请求失败"
|
||||
};
|
||||
let mut causes: Vec<String> = Vec::new();
|
||||
let mut source = std::error::Error::source(error);
|
||||
while let Some(current) = source {
|
||||
let text = current.to_string();
|
||||
if !text.is_empty() && !causes.contains(&text) {
|
||||
causes.push(text);
|
||||
}
|
||||
source = current.source();
|
||||
}
|
||||
if causes.is_empty() {
|
||||
return kind.to_string();
|
||||
}
|
||||
format!("{kind}:{}", causes.join(" → "))
|
||||
}
|
||||
|
||||
/// Keep provider validation details useful to the operator without copying an
|
||||
@@ -1443,10 +1485,14 @@ pub(crate) async fn prepare_external_canvas_generation_context(
|
||||
let project_id = if let Some(project_id) = partial.remote_project_id.clone() {
|
||||
project_id
|
||||
} else {
|
||||
// 这一步只需要按 projectId 确认绑定项目是否仍然存在,固定用摘要视图:
|
||||
// 缺省 full 会把账号下每个项目的画布与全量资源都带回来,项目增长后会耗尽本次请求的
|
||||
// 客户端预算,现场表现为「解析读取外部画布项目响应失败:error decoding response body」。
|
||||
// 站内 `/api/editor/projects` 与 `/api/external/v1/editor/projects` 都支持该视图。
|
||||
let projects_payload = external_editor_json_request(
|
||||
client
|
||||
.get(format!(
|
||||
"{}{}",
|
||||
"{}{}?view=summary",
|
||||
access.api_base_url(),
|
||||
access.api_route("/api/external/v1/editor/projects")
|
||||
))
|
||||
@@ -8535,6 +8581,28 @@ mod canvas_generation_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// reqwest 的 `Display` 只给 kind,超时 / 正文截断 / 非法 JSON 全都是同一句
|
||||
/// `error decoding response body`。这个用例钉住「至少把 kind 语义换成人话」,
|
||||
/// 避免再退回无法定责的原始文案。
|
||||
#[tokio::test]
|
||||
async fn external_request_failure_replaces_the_opaque_reqwest_kind() {
|
||||
let error = reqwest::Client::new()
|
||||
.get("http://127.0.0.1:1/healthz")
|
||||
.timeout(Duration::from_secs(2))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("closed port must fail");
|
||||
let described = describe_external_request_failure(&error);
|
||||
// 该端口在真实环境里可能被直接拒绝、也可能被中间层吞掉直到超时,两种都必须能定责。
|
||||
assert!(
|
||||
described.contains("连接失败") || described.contains("请求超时"),
|
||||
"{described}"
|
||||
);
|
||||
assert!(described.contains(":"), "必须补上底层因链:{described}");
|
||||
assert!(!described.contains("error sending request"), "{described}");
|
||||
assert!(!described.contains("http://127.0.0.1:1"), "{described}");
|
||||
}
|
||||
|
||||
fn read_test_http_request(stream: &mut std::net::TcpStream) -> String {
|
||||
stream
|
||||
.set_nonblocking(false)
|
||||
@@ -8843,7 +8911,8 @@ mod canvas_generation_tests {
|
||||
}),
|
||||
);
|
||||
false
|
||||
} else if request.starts_with("GET /api/external/v1/editor/projects ") {
|
||||
} else if request.starts_with("GET /api/external/v1/editor/projects?view=summary ")
|
||||
{
|
||||
write_test_json_response(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
@@ -9213,7 +9282,7 @@ mod canvas_generation_tests {
|
||||
.expect("write concurrent generation png body");
|
||||
return;
|
||||
}
|
||||
if request.starts_with("GET /api/external/v1/editor/projects ") {
|
||||
if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") {
|
||||
// 项目绑定已由 `install_test_external_project_binding` 预置,远端只需回认同一组身份。
|
||||
write_test_json_response(
|
||||
stream,
|
||||
@@ -9687,7 +9756,7 @@ mod canvas_generation_tests {
|
||||
"Bearer token-b" => "b",
|
||||
unexpected => panic!("unexpected authorization {unexpected}"),
|
||||
};
|
||||
let response = if request.starts_with("GET /api/editor/projects ") {
|
||||
let response = if request.starts_with("GET /api/editor/projects?view=summary ") {
|
||||
let projects = if project_created.get(account).copied().unwrap_or(false) {
|
||||
vec![serde_json::json!({
|
||||
"projectId": format!("remote-project-{account}"),
|
||||
@@ -9853,7 +9922,7 @@ mod canvas_generation_tests {
|
||||
request_sender
|
||||
.send(request.clone())
|
||||
.expect("capture concurrent binding request");
|
||||
let response = if request.starts_with("GET /api/editor/projects ") {
|
||||
let response = if request.starts_with("GET /api/editor/projects?view=summary ") {
|
||||
let (state_lock, ready) = &*state;
|
||||
let mut state = state_lock.lock().expect("lock project fixture state");
|
||||
if !state.project_created {
|
||||
@@ -9988,7 +10057,7 @@ mod canvas_generation_tests {
|
||||
request_sender
|
||||
.send(request.clone())
|
||||
.expect("capture project partial request");
|
||||
if request.starts_with("GET /api/external/v1/editor/projects ") {
|
||||
if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") {
|
||||
write_test_json_response(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
@@ -10122,7 +10191,7 @@ mod canvas_generation_tests {
|
||||
request_sender
|
||||
.send(request.clone())
|
||||
.expect("capture frozen binding request");
|
||||
if request.starts_with("GET /api/external/v1/editor/projects ") {
|
||||
if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") {
|
||||
write_test_json_response(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
@@ -10247,23 +10316,24 @@ mod canvas_generation_tests {
|
||||
request_sender
|
||||
.send(request.clone())
|
||||
.expect("capture folder partial request");
|
||||
let response = if request.starts_with("GET /api/external/v1/editor/projects ") {
|
||||
serde_json::json!({"data": {"projects": []}})
|
||||
} else if request.starts_with("POST /api/external/v1/editor/projects ") {
|
||||
serde_json::json!({"data": {"project": {
|
||||
"projectId": "folder-partial-project"
|
||||
}}})
|
||||
} else if request.starts_with("GET /api/external/v1/editor/assets/library ") {
|
||||
serde_json::json!({"data": {"library": {"folders": []}}})
|
||||
} else if request.starts_with("POST /api/external/v1/editor/assets/folders ") {
|
||||
fs::create_dir_all(&blocked_binding_path)
|
||||
.expect("block final binding write after folder creation");
|
||||
serde_json::json!({"data": {"folder": {
|
||||
"folderId": "folder-partial-folder"
|
||||
}}})
|
||||
} else {
|
||||
panic!("unexpected folder partial request: {request}");
|
||||
};
|
||||
let response =
|
||||
if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") {
|
||||
serde_json::json!({"data": {"projects": []}})
|
||||
} else if request.starts_with("POST /api/external/v1/editor/projects ") {
|
||||
serde_json::json!({"data": {"project": {
|
||||
"projectId": "folder-partial-project"
|
||||
}}})
|
||||
} else if request.starts_with("GET /api/external/v1/editor/assets/library ") {
|
||||
serde_json::json!({"data": {"library": {"folders": []}}})
|
||||
} else if request.starts_with("POST /api/external/v1/editor/assets/folders ") {
|
||||
fs::create_dir_all(&blocked_binding_path)
|
||||
.expect("block final binding write after folder creation");
|
||||
serde_json::json!({"data": {"folder": {
|
||||
"folderId": "folder-partial-folder"
|
||||
}}})
|
||||
} else {
|
||||
panic!("unexpected folder partial request: {request}");
|
||||
};
|
||||
write_test_json_response(&mut stream, "200 OK", &response);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -862,11 +862,22 @@ fn resource_edit_state_sha256<T: Serialize>(value: &T, label: &str) -> Result<St
|
||||
.map_err(|error| format!("序列化{label}身份失败:{error}"))
|
||||
}
|
||||
|
||||
fn ensure_resource_edit_phase_resumable(phase: &ResourceEditLedgerPhase) -> Result<(), String> {
|
||||
match phase {
|
||||
ResourceEditLedgerPhase::RemoteFailed => {
|
||||
Err("remote-terminal-failed: 远端资源编辑已明确失败,不允许再次请求".to_string())
|
||||
}
|
||||
/// 只有仍可继续的阶段允许再次请求远端;终态必须在这里失败关闭。
|
||||
///
|
||||
/// 终态文案要带出稳定失败码和唯一出口(移出恢复队列):否则用户只会看到一句
|
||||
/// 「已明确失败」并反复点重试。上游原文不进账本(见 `terminal_failure_code` 的写入边界),
|
||||
/// 这里只透传分类码,不做二次解释。
|
||||
fn ensure_resource_edit_phase_resumable(ledger: &ResourceEditLedger) -> Result<(), String> {
|
||||
match ledger.phase {
|
||||
ResourceEditLedgerPhase::RemoteFailed => Err(format!(
|
||||
"remote-terminal-failed: 远端资源编辑已明确失败({}),不允许再次请求;如需重试,请先在待恢复资源编辑中把它移出恢复队列",
|
||||
ledger
|
||||
.terminal_failure_code
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|code| !code.is_empty())
|
||||
.unwrap_or("remote-failed")
|
||||
)),
|
||||
ResourceEditLedgerPhase::Archived => {
|
||||
Err("resource-edit-archived: 资源编辑已移出恢复队列".to_string())
|
||||
}
|
||||
@@ -5122,7 +5133,7 @@ pub(crate) async fn request_resource_edit_service_identity_confirmation_at(
|
||||
{
|
||||
return Err("资源编辑服务身份确认对应的 operation 身份无效".to_string());
|
||||
}
|
||||
ensure_resource_edit_phase_resumable(&ledger.phase)?;
|
||||
ensure_resource_edit_phase_resumable(&ledger)?;
|
||||
let (api_base_url, api_key, platform_session) =
|
||||
resolve_canvas_sync_api_credentials(None, None)?;
|
||||
match prepare_resource_edit_service_identity(
|
||||
@@ -5195,7 +5206,7 @@ fn confirm_resource_edit_service_identity_under_lease(
|
||||
return Err("资源编辑服务身份确认对应的 operation 身份已变化".to_string());
|
||||
}
|
||||
validate_resource_edit_service_identity_owner(&ledger, platform_session)?;
|
||||
ensure_resource_edit_phase_resumable(&ledger.phase)?;
|
||||
ensure_resource_edit_phase_resumable(&ledger)?;
|
||||
let confirmation = ledger
|
||||
.service_identity_confirmation
|
||||
.clone()
|
||||
@@ -5321,7 +5332,7 @@ pub(crate) async fn resume_local_project_resource_edit_at(
|
||||
{
|
||||
return Err("待恢复的资源编辑账本身份无效".to_string());
|
||||
}
|
||||
ensure_resource_edit_phase_resumable(&ledger.phase)?;
|
||||
ensure_resource_edit_phase_resumable(&ledger)?;
|
||||
let source_asset = ledger
|
||||
.source_asset_id
|
||||
.as_deref()
|
||||
@@ -5552,7 +5563,7 @@ pub(crate) async fn derive_local_project_resource_at(
|
||||
{
|
||||
return Err("operationId 或幂等键已绑定到不同资源编辑请求".to_string());
|
||||
}
|
||||
ensure_resource_edit_phase_resumable(&ledger.phase)?;
|
||||
ensure_resource_edit_phase_resumable(&ledger)?;
|
||||
ledger
|
||||
}
|
||||
None => {
|
||||
@@ -6157,8 +6168,8 @@ mod tests {
|
||||
let request = read_http_request(&mut stream);
|
||||
let request_line = request.lines().next().unwrap_or_default();
|
||||
requests.push(request.clone());
|
||||
if request_line.starts_with("GET /api/external/v1/editor/projects ")
|
||||
|| request_line.starts_with("GET /api/editor/projects ")
|
||||
if request_line.starts_with("GET /api/external/v1/editor/projects?view=summary ")
|
||||
|| request_line.starts_with("GET /api/editor/projects?view=summary ")
|
||||
{
|
||||
write_json(
|
||||
&mut stream,
|
||||
@@ -6686,7 +6697,9 @@ mod tests {
|
||||
let request = read_http_request(&mut stream);
|
||||
sender.send(request.clone()).expect("capture B request");
|
||||
let request_line = request.lines().next().unwrap_or_default();
|
||||
if request_line.starts_with("GET /api/external/v1/editor/projects ") {
|
||||
if request_line
|
||||
.starts_with("GET /api/external/v1/editor/projects?view=summary ")
|
||||
{
|
||||
write_json(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
@@ -7186,7 +7199,7 @@ mod tests {
|
||||
);
|
||||
assert!(requests.iter().all(|request| {
|
||||
let line = request.lines().next().unwrap_or_default();
|
||||
line.starts_with("GET /api/external/v1/editor/projects ")
|
||||
line.starts_with("GET /api/external/v1/editor/projects?view=summary ")
|
||||
|| line.starts_with("GET /api/external/v1/editor/assets/library ")
|
||||
}));
|
||||
}
|
||||
@@ -9293,7 +9306,7 @@ mod tests {
|
||||
.expect("capture External video request");
|
||||
let request_line = request.lines().next().unwrap_or_default();
|
||||
let request_lower = request.to_ascii_lowercase();
|
||||
if request_line.starts_with("GET /api/external/v1/editor/projects ") {
|
||||
if request_line.starts_with("GET /api/external/v1/editor/projects?view=summary ") {
|
||||
write_json(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
|
||||
+2
-2
@@ -391,8 +391,8 @@ fn assert_developer_submission(request: &str) {
|
||||
|
||||
fn respond_canvas_context_request(stream: &mut TcpStream, request: &str) -> bool {
|
||||
let line = request.lines().next().unwrap_or_default();
|
||||
if line.starts_with("GET /api/external/v1/editor/projects ")
|
||||
|| line.starts_with("GET /api/editor/projects ")
|
||||
if line.starts_with("GET /api/external/v1/editor/projects?view=summary ")
|
||||
|| line.starts_with("GET /api/editor/projects?view=summary ")
|
||||
{
|
||||
write_json(
|
||||
stream,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -3337,8 +3337,8 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate(
|
||||
}
|
||||
let normalized_request = request.to_ascii_lowercase();
|
||||
let (status, content_type, body) = if request
|
||||
.starts_with("GET /api/external/v1/editor/projects ")
|
||||
|| request.starts_with("GET /api/editor/projects ")
|
||||
.starts_with("GET /api/external/v1/editor/projects?view=summary ")
|
||||
|| request.starts_with("GET /api/editor/projects?view=summary ")
|
||||
{
|
||||
assert!(normalized_request.contains("authorization: bearer "));
|
||||
("200 OK", "application/json", projects_body.as_bytes().to_vec())
|
||||
@@ -3564,8 +3564,8 @@ fn spawn_mock_external_canvas_generation_failure_server() -> String {
|
||||
let (status, body) = match index {
|
||||
0 => {
|
||||
assert!(
|
||||
request.starts_with("GET /api/external/v1/editor/projects ")
|
||||
|| request.starts_with("GET /api/editor/projects ")
|
||||
request.starts_with("GET /api/external/v1/editor/projects?view=summary ")
|
||||
|| request.starts_with("GET /api/editor/projects?view=summary ")
|
||||
);
|
||||
(
|
||||
"200 OK",
|
||||
|
||||
+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",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user