Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95a3b9f593 |
-11
@@ -41,14 +41,3 @@
|
|||||||
## 注意
|
## 注意
|
||||||
|
|
||||||
不同 enum 的 variant 顺序必须以生成 binding 或 module 源码为准,不能复用其他 enum 的索引映射。
|
不同 enum 的 variant 顺序必须以生成 binding 或 module 源码为准,不能复用其他 enum 的索引映射。
|
||||||
|
|
||||||
## 通用表查询页的枚举展示(2026-09-23 起)
|
|
||||||
|
|
||||||
后台“表查询”(`#tables`)不再逐表硬编码枚举映射,改为按 schema 自动解析:
|
|
||||||
|
|
||||||
- api-server 在 `server-rs/crates/api-server/src/admin.rs` 读取 schema 的 `typespace.types` 和表的 `product_type_ref`,对每个“`Sum` 且所有变体都是单元变体(`Product.elements` 为空)”的列生成 `列名 -> [按变体索引排列的展示名]`,变体名归一到 snake_case。
|
|
||||||
- `Option<枚举>` 列单独标记为可空:`[0, [索引, []]]` 出变体名,`[1, []]` 仍是空值。`Option<普通值>` 与带载荷的 Sum 直接跳过,交回通用解码,避免把普通 `Option` 列误标成枚举名。
|
|
||||||
- 映射同时应用到 `cells` 与 `raw`,因此关键词搜索、结构化筛选、稳定排序解析到的都是展示名。
|
|
||||||
- 单变体枚举也要出名字;变体索引顺序以 schema 为准,不依赖生成 binding 的副本。
|
|
||||||
|
|
||||||
因此新增表或新增枚举列无需再改后端映射,只要模块已发布且 schema 可读;如果 schema 读取失败,表查询会以“表不存在”失败,而不是退回展示数字。定向验证:`cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server admin_database`。
|
|
||||||
|
|||||||
@@ -5,42 +5,131 @@ description: Guide use of Genarrative's hosted external editor/canvas MCP or asy
|
|||||||
|
|
||||||
# Genarrative External Editor API
|
# Genarrative External Editor API
|
||||||
|
|
||||||
Use Genarrative to manage canvas projects and asset-library records, generate images, character animations, videos, and audio, and edit images. Discover the hosted integration at `https://www.genarrative.world/api/external/v1/agent-integration.json`.
|
Discover the live integration through `GET https://www.genarrative.world/api/external/v1/agent-integration.json`. Treat `GET https://www.genarrative.world/api/external/v1/openapi.json` as the field-level source of truth. In this repository, the same contract is `docs/openapi/genarrative-external-v1.openapi.json`.
|
||||||
|
|
||||||
Connect to `https://www.genarrative.world/api/external/v1/mcp` using Streamable HTTP and a Bearer API Key. Read tool descriptions and input schemas from `tools/list`; read the documents below through `resources/read` when needed. The hosted service needs no local MCP server. For direct REST, use the live `/api/external/v1/openapi.json` contract (in this repository: `docs/openapi/genarrative-external-v1.openapi.json`).
|
Prefer the hosted Streamable HTTP MCP at `https://www.genarrative.world/api/external/v1/mcp` when the Agent supports remote MCP with a custom Bearer token. It exposes the External v1 operations as tools and the Skill documentation as resources; it does not require a local MCP server. Use this complete Skill package when remote MCP is unavailable or local-file upload needs client-side orchestration.
|
||||||
|
|
||||||
## Working with the Service
|
Prefer `scripts/genarrative_external_api.py` for runnable REST calls. It uses only Python stdlib, reads the local private API Key file, keeps the production base URL fixed, uploads local references, and wraps asynchronous submission, polling, and result retrieval.
|
||||||
|
|
||||||
- Select tools by the requested outcome. Use `find_canvas_projects` and `find_assets` to locate existing context; create projects or folders only when the task needs them. A folder need not have the same name as the project.
|
## Workflow
|
||||||
- For generation, specify project, library, and `canvasCompletion` fields only as supported by the selected tool and needed for the requested destination. Do not duplicate records already created by generation.
|
|
||||||
- Upload local references using `prepare_asset_upload`: request a ticket, transfer the file from the client, then confirm the object. Confirmation does not create a canvas layer or a project/library record. Use the reference type accepted by the target tool; some operations require a registered resource or asset ID rather than an object key.
|
1. Discover the integration manifest. Choose hosted MCP when supported; otherwise use the helper or direct REST.
|
||||||
- Generation is paid and asynchronous. Keep one stable `idempotencyKey` per logical generation and retain the returned `operationId`. Call `check_generation` according to `pollAfterMs`; consume `result` only after `completed`, and report the safe error on `failed`. A polling timeout does not justify another generation.
|
2. Before the first generation in a new conversation, obtain a canvas name unless an existing `projectId` and `assetFolderId` were supplied. Create or reuse a project and a same-name asset-library folder. Retain `canvasName`, `projectId`, `assetFolderId`, and the current art spec.
|
||||||
- Read actual artifacts and warnings before claiming the requested deliverable is complete. Use project/library reads for complete persisted records, and `find_assets` with `action=get_download_url` for temporary media access.
|
3. Normalize art requests into a reusable spec. Ask only for missing values that block the selected operation. Reuse the spec until the user changes its style, subject family, palette, format, or constraints.
|
||||||
- Keep API Keys and temporary upload/download credentials out of chat, repository files, and logs. Business calls operate within the API Key's owner and scopes.
|
4. Infer the operation from the user's intent. Do not ask the user to select an API unless two operations would produce materially different artifacts.
|
||||||
|
5. If a reference exists only as a local file, upload and confirm it first. Pass the stable returned `objectKey` to operations that accept object references; never substitute a temporary signed URL. For an icon-spritesheet primary spec, additionally create a project resource or asset record with `assetKind="icon-spec"`, then pass the returned resource or asset ID as `referenceId`.
|
||||||
|
6. For generation endpoints that support the fields, include `projectId`, `assetFolderId`, an asset label, and `canvasCompletion` so the result enters both the canvas and its same-name library folder.
|
||||||
|
7. Treat every generation POST as asynchronous. Send one stable `Idempotency-Key` per logical request, retain the returned `operationId`, and poll the returned `statusUrl` or `GET /api/external/v1/generations/{operationId}` according to `pollAfterMs`.
|
||||||
|
8. Consume `result` only after `status=completed`. On `failed`, surface the safe error. On a client timeout or lost response, retain the operation/key; do not create a replacement request.
|
||||||
|
9. Reload the normal project or asset-library read endpoint when the caller needs complete authoritative state. Generation results are intentionally compact.
|
||||||
|
10. Stay within `/api/external/v1`. Never call internal workers, queues, admin/profile APIs, or SpacetimeDB endpoints unless the user explicitly changes scope.
|
||||||
|
|
||||||
|
## Essential Invariants
|
||||||
|
|
||||||
|
- Authenticate MCP and business API calls with `Authorization: Bearer <tnr_sk_...>`. Never ask the user to paste a key into chat or place one in repository files.
|
||||||
|
- All nine generation POST routes require `Idempotency-Key` and return HTTP `202`; `202` is durable acceptance, not a media result.
|
||||||
|
- Retry an uncertain submission only with the exact same body and the same idempotency key. A polling timeout is not permission to generate again.
|
||||||
|
- Use stable references such as `objectKey`, project resource ID, or asset ID where each operation permits them. Image edit/redraw is stricter: `sourceReferenceId` accepts only a registered project resource ID or asset ID; upload confirmation alone is not enough. Use `/assets/read-url` only for temporary preview/download access.
|
||||||
|
- Preserve both warning channels after completion. A general `warning` can coexist with `sliceWarning`; do not discard either.
|
||||||
|
- Do not invent missing derivatives. A source-preserved warning means the main source remains usable but requested post-processing failed. A slice warning means the complete transparent sheet is usable but individual slices are absent.
|
||||||
|
- Icon spritesheet generation requires an explicit `sliceMode` and has no default. Use `sliceMode="grid"` with the `gridX` and `gridY` the requirement actually names (1-32 each) only for equal grid cells or fixed slots; use `sliceMode="connected-components"` for free-form sheets or an open number of subjects, and constrain the count with `sliceCount` instead of inventing grid dimensions. `connected-components` must not carry `gridX`/`gridY`; an omitted, contradictory, or misapplied declaration returns 400 before billing.
|
||||||
|
- For successful `style="pixelArt"`, treat completed-result and nested resource/asset dimensions as the final logical-grid PNG dimensions. They may differ from `size`, `imageSize`, the provider image, and `canvasCompletion.placeholder`; do not rescale or reject the artifact to match those inputs.
|
||||||
|
- Keep generated artifacts in the canvas and asset library together. Character animation accepts `assetFolderId` and `assetLabel`; its completed result directly returns the final `assetKind="character-animation"` resource and asset with formal sequence fields. Do not create a duplicate first-frame record.
|
||||||
|
|
||||||
## Documentation Navigation
|
## Documentation Navigation
|
||||||
|
|
||||||
Read the reference relevant to the current operation; exact input fields and enums come from the tool schema or OpenAPI.
|
Read only the references needed for the task, but always verify exact schemas and enums against live OpenAPI:
|
||||||
|
|
||||||
| Need | Reference | MCP resource URI |
|
- `references/capability-routing.md`: read before selecting an MCP tool or REST operation, creating a canvas session, or working in the AI game creator visual DAG.
|
||||||
| --- | --- | --- |
|
- `references/api-operations.md`: read when constructing project, canvas, asset-library, upload, generation, or generation-status calls.
|
||||||
| Choose tools and actions by user intent | [Capability routing](references/capability-routing.md) | `genarrative://external-editor/skill/references/capability-routing.md` |
|
- `references/authentication-and-safety.md`: read before handling credentials, local files, OSS form upload, retries, private media, or logs.
|
||||||
| Map tool calls to REST operations | [API operations](references/api-operations.md) | `genarrative://external-editor/skill/references/api-operations.md` |
|
- `references/requests-and-outputs.md`: read before building generation payloads, polling, interpreting compact results, applying canvas completion, or handling post-processing warnings.
|
||||||
| Configure credentials, upload files, handle retries and deletion | [Authentication and safety](references/authentication-and-safety.md) | `genarrative://external-editor/skill/references/authentication-and-safety.md` |
|
|
||||||
| Construct requests, poll results, place media, handle warnings | [Requests and outputs](references/requests-and-outputs.md) | `genarrative://external-editor/skill/references/requests-and-outputs.md` |
|
|
||||||
|
|
||||||
`genarrative://external-editor/usage` contains the short service instructions; `genarrative://external-editor/openapi` contains the REST contract. This entry is available at `genarrative://external-editor/skill`. Reading a resource does not install the downloadable Skill or its Python helper.
|
The hosted MCP exposes the same documents through:
|
||||||
|
|
||||||
## Direct REST and Local Helpers
|
- `genarrative://external-editor/skill`
|
||||||
|
- `genarrative://external-editor/skill/references/capability-routing.md`
|
||||||
|
- `genarrative://external-editor/skill/references/api-operations.md`
|
||||||
|
- `genarrative://external-editor/skill/references/authentication-and-safety.md`
|
||||||
|
- `genarrative://external-editor/skill/references/requests-and-outputs.md`
|
||||||
|
- `genarrative://external-editor/openapi`
|
||||||
|
|
||||||
When remote MCP is unavailable or local-file orchestration needs a helper, the complete package is available at `GET /api/external/v1/skill.zip`; the raw entry is at `GET /api/external/v1/skill/SKILL.md`. Verify the archive SHA-256 against the integration manifest before installing. The archive includes this entry, four references, `scripts/genarrative_external_api.py`, and `agents/openai.yaml`. Discovery and documentation downloads are public; MCP and business calls require authentication.
|
## Hosted Integration Discovery
|
||||||
|
|
||||||
The Python stdlib helper reads the private API Key file described in [authentication and safety](references/authentication-and-safety.md) and uses the production base URL. For a read-only smoke test:
|
- Manifest: `GET /api/external/v1/agent-integration.json`.
|
||||||
|
- Hosted MCP: `POST /api/external/v1/mcp`, Streamable HTTP, same Bearer API Key.
|
||||||
|
- OpenAPI: `GET /api/external/v1/openapi.json`.
|
||||||
|
- Raw Skill entry: `GET /api/external/v1/skill/SKILL.md`.
|
||||||
|
- Complete Skill archive: `GET /api/external/v1/skill.zip`.
|
||||||
|
|
||||||
|
The archive contains this main file, four one-level references, the Python helper, and `agents/openai.yaml`. Verify its SHA-256 against `agent-integration.json` before installing. Discovery, OpenAPI, and Skill downloads are public; MCP and business operations require authentication.
|
||||||
|
|
||||||
|
## Python Helper
|
||||||
|
|
||||||
|
Store the API Key outside the repository at `~/.config/genarrative/external-editor-api.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiKey": "tnr_sk_..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Set restrictive permissions where possible, then smoke-test without printing the key:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
chmod 600 ~/.config/genarrative/external-editor-api.json
|
||||||
python3 .codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py list-projects
|
python3 .codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py list-projects
|
||||||
```
|
```
|
||||||
|
|
||||||
Its `prepare_canvas_session` convenience method creates or reuses a project and a same-name folder. Use it only when that organization matches the task; it is not a prerequisite for MCP or REST calls. Convenience generation methods wait locally while the server uses short asynchronous submit/status requests. Use `submit_generation`, `get_generation`, and `wait_for_generation` for caller-controlled orchestration; see [requests and outputs](references/requests-and-outputs.md).
|
For a canvas-backed generation:
|
||||||
|
|
||||||
Stay within `/api/external/v1` for this integration. Internal workers, queues, admin/profile APIs, and SpacetimeDB endpoints are outside this contract.
|
```python
|
||||||
|
from genarrative_external_api import GenarrativeExternalClient
|
||||||
|
|
||||||
|
client = GenarrativeExternalClient()
|
||||||
|
session = client.prepare_canvas_session("新画板")
|
||||||
|
client.generate_image(
|
||||||
|
"生成一张 16:9 幻想森林游戏背景",
|
||||||
|
canvasSession=session,
|
||||||
|
assetLabel="森林背景",
|
||||||
|
aspectRatio="16:9",
|
||||||
|
imageSize="1K",
|
||||||
|
artSpec={
|
||||||
|
"assetType": "background",
|
||||||
|
"subject": "幻想森林主视觉",
|
||||||
|
"style": "手绘游戏概念图",
|
||||||
|
"palette": "翡翠绿与金色光斑",
|
||||||
|
"composition": "横版,中心留出角色站位",
|
||||||
|
"format": "16:9, 1K",
|
||||||
|
"constraints": "无文字、无 UI 按钮",
|
||||||
|
"references": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
For background removal, pass a stable owner-scoped object key, project resource ID, or asset ID; the helper keeps the same asynchronous submission and polling contract:
|
||||||
|
|
||||||
|
```python
|
||||||
|
session = client.prepare_canvas_session("去背景画布")
|
||||||
|
client.remove_background(
|
||||||
|
"editor-upload/object.png",
|
||||||
|
source_width=720,
|
||||||
|
source_height=1280,
|
||||||
|
canvasSession=session,
|
||||||
|
assetLabel="去背景结果",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Background removal preserves the source pixel size. For normal canvas placement with `canvasSession`, pass the real `source_width` and `source_height`, or provide both `canvasWidth` and `canvasHeight`; the helper rejects missing dimensions instead of guessing a square placeholder. `assetKind` may only describe a static image and must match the authoritative source record. Prefer a project resource ID or asset ID when the same object key has multiple semantic registrations; for a raw object key outside in-place replacement, pass `sourceResourceId` to disambiguate. Passing `targetLayerId` selects in-place replacement: the helper retains the session's project/library context but does not inject `canvasCompletion`, and it rejects an explicit `canvasCompletion` combined with `targetLayerId`. The target layer must point to the same authoritative object as the source, and the server durably binds a raw object key to that target resource for Worker revalidation.
|
||||||
|
|
||||||
|
Helper convenience methods wait locally, but the server still uses short asynchronous submit/status requests. For durable caller-controlled orchestration, call `submit_generation`, persist its `operationId` and idempotency key, then call `get_generation` or `wait_for_generation`.
|
||||||
|
|
||||||
|
For character animation, pass the canvas session and asset label to `animate_character`. The helper submits asynchronously and returns the completed compact result containing the authoritative formal `resource` and `asset`; do not synthesize a library asset from the first frame.
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
- Do not change the fixed production base URL in generated examples.
|
||||||
|
- Do not move the API Key into environment variables, source files, generated projects, logs, docs, screenshots, or shell snippets containing literal secrets.
|
||||||
|
- Do not treat a Data URL, Blob URL, expiring signed URL, worker lease, or provider diagnostic as a durable result.
|
||||||
|
- Do not reconstruct authoritative canvas, resource, or library snapshots from a compact generation response.
|
||||||
|
- Do not replace icon-spritesheet generation with ordinary image generation when the deliverable requires a reusable transparent atlas.
|
||||||
|
|||||||
@@ -4,44 +4,6 @@ Use this reference after selecting a capability. Treat `GET /api/external/v1/ope
|
|||||||
|
|
||||||
All paths below are relative to `https://www.genarrative.world`. Discovery and Skill download routes are public. Project, asset, upload, generation, and generation-query operations require the Bearer API Key.
|
All paths below are relative to `https://www.genarrative.world`. Discovery and Skill download routes are public. Project, asset, upload, generation, and generation-query operations require the Bearer API Key.
|
||||||
|
|
||||||
## MCP Tool to API Map
|
|
||||||
|
|
||||||
The hosted MCP offers the following tools. Choose the task tool when its action matches the request; the operation tool calls the indicated REST operation directly. Task tools with actions take `{ "action": "...", "input": { ... } }`; tools without actions take the operation fields directly. `idempotencyKey` is top-level in task tools. Operation tools use `body`, `pathParameters`, and `queryParameters` wrappers from their live input schemas. Read the live tool schema and OpenAPI for exact required fields.
|
|
||||||
|
|
||||||
| REST operation | Task tool (action) | Operation tool |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `GET /api/external/v1/openapi.json` | — | `get_external_open_api_json` |
|
|
||||||
| `GET /api/external/v1/editor/projects` | `find_canvas_projects` (`list`) | `list_editor_projects` |
|
|
||||||
| `GET /api/external/v1/editor/projects/recent` | `find_canvas_projects` (`recent`) | `load_recent_editor_project` |
|
|
||||||
| `GET /api/external/v1/editor/projects/{projectId}` | `find_canvas_projects` (`get`), `find_assets` (`get_project_resources`), `edit_canvas` (`get`) | `get_editor_project` |
|
|
||||||
| `POST /api/external/v1/editor/projects` | `manage_canvas_projects` (`create`) | `create_editor_project` |
|
|
||||||
| `PATCH /api/external/v1/editor/projects/{projectId}/metadata` | `manage_canvas_projects` (`rename`) | `rename_editor_project` |
|
|
||||||
| `DELETE /api/external/v1/editor/projects/{projectId}` | `delete_resources` (`delete_project`) | `delete_editor_project` |
|
|
||||||
| `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `edit_canvas` (`save_layout`) | `save_editor_project_canvas` |
|
|
||||||
| `POST /api/external/v1/editor/projects/{projectId}/resources` | `edit_canvas` (`register_resource`) | `create_editor_project_resource` |
|
|
||||||
| `POST /api/external/v1/assets/direct-upload-tickets` | `prepare_asset_upload` (`create_upload_ticket`) | `create_external_direct_upload_ticket` |
|
|
||||||
| `POST /api/external/v1/assets/objects/confirm` | `prepare_asset_upload` (`confirm_upload`) | `confirm_external_asset_object` |
|
|
||||||
| `GET /api/external/v1/assets/read-url` | `find_assets` (`get_download_url`) | `get_external_asset_read_url` |
|
|
||||||
| `GET /api/external/v1/editor/assets/library` | `find_assets` (`list_library`) | `get_editor_asset_library` |
|
|
||||||
| `POST /api/external/v1/editor/assets/folders` | `organize_asset_library` (`create_folder`) | `create_editor_asset_folder` |
|
|
||||||
| `PATCH /api/external/v1/editor/assets/folders/{folderId}` | `organize_asset_library` (`update_folder`) | `update_editor_asset_folder` |
|
|
||||||
| `DELETE /api/external/v1/editor/assets/folders/{folderId}` | `delete_resources` (`delete_folder`) | `delete_editor_asset_folder` |
|
|
||||||
| `POST /api/external/v1/editor/assets` | `organize_asset_library` (`create_asset`) | `create_editor_asset` |
|
|
||||||
| `PATCH /api/external/v1/editor/assets/{assetId}` | `organize_asset_library` (`update_asset`) | `update_editor_asset` |
|
|
||||||
| `DELETE /api/external/v1/editor/assets/{assetId}` | `delete_resources` (`delete_asset`) | `delete_editor_asset` |
|
|
||||||
| `POST /api/external/v1/editor/images/generations` | `generate_image`, `modify_image` (`variation`, fixed `kind="quick-edit"`) | `generate_external_editor_image` |
|
|
||||||
| `POST /api/external/v1/editor/images/edits` | `modify_image` (`edit`) | `edit_external_editor_image` |
|
|
||||||
| `POST /api/external/v1/editor/images/background-removals` | `modify_image` (`remove_background`) | `remove_external_editor_image_background` |
|
|
||||||
| `POST /api/external/v1/editor/icon-spritesheets/generations` | `generate_icon_spritesheet` | `generate_external_editor_icon_spritesheet` |
|
|
||||||
| `POST /api/external/v1/editor/ui-designs/assets/extractions` | `extract_ui_assets` | `extract_external_editor_ui_design_assets` |
|
|
||||||
| `POST /api/external/v1/editor/character-animations/generations` | `generate_character_animation` | `generate_external_editor_character_animation` |
|
|
||||||
| `POST /api/external/v1/editor/videos/generations` | `generate_video` | `generate_external_editor_video` |
|
|
||||||
| `POST /api/external/v1/editor/audios/sound-effects/generations` | `generate_audio` (`sound_effect`) | `generate_external_editor_sound_effect` |
|
|
||||||
| `POST /api/external/v1/editor/audios/background-music/generations` | `generate_audio` (`background_music`) | `generate_external_editor_background_music` |
|
|
||||||
| `GET /api/external/v1/generations/{operationId}` | `check_generation` | `get_external_editor_generation_job` |
|
|
||||||
|
|
||||||
The public `agent-integration.json`, `skill/SKILL.md`, and `skill.zip` routes and the MCP transport route are HTTP entry points, not callable MCP tools. The hosted resource URIs remain `genarrative://external-editor/skill`, `genarrative://external-editor/skill/references/capability-routing.md`, `genarrative://external-editor/skill/references/api-operations.md`, `genarrative://external-editor/skill/references/authentication-and-safety.md`, `genarrative://external-editor/skill/references/requests-and-outputs.md`, and `genarrative://external-editor/openapi`.
|
|
||||||
|
|
||||||
## Project and Canvas Operations
|
## Project and Canvas Operations
|
||||||
|
|
||||||
| Operation | Method and path | Minimum input |
|
| Operation | Method and path | Minimum input |
|
||||||
@@ -61,7 +23,7 @@ Project listing supports two views:
|
|||||||
|
|
||||||
- `view=full` is the REST default and returns the complete project, canvas, layers, and resources.
|
- `view=full` is the REST default and returns the complete project, canvas, layers, and resources.
|
||||||
- `view=summary` returns only `projectId`, `title`, `updatedAt`, and nullable `cover`, so callers can display, search, disambiguate same-name projects, and select a safe target without loading every canvas snapshot.
|
- `view=summary` returns only `projectId`, `title`, `updatedAt`, and nullable `cover`, so callers can display, search, disambiguate same-name projects, and select a safe target without loading every canvas snapshot.
|
||||||
- Hosted MCP `list_editor_projects` and `find_canvas_projects` (`list`) use `summary`; call `get_editor_project` or `find_canvas_projects` (`get`) after selecting a `projectId` when complete authoritative state is required.
|
- Hosted MCP `list_editor_projects` always uses `summary`; call `get_editor_project` after selecting a `projectId` when complete authoritative state is required.
|
||||||
- `cover` contains only `resourceId`, stable `objectKey`, dimensions, and `updatedAt`. It never embeds image bytes, a Data URL, or a signed URL. To display it, pass `cover.objectKey` to `get_external_asset_read_url`; signed URLs are temporary and must not be persisted or reused as generation references.
|
- `cover` contains only `resourceId`, stable `objectKey`, dimensions, and `updatedAt`. It never embeds image bytes, a Data URL, or a signed URL. To display it, pass `cover.objectKey` to `get_external_asset_read_url`; signed URLs are temporary and must not be persisted or reused as generation references.
|
||||||
|
|
||||||
## Asset and Upload Operations
|
## Asset and Upload Operations
|
||||||
@@ -80,7 +42,6 @@ Project listing supports two views:
|
|||||||
| Delete asset record | `DELETE /api/external/v1/editor/assets/{assetId}` | `assetId` |
|
| Delete asset record | `DELETE /api/external/v1/editor/assets/{assetId}` | `assetId` |
|
||||||
|
|
||||||
Upload is a three-step client flow: create a ticket, POST the file and returned fields directly to the OSS form endpoint, then confirm the returned `objectKey`. See `authentication-and-safety.md` before implementing this flow.
|
Upload is a three-step client flow: create a ticket, POST the file and returned fields directly to the OSS form endpoint, then confirm the returned `objectKey`. See `authentication-and-safety.md` before implementing this flow.
|
||||||
`prepare_asset_upload` handles the ticket and confirmation as separate calls; it does not send local bytes to OSS or automatically register a project resource, asset record, or canvas layer. `manage_canvas_projects` (`create`) likewise does not create a same-name asset folder. Register or organize records only when the task needs them.
|
|
||||||
|
|
||||||
## Generation Operations
|
## Generation Operations
|
||||||
|
|
||||||
@@ -91,7 +52,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
|
|||||||
| Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
| Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||||
| Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion` |
|
| Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion` |
|
||||||
| Background removal | `/api/external/v1/editor/images/background-removals` | `sourceImageSrc` | `projectId`, `sourceResourceId`, `targetLayerId`, static-image `assetKind`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
| Background removal | `/api/external/v1/editor/images/background-removals` | `sourceImageSrc` | `projectId`, `sourceResourceId`, `targetLayerId`, static-image `assetKind`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||||
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions`, `sliceMode` | `gridX`, `gridY`, `sliceCount`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
|
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions` | `sliceMode`, `gridX`, `gridY`, `sliceCount`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
|
||||||
| UI asset extraction | `/api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` |
|
| UI asset extraction | `/api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` |
|
||||||
| Character animation | `/api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
|
| Character animation | `/api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
|
||||||
| Video generation | `/api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
|
| Video generation | `/api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
|
||||||
@@ -108,10 +69,10 @@ Supply the `operationId` returned by submission. Poll no faster than `pollAfterM
|
|||||||
|
|
||||||
## Canvas and Library Field Rules
|
## Canvas and Library Field Rules
|
||||||
|
|
||||||
- Pass `projectId` and `canvasCompletion` when the task calls for generated output in a canvas.
|
- Pass `projectId` and `canvasCompletion` to write generated output into the canvas.
|
||||||
- Pass `assetFolderId` plus the relevant label field when the task calls for a library record. Neither destination requires the other, and their names need not match.
|
- Pass `assetFolderId` plus `assetLabel` for image, edit, icon spritesheet, video, sound effect, and BGM operations when supported.
|
||||||
- UI extraction uses `assetFolderId` and `spritesheetLabel`.
|
- UI extraction uses `assetFolderId` and `spritesheetLabel`.
|
||||||
- Character animation accepts `assetFolderId` and `assetLabel` and persists the generated sequence. Consume returned artifacts and persisted identities; never create a duplicate first-frame resource or asset.
|
- Character animation accepts `assetFolderId` and `assetLabel`. Its completed compact result directly returns the final `assetKind="character-animation"` resource and asset with `imageSequenceFrames` and `imageSequenceDurationMs`; never create a duplicate first-frame resource or asset.
|
||||||
- Background removal derives the final static-image `assetKind` from the authoritative source record. A conflicting request kind or any video, audio, animation, or image-sequence kind returns `400` before queueing. Without `canvasCompletion`, `targetLayerId` must point to the same authoritative object as `sourceImageSrc` (prefer `assetObjectId`, otherwise canonical bucket/object key).
|
- Background removal derives the final static-image `assetKind` from the authoritative source record. A conflicting request kind or any video, audio, animation, or image-sequence kind returns `400` before queueing. Without `canvasCompletion`, `targetLayerId` must point to the same authoritative object as `sourceImageSrc` (prefer `assetObjectId`, otherwise canonical bucket/object key).
|
||||||
- If a caller must manually create a `character-animation` resource or asset, put the authoritative frames and total sequence duration in `imageSequenceFrames` and `imageSequenceDurationMs`. Keep `generationInputs` replayable: it must not contain legacy runtime fields such as `characterAnimation`, `frames`, `previewVideoPath`, `frameCount`, `fps`, or `durationSeconds`.
|
- If a caller must manually create a `character-animation` resource or asset, put the authoritative frames and total sequence duration in `imageSequenceFrames` and `imageSequenceDurationMs`. Keep `generationInputs` replayable: it must not contain legacy runtime fields such as `characterAnimation`, `frames`, `previewVideoPath`, `frameCount`, `fps`, or `durationSeconds`.
|
||||||
- Reload project/library state after completion when full current state is required.
|
- Reload project/library state after completion when full current state is required.
|
||||||
|
|||||||
+7
-15
@@ -21,7 +21,7 @@ Authorization: Bearer <tnr_sk_...>
|
|||||||
|
|
||||||
Guide a logged-in user to create a key in the product UI under `开发者 API Key`. The raw key is shown only once. Never ask the user to paste it into chat.
|
Guide a logged-in user to create a key in the product UI under `开发者 API Key`. The raw key is shown only once. Never ask the user to paste it into chat.
|
||||||
|
|
||||||
For hosted MCP, configure the Bearer token in the client's private connection settings. For the bundled REST helper, store it outside repositories in the user's private JSON file:
|
Store it outside repositories in the user's private JSON file:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
~/.config/genarrative/external-editor-api.json
|
~/.config/genarrative/external-editor-api.json
|
||||||
@@ -61,7 +61,7 @@ The OpenAPI document, integration manifest, raw Skill entry, and Skill archive a
|
|||||||
|
|
||||||
For each logical generation:
|
For each logical generation:
|
||||||
|
|
||||||
1. Create one printable ASCII key of 1-128 bytes. MCP takes `idempotencyKey` at the top level of the tool arguments (outside `input`); REST takes the `Idempotency-Key` header.
|
1. Create one printable ASCII `Idempotency-Key` of 1-128 bytes.
|
||||||
2. Persist the key with the exact request body and returned `operationId`.
|
2. Persist the key with the exact request body and returned `operationId`.
|
||||||
3. If submission transport fails or the response is lost, resend only the exact same body with the same key.
|
3. If submission transport fails or the response is lost, resend only the exact same body with the same key.
|
||||||
4. Never allocate a new key merely because the outcome is unknown.
|
4. Never allocate a new key merely because the outcome is unknown.
|
||||||
@@ -69,21 +69,15 @@ For each logical generation:
|
|||||||
|
|
||||||
Treat a different body under the same key as invalid. Do not automatically replay a failed terminal generation unless the user intentionally requests a new logical generation.
|
Treat a different body under the same key as invalid. Do not automatically replay a failed terminal generation unless the user intentionally requests a new logical generation.
|
||||||
|
|
||||||
Keep the API operation as well as the request and key unchanged across a submission retry. A different tool name does not create a separate idempotency namespace. When an operation ID is known, query `check_generation` directly. A rejected submission is not permission to switch keys and generate again.
|
|
||||||
|
|
||||||
`manage_canvas_projects/create`, `edit_canvas/register_resource`, and `organize_asset_library/create_folder` accept optional top-level `idempotencyKey`. Other non-generation actions do not accept it; in particular, `create_asset` is not an idempotent generation submission.
|
|
||||||
|
|
||||||
## Local Reference Upload
|
## Local Reference Upload
|
||||||
|
|
||||||
Do not ask the user to convert local files to base64. Upload from the Agent/client machine:
|
Do not ask the user to convert local files to base64. Upload from the Agent/client machine:
|
||||||
|
|
||||||
1. Detect the original filename, MIME type, byte length, and image dimensions when relevant.
|
1. Detect the original filename, MIME type, byte length, and image dimensions when relevant.
|
||||||
2. Call `prepare_asset_upload` with `action=create_upload_ticket` and the ticket body in `input` (REST: `POST /api/external/v1/assets/direct-upload-tickets`).
|
2. Create a ticket with `POST /api/external/v1/assets/direct-upload-tickets`.
|
||||||
3. POST all returned non-null `formFields` and the file part named `file` directly to `upload.host`.
|
3. POST all returned non-null `formFields` and the file part named `file` directly to `upload.host`.
|
||||||
4. Call `prepare_asset_upload` with `action=confirm_upload` and the confirmation body in `input` (REST: `POST /api/external/v1/assets/objects/confirm`).
|
4. Confirm the object with `POST /api/external/v1/assets/objects/confirm`.
|
||||||
5. Pass the confirmed stable `objectKey` where the selected tool permits it. For operations requiring a registered source, register a project resource or asset first and use its ID.
|
5. Pass the confirmed stable `objectKey` to the selected editor operation.
|
||||||
|
|
||||||
The MCP tool does not transfer file bytes and does not accept a local path or base64. The client needs an HTTP/file-transfer capability for step 3. Object ownership comes from the API Key; do not supply `ownerUserId`. Confirmation alone creates neither a library record nor a canvas layer.
|
|
||||||
|
|
||||||
For a private reference image, use a ticket body shaped like:
|
For a private reference image, use a ticket body shaped like:
|
||||||
|
|
||||||
@@ -113,7 +107,7 @@ Confirm with the actual file metadata:
|
|||||||
|
|
||||||
`contentLength` is a JSON number in bytes, not a quoted string. Never invent `sourceWidth` or `sourceHeight`; read them from the local image or ask the user if they cannot be determined.
|
`contentLength` is a JSON number in bytes, not a quoted string. Never invent `sourceWidth` or `sourceHeight`; read them from the local image or ask the user if they cannot be determined.
|
||||||
|
|
||||||
For character animation, use source identity and dimensions from the actual selected resource; do not invent an existing canvas layer. The bundled local-file helper can maintain its own stable source label, which is not evidence of a persisted canvas layer.
|
For character animation, reuse a real canvas layer ID when available. For a local-only source, derive a stable synthetic `sourceLayerId`, such as `external-reference-hero`, from the filename and keep it unchanged across retries.
|
||||||
|
|
||||||
The bundled helper implements ticket creation, a stdlib multipart upload, confirmation, dimension detection for common formats, and stable source-layer IDs:
|
The bundled helper implements ticket creation, a stdlib multipart upload, confirmation, dimension detection for common formats, and stable source-layer IDs:
|
||||||
|
|
||||||
@@ -132,7 +126,7 @@ Do not print the complete confirmation response if it may contain temporary acce
|
|||||||
- Use `objectKey`, project resource ID, asset ID, or an allowed durable public URL for generation input.
|
- Use `objectKey`, project resource ID, asset ID, or an allowed durable public URL for generation input.
|
||||||
- Use a Data URL only when the endpoint explicitly allows it and the caller has a deliberate reason; do not persist it as a durable output.
|
- Use a Data URL only when the endpoint explicitly allows it and the caller has a deliberate reason; do not persist it as a durable output.
|
||||||
- Never use a Blob URL outside the browser process that created it.
|
- Never use a Blob URL outside the browser process that created it.
|
||||||
- Use `find_assets` with `action=get_download_url` (REST: `GET /api/external/v1/assets/read-url`) to obtain a short-lived `signedUrl` for display/download. This returns a URL; the client still performs any download.
|
- Use `GET /api/external/v1/assets/read-url` to obtain a short-lived `signedUrl` for display/download.
|
||||||
- Never store or feed an expiring signed URL back into generation when a stable `objectKey` exists.
|
- Never store or feed an expiring signed URL back into generation when a stable `objectKey` exists.
|
||||||
|
|
||||||
## Logging and Command Safety
|
## Logging and Command Safety
|
||||||
@@ -145,8 +139,6 @@ Do not print the complete confirmation response if it may contain temporary acce
|
|||||||
|
|
||||||
## Scope and Retry Guardrails
|
## Scope and Retry Guardrails
|
||||||
|
|
||||||
- Generation spends account credits. Respect the user's authorized task and scope; do not restart generation merely because a requested derivative is missing.
|
|
||||||
- For `delete_resources`, identify the precise IDs and obtain authorization for the actual deletion scope. Project deletion cascades to its default canvas and project-resource metadata. Folder deletion moves its assets to the default folder; the default folder cannot be deleted. Deleting a folder or asset record does not delete the underlying OSS file.
|
|
||||||
- Do not use account JWT/profile endpoints as the default external integration. Logged-in profile APIs may create/revoke developer keys, but they are outside this external editor contract.
|
- Do not use account JWT/profile endpoints as the default external integration. Logged-in profile APIs may create/revoke developer keys, but they are outside this external editor contract.
|
||||||
- Do not call internal workers, queues, SpacetimeDB, or admin endpoints.
|
- Do not call internal workers, queues, SpacetimeDB, or admin endpoints.
|
||||||
- Do not bypass upload confirmation or invent an object key.
|
- Do not bypass upload confirmation or invent an object key.
|
||||||
|
|||||||
@@ -10,17 +10,21 @@ Use this reference to translate user intent into a hosted MCP tool or its corres
|
|||||||
- Public contract: `GET /api/external/v1/openapi.json`.
|
- Public contract: `GET /api/external/v1/openapi.json`.
|
||||||
- Skill fallback: `GET /api/external/v1/skill/SKILL.md` or `GET /api/external/v1/skill.zip`.
|
- Skill fallback: `GET /api/external/v1/skill/SKILL.md` or `GET /api/external/v1/skill.zip`.
|
||||||
|
|
||||||
Prefer MCP when the Agent supports a remote endpoint plus a custom Bearer token. Prefer the complete Skill and Python helper when MCP is unavailable or a client-side local-file upload must be orchestrated. Choose a task-oriented tool below for ordinary requests, or the corresponding operation tool in [API Operations](api-operations.md) when the request needs direct control of one REST call. All 44 tools remain available. Discover the live tool schema before calling it; OpenAPI remains the field-level authority. The public discovery and Skill download routes are listed below, but are not MCP tools.
|
Prefer MCP when the Agent supports a remote endpoint plus a custom Bearer token. Prefer the complete Skill and Python helper when MCP is unavailable or a client-side local-file upload must be orchestrated. The MCP tool names are derived from OpenAPI `operationId` values in snake case; select by capability instead of memorizing the name.
|
||||||
|
|
||||||
## Project and Asset Destination
|
## Canvas Session
|
||||||
|
|
||||||
Use `find_canvas_projects` (`action=list`, `recent`, or `get`) to locate an existing canvas when the request involves one. Use `manage_canvas_projects` (`create` or `rename`) only when the user needs a project created or renamed. A new project does not create an asset folder automatically. Use `find_assets` and `organize_asset_library` when the task involves library records or folders. A project and folder may have different names, and either may be unnecessary for a standalone generation.
|
Before the first generation in a new conversation, obtain a canvas name unless the user already supplied an existing `projectId` and `assetFolderId`.
|
||||||
|
|
||||||
For generation, pass `projectId` with `canvasCompletion` when the result should enter a canvas, and `assetFolderId` with the endpoint's label field when it should enter the library. Use both only when the task requires both destinations. Character animation returns its final resource and asset directly when those destinations are requested; do not duplicate its first frame.
|
1. List or create a project. When creating one, use the canvas name as `title`.
|
||||||
|
2. Read the asset library. Reuse a folder with the same label or create one with the canvas name.
|
||||||
|
3. Retain `canvasName`, `projectId`, `assetFolderId`, and the current art spec in conversation state.
|
||||||
|
|
||||||
|
Generated artifacts must enter both the current canvas and its same-name library folder whenever the endpoint supports that invariant. Pass `projectId`, `assetFolderId`, the endpoint's label field, and `canvasCompletion`. Character animation returns the final formal resource and asset directly; use those records and never create a duplicate from the first frame.
|
||||||
|
|
||||||
## Art Spec Routing
|
## Art Spec Routing
|
||||||
|
|
||||||
For a series of related art requests, an optional reusable spec can carry the shared requirements:
|
Before art generation, normalize the user's request into:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -39,48 +43,48 @@ Infer what is already clear and ask only for missing fields that block the selec
|
|||||||
|
|
||||||
## Intent Map
|
## Intent Map
|
||||||
|
|
||||||
| User intent | MCP tool and action |
|
| User intent | MCP/REST capability |
|
||||||
| --- | --- |
|
| ----------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||||
| Find, open, create, or rename a canvas project | `find_canvas_projects` (`list`, `recent`, `get`); `manage_canvas_projects` (`create`, `rename`) |
|
| Generate a background, character, spec, UI mockup, or publication image | Image generation |
|
||||||
| Read project resources or library records | `find_assets` (`get_project_resources`, `list_library`) |
|
| Redraw, retouch, or replace an existing image | Image edit |
|
||||||
| Create or change folders and asset records | `organize_asset_library` (`create_folder`, `update_folder`, `create_asset`, `update_asset`) |
|
| Remove the background from an existing image | Background removal |
|
||||||
| Upload a local image/audio/video asset | `prepare_asset_upload` (`create_upload_ticket`), client-side OSS form upload, then `prepare_asset_upload` (`confirm_upload`) |
|
| Generate from a local reference | Upload and confirm the local file, then image generation or edit |
|
||||||
| Register existing media in a project, read a canvas, or save its full layout | `edit_canvas` (`register_resource`, `get`, `save_layout`) |
|
| Build a reusable transparent icon/game atlas from a visual spec | Icon spritesheet generation |
|
||||||
| Generate a background, character, spec, UI mockup, or publication image | `generate_image` |
|
| Extract marked assets from an existing UI design | UI design asset extraction |
|
||||||
| Retouch an existing image, make a reference variation, or remove its background | `modify_image` (`edit`, `variation`, `remove_background`) |
|
| Animate a character into frames | Character animation generation |
|
||||||
| Build a transparent icon/game atlas from a registered visual spec | `generate_icon_spritesheet` |
|
| Generate video | Video generation |
|
||||||
| Generate marked assets from an existing UI design | `extract_ui_assets` |
|
| Generate a sound effect | Sound-effect generation |
|
||||||
| Animate a character into frames | `generate_character_animation` |
|
| Generate background music/BGM | Background-music generation |
|
||||||
| Generate video | `generate_video` |
|
| Upload a local image/audio/video asset | Upload ticket -> OSS form upload -> object confirm |
|
||||||
| Generate a sound effect or background music | `generate_audio` (`sound_effect`, `background_music`) |
|
| Save viewport/layers | Canvas save |
|
||||||
| Check generation progress or retrieve its result | `check_generation` |
|
| Create, load, rename, or delete a canvas | Project operations |
|
||||||
| Obtain temporary access to private media | `find_assets` (`get_download_url`) |
|
| Organize folders and asset records | Asset-library operations |
|
||||||
| Delete an exact project, folder, or asset record | `delete_resources` (`delete_project`, `delete_folder`, `delete_asset`) |
|
| Obtain temporary access to private media | Signed read URL |
|
||||||
|
| Check generation progress or retrieve its result | Generation query |
|
||||||
For tools with actions, send `{ "action": "...", "input": { ... } }`; place `idempotencyKey` at the top level when supported or required. Tools without actions accept operation fields directly, with `idempotencyKey` at the top level for generation. The direct operation tools use `body`, `pathParameters`, and `queryParameters` wrappers as shown by their live schemas.
|
|
||||||
|
|
||||||
Do not present an API menu unless the request is genuinely ambiguous. Ask a follow-up when two routes create different artifacts, for example “处理这张图” could mean edit, extract marked UI assets, or use it as a reference for a new generation.
|
Do not present an API menu unless the request is genuinely ambiguous. Ask a follow-up when two routes create different artifacts, for example “处理这张图” could mean edit, extract marked UI assets, or use it as a reference for a new generation.
|
||||||
|
|
||||||
## Route-Specific Decisions
|
## Route-Specific Decisions
|
||||||
|
|
||||||
- Use `modify_image` `edit` when the requested output modifies a registered source image. Use `variation` when reference images should guide a new `quick-edit` image; it is image generation with fixed `kind="quick-edit"`. Use `remove_background` for a static source image. With `projectId`, `targetLayerId` may replace a matching existing layer when no explicit `canvasCompletion` is supplied.
|
- Use image edit when the requested output replaces or modifies a source image. With `projectId`, pass `targetLayerId` to replace an existing layer when no explicit `canvasCompletion` is supplied.
|
||||||
- Use icon spritesheet generation for a transparent reusable atlas when a stable visual-spec reference and concrete `iconDescriptions` exist. Do not use ordinary image generation just because it can draw several objects.
|
- Use icon spritesheet generation for a transparent reusable atlas when a stable visual-spec reference and concrete `iconDescriptions` exist. Do not use ordinary image generation just because it can draw several objects.
|
||||||
- Use UI extraction only for an existing UI design image with red-box annotations. It is not UI generation.
|
- Use UI extraction only for an existing UI design image with red-box annotations. It is not UI generation.
|
||||||
- Character animation requires a real `sourceLayerId`, source image, and dimensions from an existing resource. A local-only file must first be uploaded and registered where needed; do not invent a layer ID.
|
- Use a project layer ID as character animation `sourceLayerId` when one exists. For a local-only source, derive a stable synthetic ID from the filename.
|
||||||
- For video with image/video/audio references, use a Seedance 2.0-family model; default to `seedance2.0-fast`, `mode: "std"`, and explicit `sound`.
|
- For video with image/video/audio references, use a Seedance 2.0-family model; default to `seedance2.0-fast`, `mode: "std"`, and explicit `sound`.
|
||||||
- `prepare_asset_upload` obtains a ticket and confirms an uploaded object; it does not transfer the file or register a project resource, asset record, or canvas layer. Use `edit_canvas` `register_resource` or `organize_asset_library` `create_asset` only when the task needs those records.
|
- Use `signedUrl` only for preview/download. Feed stable `objectKey` or registered resource/asset identifiers into generation.
|
||||||
- Use temporary signed URLs only for preview/download. Feed stable `objectKey` or registered resource/asset identifiers into generation as each operation permits.
|
|
||||||
|
|
||||||
## Example: Reusable Icon Assets
|
## AI Game Creator Canonical Visual DAG
|
||||||
|
|
||||||
When the task needs a visual spec and a reusable icon atlas:
|
Keep the existing autonomous-build task graph. Do not add a parallel task system or collapse these artifacts into one ordinary generation request:
|
||||||
|
|
||||||
1. Reuse an existing registered `icon-spec`, or generate the requested spec using `generate_image` with `kind=spec` and register it as `assetKind=icon-spec` if necessary.
|
1. `art-director` generates `assets/art-spec.png` with image generation, `kind: "spec"`, then registers it as `assetKind: "icon-spec"`. This image is the authoritative visual spec; `generationInputs.artSpec` is supporting structured context.
|
||||||
2. Call `generate_icon_spritesheet` with that registered ID as `referenceId`, concrete `iconDescriptions`, and explicit `sliceMode`. Choose `grid` only for requested equal cells or fixed slots and provide those `gridX`/`gridY` values; otherwise use `connected-components`, optionally with `sliceCount`.
|
2. `design-foundation` generates `assets/ui-prototype.png` with `kind: "ui-design"`, using the registered art-spec resource ID in `referenceImageSrcs`.
|
||||||
3. Query `check_generation` and inspect the full sheet and actual slices. Preserve warnings; a usable full sheet does not imply that individual slices exist. Use returned slice identities and dimensions rather than guessing crop coordinates.
|
3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. `sliceMode` is required and has no default: send `sliceMode: "grid"` with `gridX`/`gridY` only when the requirement itself fixes the slots or names the column/row count, and otherwise send `sliceMode: "connected-components"` (with `sliceCount` when a subject count must be constrained); never invent a grid to express "kinds of assets", and never send `gridX`/`gridY` with `connected-components`.
|
||||||
|
|
||||||
An ordinary UI mockup or uploaded image is not automatically an `icon-spec`. For extracting marked components from a UI design, use `extract_ui_assets`, which includes generation and does not promise pixel-exact cropping.
|
For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. When the requirement fixes grid slots, require the response `sliceMode` to match the declared `grid` request and exactly `gridX × gridY` slices before registering the local runtime sheet; a connected-components request is instead judged by its own `sliceCount` or by the requirement, and both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet `<img>`, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation.
|
||||||
|
|
||||||
|
Never use `assets/ui-prototype.png` as the spritesheet visual-spec reference. UI extraction is outside this canonical DAG.
|
||||||
|
|
||||||
## Scope Boundary
|
## Scope Boundary
|
||||||
|
|
||||||
Stay within `/api/external/v1`. Do not invent worker, queue, runtime task-list, admin, profile, or SpacetimeDB calls. The only external generation query is `GET /api/external/v1/generations/{operationId}`. The hosted MCP also exposes the Skill and OpenAPI resources at `genarrative://external-editor/skill`, its four `skill/references/*.md` URIs, and `genarrative://external-editor/openapi`; keep those URI names unchanged.
|
Stay within `/api/external/v1`. Do not invent worker, queue, runtime task-list, admin, profile, or SpacetimeDB calls. The only external generation query is `GET /api/external/v1/generations/{operationId}`.
|
||||||
|
|||||||
@@ -4,46 +4,15 @@ Use this reference to build generation payloads, carry canvas/library context, p
|
|||||||
|
|
||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
- [MCP Argument Shapes](#mcp-argument-shapes)
|
|
||||||
- [Asynchronous Submission](#asynchronous-submission)
|
- [Asynchronous Submission](#asynchronous-submission)
|
||||||
- [Polling State Machine](#polling-state-machine)
|
- [Polling State Machine](#polling-state-machine)
|
||||||
- [Canvas and Asset-Library Completion](#canvas-and-asset-library-completion)
|
- [Canvas and Asset-Library Completion](#canvas-and-asset-library-completion)
|
||||||
- [Saving Existing Canvas Layout](#saving-existing-canvas-layout)
|
|
||||||
- [Art Spec and Image Request](#art-spec-and-image-request)
|
- [Art Spec and Image Request](#art-spec-and-image-request)
|
||||||
- [Local Reference Requests](#local-reference-requests)
|
- [Local Reference Requests](#local-reference-requests)
|
||||||
- [Compact Completed Result](#compact-completed-result)
|
- [Compact Completed Result](#compact-completed-result)
|
||||||
- [Warning Semantics](#warning-semantics)
|
- [Warning Semantics](#warning-semantics)
|
||||||
- [Output Handling Checklist](#output-handling-checklist)
|
- [Output Handling Checklist](#output-handling-checklist)
|
||||||
|
|
||||||
## MCP Argument Shapes
|
|
||||||
|
|
||||||
Pass these objects as the `arguments` of the named tool in `tools/call`. They are not REST request envelopes.
|
|
||||||
|
|
||||||
Single-function tools take business fields directly. For example, `generate_image`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"prompt": "一张横版幻想森林背景,无文字",
|
|
||||||
"aspectRatio": "16:9",
|
|
||||||
"imageSize": "1K",
|
|
||||||
"idempotencyKey": "forest-image-001"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Multi-function tools take `action` and `input`. A generation key stays outside `input`. For example, `generate_audio`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"action": "sound_effect",
|
|
||||||
"input": {"prompt": "轻柔的游戏菜单确认音", "duration": 1},
|
|
||||||
"idempotencyKey": "menu-sound-001"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Keys above identify distinct example requests; create and persist your own key for each new logical generation. An action with no business fields still requires `input: {}`, such as `find_canvas_projects` with `action=list`. Use only fields belonging to the selected action; do not combine branches. REST examples below use the business body directly and put the key in the HTTP header instead.
|
|
||||||
|
|
||||||
MCP returns business data in `structuredContent`, without the REST `data` envelope. Check `isError` before using it; an HTTP-successful MCP exchange can still carry a tool error. Generation acceptance contains an `operationId`, not the final media.
|
|
||||||
|
|
||||||
## Asynchronous Submission
|
## Asynchronous Submission
|
||||||
|
|
||||||
All nine generation POST routes require `Idempotency-Key` and return HTTP `202` with an `ExternalEditorGenerationSubmissionResponse` shaped like:
|
All nine generation POST routes require `Idempotency-Key` and return HTTP `202` with an `ExternalEditorGenerationSubmissionResponse` shaped like:
|
||||||
@@ -81,7 +50,7 @@ Persist the key, exact request body, and `operationId`. If submission outcome is
|
|||||||
|
|
||||||
## Polling State Machine
|
## Polling State Machine
|
||||||
|
|
||||||
With MCP, call `check_generation` with `{"operationId":"<returned operationId>"}`. Each call queries once and does not wait for completion. With REST, poll `statusUrl` or `GET /api/external/v1/generations/{operationId}`. Query no faster than `pollAfterMs`:
|
Poll `statusUrl`, or `GET /api/external/v1/generations/{operationId}`, no faster than `pollAfterMs`:
|
||||||
|
|
||||||
- `queued` / `running`: retain `operationId`; show `phaseLabel`, `phaseDetail`, and `progress` when present; wait before querying again.
|
- `queued` / `running`: retain `operationId`; show `phaseLabel`, `phaseDetail`, and `progress` when present; wait before querying again.
|
||||||
- `completed`: consume the compact `result` and all warning fields, then stop polling.
|
- `completed`: consume the compact `result` and all warning fields, then stop polling.
|
||||||
@@ -106,10 +75,10 @@ Background removal uses the same submission and polling state machine. `sourceIm
|
|||||||
|
|
||||||
## Canvas and Asset-Library Completion
|
## Canvas and Asset-Library Completion
|
||||||
|
|
||||||
Choose destinations according to the task. Locate an existing project with `find_canvas_projects` and inspect folders with `find_assets/list_library`; create missing destinations with `manage_canvas_projects/create` and `organize_asset_library/create_folder` only when needed. Project creation does not create a folder. For endpoints that support the requested destinations, include:
|
For endpoints that support these fields, include:
|
||||||
|
|
||||||
- `projectId`: target canvas project.
|
- `projectId`: target canvas project.
|
||||||
- `assetFolderId`: target asset-library folder; its name need not match the project.
|
- `assetFolderId`: folder whose label matches the canvas name.
|
||||||
- `assetLabel` or UI extraction's `spritesheetLabel`: user-visible library label.
|
- `assetLabel` or UI extraction's `spritesheetLabel`: user-visible library label.
|
||||||
- `canvasCompletion`: backend canvas placement instructions.
|
- `canvasCompletion`: backend canvas placement instructions.
|
||||||
|
|
||||||
@@ -133,24 +102,15 @@ A minimal `canvasCompletion` is:
|
|||||||
|
|
||||||
Background removal preserves the source image dimensions. For normal canvas placement, the Python helper therefore requires the real `source_width` and `source_height` whenever `canvasSession` is used without an explicit `canvasWidth` plus `canvasHeight`; it never substitutes a square default. Passing `targetLayerId` instead selects in-place replacement, so the helper keeps the session's project/library fields without injecting `canvasCompletion` and rejects callers that explicitly combine both placement modes. The request `assetKind` is optional, static-image only, and must equal the authoritative source type when one exists. An in-place target must resolve to the same authoritative source object; a raw object key is bound to that target resource instead of relying on project-list order.
|
Background removal preserves the source image dimensions. For normal canvas placement, the Python helper therefore requires the real `source_width` and `source_height` whenever `canvasSession` is used without an explicit `canvasWidth` plus `canvasHeight`; it never substitutes a square default. Passing `targetLayerId` instead selects in-place replacement, so the helper keeps the session's project/library fields without injecting `canvasCompletion` and rejects callers that explicitly combine both placement modes. The request `assetKind` is optional, static-image only, and must equal the authoritative source type when one exists. An in-place target must resolve to the same authoritative source object; a raw object key is bound to that target resource instead of relying on project-list order.
|
||||||
|
|
||||||
Character animation accepts `assetFolderId` and `assetLabel` and persists the generated sequence. Consume the returned animation artifacts and persisted identities; do not synthesize a duplicate animation asset from the first frame. Use complete project/library records when complete persisted state is needed.
|
Character animation accepts `assetFolderId` and `assetLabel` and persists the final transparent sequence directly. Its completed compact result includes the authoritative `assetKind="character-animation"` resource and asset with `imageSequenceFrames` and `imageSequenceDurationMs`. Use those records directly and never synthesize a duplicate asset from the first frame.
|
||||||
|
|
||||||
For the lower-level asset/resource creation endpoints, `generationInputs` is replayable request context rather than a media-runtime container. When `assetKind` is `character-animation`, the server rejects legacy runtime keys including `characterAnimation`, `frames`, `previewVideoPath`, `frameCount`, `fps`, and `durationSeconds`; send the formal sequence through `imageSequenceFrames` and `imageSequenceDurationMs`. Internal processing audit keys such as `screenColorHex`, `mattingProvider`, and `mattingModel` are removed before persistence.
|
For the lower-level asset/resource creation endpoints, `generationInputs` is replayable request context rather than a media-runtime container. When `assetKind` is `character-animation`, the server rejects legacy runtime keys including `characterAnimation`, `frames`, `previewVideoPath`, `frameCount`, `fps`, and `durationSeconds`; send the formal sequence through `imageSequenceFrames` and `imageSequenceDurationMs`. Internal processing audit keys such as `screenColorHex`, `mattingProvider`, and `mattingModel` are removed before persistence.
|
||||||
|
|
||||||
## Saving Existing Canvas Layout
|
|
||||||
|
|
||||||
1. Call `edit_canvas` with `action=get` and `input.projectId` to read the latest project and canvas revision.
|
|
||||||
2. Build the intended complete `viewport` and `layers`, preserving unrelated layers. `save_layout` replaces the layout; it is not a one-layer patch.
|
|
||||||
3. Call `edit_canvas` with `action=save_layout` and `input` containing `projectId`, the read `expectedRevision`, and the complete `viewport` and `layers`.
|
|
||||||
4. On a revision conflict, reread and reconcile with the current layout before retrying. Do not blindly resend stale layers with a refreshed revision.
|
|
||||||
|
|
||||||
`edit_canvas/register_resource` registers existing media but does not create a canvas layer. `organize_asset_library/create_asset` creates metadata but does not upload or generate media. For generated media placement, prefer the generation tool's supported `canvasCompletion`; inspect returned identities before registering anything again.
|
|
||||||
|
|
||||||
## Art Spec and Image Request
|
## Art Spec and Image Request
|
||||||
|
|
||||||
Generic External v1 image generation does not expose the main-site structured game-scene contract. `kind: "scene"` and `assetKind: "scene"` are both invalid and return HTTP `400` before any generation job is queued. Do not replace the structured scene fields and server-owned prompt assembly with a generic image prompt.
|
Generic External v1 image generation does not expose the main-site structured game-scene contract. `kind: "scene"` and `assetKind: "scene"` are both invalid and return HTTP `400` before any generation job is queued. Do not replace the structured scene fields and server-owned prompt assembly with a generic image prompt.
|
||||||
|
|
||||||
When maintaining a reusable art spec, carry it in `generationInputs.artSpec` and reflect important constraints in the prompt. This is an example with both canvas and library destinations, not a requirement for every generation:
|
Carry the current art spec in `generationInputs.artSpec` and reflect important constraints in the prompt:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -217,11 +177,11 @@ Image edit/redraw has a stricter main-source identity rule. After upload confirm
|
|||||||
|
|
||||||
Icon spritesheet generation has a stricter primary-spec contract. After upload confirmation, create a project resource or asset record with `assetKind: "icon-spec"`, retain its returned `resourceId` or `assetId`, and pass that ID as `referenceId`. The primary spec does not accept the uploaded `objectKey` directly; only additional style references may continue to use stable object keys in `referenceImageSrcs`.
|
Icon spritesheet generation has a stricter primary-spec contract. After upload confirmation, create a project resource or asset record with `assetKind: "icon-spec"`, retain its returned `resourceId` or `assetId`, and pass that ID as `referenceId`. The primary spec does not accept the uploaded `objectKey` directly; only additional style references may continue to use stable object keys in `referenceImageSrcs`.
|
||||||
|
|
||||||
For character animation, use the selected source identity and actual dimensions. The following is a business-body example; `generate_character_animation` also requires a top-level `idempotencyKey`:
|
For character animation from a local-only source, use actual dimensions and a stable synthetic layer ID:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"sourceLayerId": "<selected source layer ID>",
|
"sourceLayerId": "external-reference-hero",
|
||||||
"sourceImageSrc": "<confirmed objectKey>",
|
"sourceImageSrc": "<confirmed objectKey>",
|
||||||
"sourceWidth": 720,
|
"sourceWidth": 720,
|
||||||
"sourceHeight": 1280,
|
"sourceHeight": 1280,
|
||||||
@@ -249,7 +209,7 @@ The completed `result` may contain stable artifact fields such as:
|
|||||||
- `spritesheetResource`, `spritesheetAsset`, and stable spritesheet metadata.
|
- `spritesheetResource`, `spritesheetAsset`, and stable spritesheet metadata.
|
||||||
- `warning` and `sliceWarning` structures.
|
- `warning` and `sliceWarning` structures.
|
||||||
|
|
||||||
It deliberately excludes a complete project/canvas/library snapshot, Data URL, Blob URL, expiring signed URL, worker lease, queue state, and internal provider diagnostics. Use `find_assets/get_download_url` for temporary access to a stable `objectKey` (REST: `/assets/read-url`). Reading a record or obtaining a URL does not itself inspect or download the media.
|
It deliberately excludes a complete project/canvas/library snapshot, Data URL, Blob URL, expiring signed URL, worker lease, queue state, and internal provider diagnostics. Use `/assets/read-url` for temporary access to a stable `objectKey`.
|
||||||
|
|
||||||
## Warning Semantics
|
## Warning Semantics
|
||||||
|
|
||||||
|
|||||||
@@ -30,3 +30,8 @@ server-rs/.data
|
|||||||
server-rs/.spacetimedb
|
server-rs/.spacetimedb
|
||||||
|
|
||||||
public/generated-*
|
public/generated-*
|
||||||
|
|
||||||
|
scripts/loadtest/data/*.local.json
|
||||||
|
scripts/loadtest/data/k6-*.log
|
||||||
|
scripts/loadtest/data/k6-*summary*.md
|
||||||
|
scripts/loadtest/data/latest-*-prefix.txt
|
||||||
|
|||||||
@@ -237,8 +237,6 @@ GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false"
|
|||||||
|
|
||||||
# 官网客户端下载检测渠道:dev、release 或自定义渠道;修改后重启 API 服务。
|
# 官网客户端下载检测渠道:dev、release 或自定义渠道;修改后重启 API 服务。
|
||||||
# Windows/macOS 是系统维度,不填写 dev-win/dev-mac。
|
# Windows/macOS 是系统维度,不填写 dev-win/dev-mac。
|
||||||
# 客户端埋点也复用该渠道:dev 对应 https://dev.genarrative.world,release 对应 https://www.genarrative.world。
|
|
||||||
# 埋点不接受其它渠道;本地 dev 且 GENARRATIVE_ENV 为 development(默认)/test/container 时允许 loopback 地址及可变端口。
|
|
||||||
GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL="dev"
|
GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL="dev"
|
||||||
|
|
||||||
# Optional: official VikingDB credentials for regenerating build-tag similarities
|
# Optional: official VikingDB credentials for regenerating build-tag similarities
|
||||||
|
|||||||
@@ -365,6 +365,7 @@ module.exports = {
|
|||||||
'src/types.ts',
|
'src/types.ts',
|
||||||
'src/types/**',
|
'src/types/**',
|
||||||
'src/uiAssets.ts',
|
'src/uiAssets.ts',
|
||||||
|
'scripts/loadtest/**',
|
||||||
'packages/shared/src/contracts/jumpHop.ts',
|
'packages/shared/src/contracts/jumpHop.ts',
|
||||||
'packages/shared/src/contracts/match3dAgent.ts',
|
'packages/shared/src/contracts/match3dAgent.ts',
|
||||||
'packages/shared/src/contracts/match3dRuntime.ts',
|
'packages/shared/src/contracts/match3dRuntime.ts',
|
||||||
|
|||||||
+8
-1
@@ -65,7 +65,6 @@ temp*build*/
|
|||||||
/apps/preview-deployer-web/node_modules/
|
/apps/preview-deployer-web/node_modules/
|
||||||
/server-rs/.spacetimedb/
|
/server-rs/.spacetimedb/
|
||||||
/server-rs/.data/
|
/server-rs/.data/
|
||||||
**/server-rs/.data/
|
|
||||||
/public/generated-animations
|
/public/generated-animations
|
||||||
/public/generated-character-drafts
|
/public/generated-character-drafts
|
||||||
/public/generated-characters
|
/public/generated-characters
|
||||||
@@ -89,3 +88,11 @@ nohup.out
|
|||||||
spacetime.local.json
|
spacetime.local.json
|
||||||
deploy/container/api-server.env
|
deploy/container/api-server.env
|
||||||
deploy/container/worker-smoke/
|
deploy/container/worker-smoke/
|
||||||
|
|
||||||
|
# Local load-test data extracted from private migration files
|
||||||
|
scripts/loadtest/data/*.local.json
|
||||||
|
|
||||||
|
# Local load-test run artifacts
|
||||||
|
scripts/loadtest/data/k6-*.log
|
||||||
|
scripts/loadtest/data/k6-*summary*.md
|
||||||
|
scripts/loadtest/data/latest-*-prefix.txt
|
||||||
|
|||||||
-54
@@ -48,23 +48,6 @@ _Avoid_: 无来源的静态素材、只显示在 UI 但不落工程资源记录
|
|||||||
一组同类素材的统一批量生成方式,采用批量规划、sheet 生图、后端切图、透明化、OSS 持久化和局部重生成的通用流水线。
|
一组同类素材的统一批量生成方式,采用批量规划、sheet 生图、后端切图、透明化、OSS 持久化和局部重生成的通用流水线。
|
||||||
_Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成任一玩法专属 DTO
|
_Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成任一玩法专属 DTO
|
||||||
|
|
||||||
**UI 设计文档**:
|
|
||||||
AGC 本地项目里 `kind=ui-design-doc`、`mediaType=application/json` 的界面编辑载体,保存设计图集合、UI 树、组件绑定和 State revision;一份文档可同时承载多张设计图与各自的结构树,不强制收敛成一棵树。
|
|
||||||
_Avoid_: 把 UI 设计图当成设计文档、给设计文档再套一层「页面」概念
|
|
||||||
|
|
||||||
**设计图**:
|
|
||||||
UI 设计文档里的一张参考界面图,以它在 manifest 中的资产 ID 作为文档内身份,附带像素尺寸与像素比;文档内的每棵树都必须引用文档里已存在的设计图。
|
|
||||||
_Avoid_: 用文件名当设计图身份、设计图与设计文档同一概念
|
|
||||||
|
|
||||||
**UI 工作流步骤**:
|
|
||||||
对一份 UI 设计文档执行的一次受控处理阶段,当前只有「结构识别」与「素材切分」两步;步骤产物只有在写入文档并保存后才算完成。
|
|
||||||
_Avoid_: 把单次工具调用当成步骤、把中间产物当成步骤完成
|
|
||||||
|
|
||||||
**工作流检查点日志**:
|
|
||||||
一份 UI 设计文档旁按行追加的恢复用日志,每完成一个工作流步骤追加一行;某步是否有对应行即代表该步是否完成,恢复时从第一个缺失的行继续。
|
|
||||||
_Avoid_: 每步一个 sidecar 状态机、把切分专用的 SeparationState 泛化成通用检查点
|
|
||||||
|
|
||||||
|
|
||||||
## Language
|
## Language
|
||||||
|
|
||||||
### Puzzle Clear
|
### Puzzle Clear
|
||||||
@@ -207,34 +190,10 @@ _Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
|
|||||||
Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。
|
Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。
|
||||||
_Avoid_: 进度通知、快照轮询、第二套历史
|
_Avoid_: 进度通知、快照轮询、第二套历史
|
||||||
|
|
||||||
**逻辑回合**:
|
|
||||||
Thread Manager 拥有的一对回合边界(开始与结束),由接单动作开启、由这一轮的占用对象写出,不镜像 Codex 原生回合;界面忙碌态与回合结果只认它。
|
|
||||||
_Avoid_: Codex 原生回合、原生日志、进程生命周期
|
|
||||||
|
|
||||||
**接单**:
|
|
||||||
把一条用户消息交给宿主开始执行的动作,成立即表示这一轮已经存在;此后结果只由运行态事件回答。
|
|
||||||
_Avoid_: 发送成功、命令调用、接口返回
|
|
||||||
|
|
||||||
**拒单**:
|
|
||||||
接单成立之前拒绝这次请求(并发、权限、目录、参数、工程准备未就绪),只回一条可展示原因,不产生回合事件,也不写用户条目。
|
|
||||||
_Avoid_: 回合失败、执行失败、失败事件
|
|
||||||
|
|
||||||
**在途回合**:
|
|
||||||
界面本地已经把这条用户消息发出去、宿主还没有对应回合开始事件的那一小段状态。
|
|
||||||
_Avoid_: 运行中回合、乐观锁、发送队列
|
|
||||||
|
|
||||||
**聊天投影**:
|
**聊天投影**:
|
||||||
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
|
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
|
||||||
_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
|
_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
|
||||||
|
|
||||||
**项目对话输入**:
|
|
||||||
AGC 项目对话的输入只有自然语言回合(含 `@` 素材引用与附件);需要动作时由 Runtime 工具与确认卡承接,不从输入文本解析控制词。
|
|
||||||
_Avoid_: 斜杠命令、聊天命令草稿、命令发现列表
|
|
||||||
|
|
||||||
**项目命令 id**:
|
|
||||||
AGC 运行期工具与项目权限策略使用的稳定标识(`GAME_CREATION_APP_COMMANDS` 与 `GameCreationAppPermission`),由 Rust 运行期策略校验与 App 权限审计 / 项目前置条件判定消费,不是用户输入语法。
|
|
||||||
_Avoid_: 把命令 id 当作可输入的聊天命令、为权限位补聊天入口
|
|
||||||
|
|
||||||
**引用候选**:
|
**引用候选**:
|
||||||
输入区可以命中的对象集合(`@` 素材、`$` Skill),由宿主按种类注入;输入区不判断候选属于哪一类。
|
输入区可以命中的对象集合(`@` 素材、`$` Skill),由宿主按种类注入;输入区不判断候选属于哪一类。
|
||||||
_Avoid_: 输入区自己读项目清单或应用目录、把候选取值写死在组件里
|
_Avoid_: 输入区自己读项目清单或应用目录、把候选取值写死在组件里
|
||||||
@@ -251,18 +210,6 @@ _Avoid_: 给附件或运行画面区域造候选、为它们保留输入区内
|
|||||||
引用在正文文本里的形态(`@显示名` / `$名称` / `@附件名`)及其反解析;出站与解析必须同一口径,token 前后各留一个空白。
|
引用在正文文本里的形态(`@显示名` / `$名称` / `@附件名`)及其反解析;出站与解析必须同一口径,token 前后各留一个空白。
|
||||||
_Avoid_: 出站与解析各写一套、在空白边界之外再补兼容别名、让解析依赖具体种类的字段
|
_Avoid_: 出站与解析各写一套、在空白边界之外再补兼容别名、让解析依赖具体种类的字段
|
||||||
|
|
||||||
**引用名**:
|
|
||||||
引用自己的名字,同时就是它在正文里的 token(资源显示名、Skill 名、附件名);内部不允许出现空白,空白统一经 `normalizeMentionName` 折成 `-`。
|
|
||||||
_Avoid_: 名字与 token 各存一份、靠补兼容别名或 `resourceId` 兜底来消化空白
|
|
||||||
|
|
||||||
**引用候选枚举**:
|
|
||||||
一种引用种类当前就绪的全部可引用对象,与候选菜单共用同一份集合;区别只在没有查询过滤和条数上限。
|
|
||||||
_Avoid_: 拿菜单查询当枚举、为粘贴另建一份候选清单
|
|
||||||
|
|
||||||
**引用粘贴解析**:
|
|
||||||
把粘贴进来的纯文本按引用文本语法反解析回正文引用;只有身份唯一且逐字确认的 token 才成为引用,其余按原文保留。
|
|
||||||
_Avoid_: 猜文件名或路径、为不确定的 token 挑一个候选、改写用户粘贴的其余文字
|
|
||||||
|
|
||||||
**引用输入区**:
|
**引用输入区**:
|
||||||
只负责编辑与渲染引用的共享输入组件;候选、身份解析与文本语法都来自注入的 provider,它不持有项目清单、不访问后端。
|
只负责编辑与渲染引用的共享输入组件;候选、身份解析与文本语法都来自注入的 provider,它不持有项目清单、不访问后端。
|
||||||
_Avoid_: 输入区自己拉 Skill 目录、把选择器面板塞在输入区内部
|
_Avoid_: 输入区自己拉 Skill 目录、把选择器面板塞在输入区内部
|
||||||
@@ -293,7 +240,6 @@ _Avoid_: 待发送附件列表与正文芯片并存、提交时再拼一遍附
|
|||||||
- **引用输入区** 由宿主注入的若干 **引用 provider** 组成;**引用候选** 与 **引用文本语法** 都来自 provider,输入区不判断引用种类。
|
- **引用输入区** 由宿主注入的若干 **引用 provider** 组成;**引用候选** 与 **引用文本语法** 都来自 provider,输入区不判断引用种类。
|
||||||
- **引用选择器** 不属于 **引用输入区**:它自己拿数据,确认后只通过输入区的插入缝交付引用。
|
- **引用选择器** 不属于 **引用输入区**:它自己拿数据,确认后只通过输入区的插入缝交付引用。
|
||||||
- 只有带触发符的 **引用 provider** 会产生候选;**静默 provider** 没有触发符,只能由外部插入或草稿回填进入正文。
|
- 只有带触发符的 **引用 provider** 会产生候选;**静默 provider** 没有触发符,只能由外部插入或草稿回填进入正文。
|
||||||
- **引用粘贴解析** 与出站显示是同一条 **引用文本语法** 的两端;**静默 provider** 的 token(`@附件名` / `@区域标签`)没有候选,因此粘贴时不重建。
|
|
||||||
- **附件芯片** 是本轮附件的唯一事实源;附件导入失败时不产生芯片。
|
- **附件芯片** 是本轮附件的唯一事实源;附件导入失败时不产生芯片。
|
||||||
|
|
||||||
## Example dialogue
|
## Example dialogue
|
||||||
|
|||||||
@@ -7,13 +7,10 @@ import {
|
|||||||
getAdminFeatureGateConfig,
|
getAdminFeatureGateConfig,
|
||||||
getAdminUserDetail,
|
getAdminUserDetail,
|
||||||
importAdminAgcTemplates,
|
importAdminAgcTemplates,
|
||||||
listAdminAgcTrackingEvents,
|
|
||||||
listAdminGameDistributionGames,
|
|
||||||
listAdminGameDistributionReviews,
|
listAdminGameDistributionReviews,
|
||||||
listAdminRechargeOrders,
|
listAdminRechargeOrders,
|
||||||
reconcileAdminUserConsumption,
|
reconcileAdminUserConsumption,
|
||||||
resolveAdminRechargeRefundManualReview,
|
resolveAdminRechargeRefundManualReview,
|
||||||
restoreAdminGameDistributionGame,
|
|
||||||
reviewAdminGameDistributionVersion,
|
reviewAdminGameDistributionVersion,
|
||||||
suspendAdminGameDistributionGame,
|
suspendAdminGameDistributionGame,
|
||||||
updateAdminAccount,
|
updateAdminAccount,
|
||||||
@@ -27,30 +24,6 @@ afterEach(() => {
|
|||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('客户端埋点查询传递筛选和游标并复用后台认证', async () => {
|
|
||||||
const payload = { entries: [], nextCursor: null };
|
|
||||||
const fetchMock = vi.fn().mockResolvedValue(
|
|
||||||
new Response(JSON.stringify({ ok: true, data: payload }), {
|
|
||||||
status: 200,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
expect(
|
|
||||||
await listAdminAgcTrackingEvents('token', {
|
|
||||||
userId: 'user+1',
|
|
||||||
projectId: 'project-1',
|
|
||||||
cursor: 'page/2',
|
|
||||||
limit: 50,
|
|
||||||
}),
|
|
||||||
).toEqual(payload);
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
|
||||||
'/admin/api/agc/tracking-events?userId=user%2B1&projectId=project-1&cursor=page%2F2&limit=50',
|
|
||||||
expect.objectContaining({
|
|
||||||
headers: expect.objectContaining({ Authorization: 'Bearer token' }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('模板管理读取和更新复用认证封装,提交 revision 和封面但不提交 ZIP 或版本', async () => {
|
test('模板管理读取和更新复用认证封装,提交 revision 和封面但不提交 ZIP 或版本', async () => {
|
||||||
const library = { revision: 'revision-new', writable: true, templates: [] };
|
const library = { revision: 'revision-new', writable: true, templates: [] };
|
||||||
const fetchMock = vi.fn().mockImplementation(
|
const fetchMock = vi.fn().mockImplementation(
|
||||||
@@ -503,6 +476,7 @@ test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键'
|
|||||||
{
|
{
|
||||||
decision: 'approve',
|
decision: 'approve',
|
||||||
expectedPublicationRevision: 3,
|
expectedPublicationRevision: 3,
|
||||||
|
entryUrl: 'https://games.example.test/releases/game_1/index.html',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -522,90 +496,12 @@ test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键'
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
decision: 'approve',
|
decision: 'approve',
|
||||||
expectedPublicationRevision: 3,
|
expectedPublicationRevision: 3,
|
||||||
|
entryUrl: 'https://games.example.test/releases/game_1/index.html',
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('游戏管理列表与恢复动作使用约定的 URL、方法和幂等键', async () => {
|
|
||||||
const fetchMock = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
new Response(JSON.stringify({ ok: true, data: { games: [] } }), {
|
|
||||||
status: 200,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
ok: true,
|
|
||||||
data: {
|
|
||||||
game: {
|
|
||||||
id: 'game/1',
|
|
||||||
title: '测试游戏',
|
|
||||||
status: 'published',
|
|
||||||
publicationRevision: 10,
|
|
||||||
},
|
|
||||||
replayed: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{ status: 200 },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
const controller = new AbortController();
|
|
||||||
|
|
||||||
await listAdminGameDistributionGames(
|
|
||||||
'admin-token',
|
|
||||||
{ limit: 80 },
|
|
||||||
controller.signal,
|
|
||||||
);
|
|
||||||
await restoreAdminGameDistributionGame(
|
|
||||||
'admin-token',
|
|
||||||
' game/1 ',
|
|
||||||
' game-restore-key-1 ',
|
|
||||||
{ expectedPublicationRevision: 9 },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
||||||
'/admin/api/game-distribution/games?limit=50',
|
|
||||||
);
|
|
||||||
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
method: 'GET',
|
|
||||||
signal: controller.signal,
|
|
||||||
headers: expect.objectContaining({
|
|
||||||
Authorization: 'Bearer admin-token',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
|
||||||
'/admin/api/game-distribution/games/game%2F1/restore',
|
|
||||||
);
|
|
||||||
expect(fetchMock.mock.calls[1]?.[1]).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
method: 'POST',
|
|
||||||
headers: expect.objectContaining({
|
|
||||||
Authorization: 'Bearer admin-token',
|
|
||||||
'Idempotency-Key': 'game-restore-key-1',
|
|
||||||
}),
|
|
||||||
body: JSON.stringify({ expectedPublicationRevision: 9 }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
restoreAdminGameDistributionGame('admin-token', ' ', 'key', {
|
|
||||||
expectedPublicationRevision: 9,
|
|
||||||
}),
|
|
||||||
).toThrow('缺少游戏 ID');
|
|
||||||
expect(() =>
|
|
||||||
restoreAdminGameDistributionGame('admin-token', 'game-1', ' ', {
|
|
||||||
expectedPublicationRevision: 9,
|
|
||||||
}),
|
|
||||||
).toThrow('恢复幂等键必须是 1 到 128 个字符');
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('安全下架请求携带公开修订号、原因与幂等键', async () => {
|
test('安全下架请求携带公开修订号、原因与幂等键', async () => {
|
||||||
const fetchMock = vi.fn().mockImplementation(() =>
|
const fetchMock = vi.fn().mockImplementation(() =>
|
||||||
Promise.resolve(
|
Promise.resolve(
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
AdminAccountListResponse,
|
AdminAccountListResponse,
|
||||||
AdminAgcTemplateLibraryResponse,
|
AdminAgcTemplateLibraryResponse,
|
||||||
AdminAgcTrackingEventListResponse,
|
|
||||||
AdminAgcTrackingEventQuery,
|
|
||||||
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
|
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
|
||||||
AdminCreateAccountRequest,
|
AdminCreateAccountRequest,
|
||||||
AdminCreateAccountResponse,
|
AdminCreateAccountResponse,
|
||||||
@@ -32,9 +30,6 @@ import type {
|
|||||||
AdminExternalApiKeyListQuery,
|
AdminExternalApiKeyListQuery,
|
||||||
AdminExternalApiKeyListResponse,
|
AdminExternalApiKeyListResponse,
|
||||||
AdminFeatureGateConfigResponse,
|
AdminFeatureGateConfigResponse,
|
||||||
AdminGameDistributionGameListResponse,
|
|
||||||
AdminGameDistributionRestoreRequest,
|
|
||||||
AdminGameDistributionRestoreResponse,
|
|
||||||
AdminGameDistributionReviewListResponse,
|
AdminGameDistributionReviewListResponse,
|
||||||
AdminGameDistributionReviewRequest,
|
AdminGameDistributionReviewRequest,
|
||||||
AdminGameDistributionReviewResponse,
|
AdminGameDistributionReviewResponse,
|
||||||
@@ -414,31 +409,6 @@ export function listAdminTrackingEventKeys(token: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listAdminAgcTrackingEvents(
|
|
||||||
token: string,
|
|
||||||
query: AdminAgcTrackingEventQuery = {},
|
|
||||||
) {
|
|
||||||
return request<AdminAgcTrackingEventListResponse>(
|
|
||||||
`/admin/api/agc/tracking-events${buildQueryString((params) => {
|
|
||||||
for (const key of [
|
|
||||||
'userId',
|
|
||||||
'projectId',
|
|
||||||
'creativeTaskId',
|
|
||||||
'agentRunId',
|
|
||||||
'eventName',
|
|
||||||
'clientVersion',
|
|
||||||
'startTime',
|
|
||||||
'endTime',
|
|
||||||
'cursor',
|
|
||||||
] as const) {
|
|
||||||
appendQueryParam(params, key, query[key]);
|
|
||||||
}
|
|
||||||
appendNumericQueryParam(params, 'limit', query.limit);
|
|
||||||
})}`,
|
|
||||||
{ token },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listAdminErrorReports(
|
export function listAdminErrorReports(
|
||||||
token: string,
|
token: string,
|
||||||
query: {
|
query: {
|
||||||
@@ -1243,46 +1213,6 @@ export function listAdminGameDistributionReviews(token: string, limit = 48) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listAdminGameDistributionGames(
|
|
||||||
token: string,
|
|
||||||
options: { limit?: number } = {},
|
|
||||||
signal?: AbortSignal,
|
|
||||||
) {
|
|
||||||
const requestedLimit = options.limit ?? 50;
|
|
||||||
const normalizedLimit = Number.isFinite(requestedLimit)
|
|
||||||
? Math.min(Math.max(Math.trunc(requestedLimit), 1), 50)
|
|
||||||
: 50;
|
|
||||||
return request<AdminGameDistributionGameListResponse>(
|
|
||||||
`/admin/api/game-distribution/games?limit=${normalizedLimit}`,
|
|
||||||
{ token, signal },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function restoreAdminGameDistributionGame(
|
|
||||||
token: string,
|
|
||||||
gameId: string,
|
|
||||||
idempotencyKey: string,
|
|
||||||
payload: AdminGameDistributionRestoreRequest,
|
|
||||||
) {
|
|
||||||
const normalizedGameId = gameId.trim();
|
|
||||||
const normalizedKey = idempotencyKey.trim();
|
|
||||||
if (!normalizedGameId) {
|
|
||||||
throw new Error('缺少游戏 ID');
|
|
||||||
}
|
|
||||||
if (!normalizedKey || normalizedKey.length > 128) {
|
|
||||||
throw new Error('恢复幂等键必须是 1 到 128 个字符');
|
|
||||||
}
|
|
||||||
return request<AdminGameDistributionRestoreResponse>(
|
|
||||||
`/admin/api/game-distribution/games/${encodeURIComponent(normalizedGameId)}/restore`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
token,
|
|
||||||
headers: { 'Idempotency-Key': normalizedKey },
|
|
||||||
body: payload,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 审核游戏发行版本。幂等键由调用方生成并在同一次提交内复用,避免重复点击产生
|
* 审核游戏发行版本。幂等键由调用方生成并在同一次提交内复用,避免重复点击产生
|
||||||
* 两条审核结论。
|
* 两条审核结论。
|
||||||
|
|||||||
@@ -193,10 +193,7 @@ export interface AdminDashboardRangePayload {
|
|||||||
|
|
||||||
export interface AdminDashboardMetricsPayload {
|
export interface AdminDashboardMetricsPayload {
|
||||||
generatedAssets: number;
|
generatedAssets: number;
|
||||||
/** 已对冲退还(生成失败 / 精选审核返还 / LLM Router 冲正)的净消耗泥点。 */
|
|
||||||
consumedMudPoints: number;
|
consumedMudPoints: number;
|
||||||
/** 同期退还泥点,用于核对「消耗 + 退还」的毛消耗口径。 */
|
|
||||||
refundedMudPoints: number;
|
|
||||||
totalRegisteredUsers: number;
|
totalRegisteredUsers: number;
|
||||||
newRegisteredUsers: number;
|
newRegisteredUsers: number;
|
||||||
newUserPaymentConversion: AdminDashboardPaymentConversionPayload;
|
newUserPaymentConversion: AdminDashboardPaymentConversionPayload;
|
||||||
@@ -823,44 +820,6 @@ export interface AdminTrackingEventListResponse {
|
|||||||
entries: AdminTrackingEventEntryPayload[];
|
entries: AdminTrackingEventEntryPayload[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminAgcTrackingEventQuery {
|
|
||||||
userId?: string;
|
|
||||||
projectId?: string;
|
|
||||||
creativeTaskId?: string;
|
|
||||||
agentRunId?: string;
|
|
||||||
eventName?: string;
|
|
||||||
clientVersion?: string;
|
|
||||||
startTime?: string;
|
|
||||||
endTime?: string;
|
|
||||||
cursor?: string;
|
|
||||||
limit?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminAgcTrackingEventEntry {
|
|
||||||
eventId: string;
|
|
||||||
schemaVersion: number;
|
|
||||||
eventName: string;
|
|
||||||
eventTime: string;
|
|
||||||
userId: string;
|
|
||||||
editorSessionId: string;
|
|
||||||
projectId: string | null;
|
|
||||||
creativeTaskId: string | null;
|
|
||||||
agentRunId: string | null;
|
|
||||||
agentTurnId: string | null;
|
|
||||||
status: string | null;
|
|
||||||
errorCode: string | null;
|
|
||||||
source: string;
|
|
||||||
clientVersion: string;
|
|
||||||
properties: Record<string, unknown>;
|
|
||||||
batchId: string;
|
|
||||||
receivedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminAgcTrackingEventListResponse {
|
|
||||||
entries: AdminAgcTrackingEventEntry[];
|
|
||||||
nextCursor: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminTrackingEventKeyPayload {
|
export interface AdminTrackingEventKeyPayload {
|
||||||
eventKey: string;
|
eventKey: string;
|
||||||
eventTitle: string;
|
eventTitle: string;
|
||||||
@@ -958,8 +917,6 @@ export interface AdminRechargeOrderEntryPayload {
|
|||||||
productTitle: string;
|
productTitle: string;
|
||||||
productKind: string;
|
productKind: string;
|
||||||
amountCents: number;
|
amountCents: number;
|
||||||
/** 真实支付金额(分):未支付 / 已关闭 / 已过期订单固定为 0,不能拿订单金额当实付。 */
|
|
||||||
paidAmountCents: number;
|
|
||||||
status: string;
|
status: string;
|
||||||
paymentChannel: string;
|
paymentChannel: string;
|
||||||
paidAtMicros?: number | null;
|
paidAtMicros?: number | null;
|
||||||
@@ -999,8 +956,6 @@ export interface AdminUserDetailResponse {
|
|||||||
phoneBound: boolean;
|
phoneBound: boolean;
|
||||||
wechatBound: boolean;
|
wechatBound: boolean;
|
||||||
historicalConsumedPoints: number;
|
historicalConsumedPoints: number;
|
||||||
/** 累计充值金额(分):读取失败或命中读取上限时为 null,前端按未知展示。 */
|
|
||||||
cumulativeRechargedCents?: number | null;
|
|
||||||
canReconcileConsumption: boolean;
|
canReconcileConsumption: boolean;
|
||||||
wallet: AdminProfileWalletPayload;
|
wallet: AdminProfileWalletPayload;
|
||||||
rechargeOrders: AdminRechargeOrderEntryPayload[];
|
rechargeOrders: AdminRechargeOrderEntryPayload[];
|
||||||
@@ -1110,6 +1065,7 @@ export interface AdminGameDistributionReviewRequest {
|
|||||||
decision: 'approve' | 'reject';
|
decision: 'approve' | 'reject';
|
||||||
expectedPublicationRevision: number;
|
expectedPublicationRevision: number;
|
||||||
reviewReason?: string;
|
reviewReason?: string;
|
||||||
|
entryUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminGameDistributionReviewResponse {
|
export interface AdminGameDistributionReviewResponse {
|
||||||
@@ -1132,57 +1088,6 @@ export interface AdminGameDistributionSuspendResponse {
|
|||||||
replayed: boolean;
|
replayed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminGameDistributionGameVersionEntry {
|
|
||||||
versionId: string;
|
|
||||||
gameId: string;
|
|
||||||
versionNumber: number;
|
|
||||||
status: string;
|
|
||||||
reviewReason: string | null;
|
|
||||||
packageBytes: number;
|
|
||||||
packageSha256: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
reviewedAt: string | null;
|
|
||||||
publishedAt: string | null;
|
|
||||||
entryUrl: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminGameDistributionGameEntry {
|
|
||||||
gameId: string;
|
|
||||||
title: string;
|
|
||||||
author: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
avatarUrl: string | null;
|
|
||||||
};
|
|
||||||
status: string;
|
|
||||||
versionCount: number;
|
|
||||||
playCount: number;
|
|
||||||
activeVersionId: string | null;
|
|
||||||
publicationRevision: number;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
versions: AdminGameDistributionGameVersionEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminGameDistributionGameListResponse {
|
|
||||||
games: AdminGameDistributionGameEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminGameDistributionRestoreRequest {
|
|
||||||
expectedPublicationRevision: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminGameDistributionRestoreResponse {
|
|
||||||
game: {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
status: string;
|
|
||||||
publicationRevision: number;
|
|
||||||
};
|
|
||||||
replayed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminAgcTemplatePayload {
|
export interface AdminAgcTemplatePayload {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
|
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
|
||||||
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
|
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
|
||||||
import { AdminAgcTemplatesPage } from '../pages/AdminAgcTemplatesPage';
|
import { AdminAgcTemplatesPage } from '../pages/AdminAgcTemplatesPage';
|
||||||
import { AdminAgcTrackingPage } from '../pages/AdminAgcTrackingPage';
|
|
||||||
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
|
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
|
||||||
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
|
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
|
||||||
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
|
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
|
||||||
@@ -29,7 +28,6 @@ import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGeneration
|
|||||||
import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage';
|
import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage';
|
||||||
import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
|
import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
|
||||||
import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage';
|
import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage';
|
||||||
import { AdminGameManagementPage } from '../pages/AdminGameManagementPage';
|
|
||||||
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
|
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
|
||||||
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
|
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
|
||||||
import { AdminLoginPage } from '../pages/AdminLoginPage';
|
import { AdminLoginPage } from '../pages/AdminLoginPage';
|
||||||
@@ -235,12 +233,6 @@ export function AdminApp() {
|
|||||||
onUnauthorized={handleUnauthorized}
|
onUnauthorized={handleUnauthorized}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{activeRouteId === 'agc-tracking' ? (
|
|
||||||
<AdminAgcTrackingPage
|
|
||||||
token={token}
|
|
||||||
onUnauthorized={handleUnauthorized}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{activeRouteId === 'error-reports' ? (
|
{activeRouteId === 'error-reports' ? (
|
||||||
<AdminErrorReportsPage
|
<AdminErrorReportsPage
|
||||||
token={token}
|
token={token}
|
||||||
@@ -322,12 +314,6 @@ export function AdminApp() {
|
|||||||
onUnauthorized={handleUnauthorized}
|
onUnauthorized={handleUnauthorized}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{activeRouteId === 'game-management' ? (
|
|
||||||
<AdminGameManagementPage
|
|
||||||
token={token}
|
|
||||||
onUnauthorized={handleUnauthorized}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{activeRouteId === 'editor-assets' ? (
|
{activeRouteId === 'editor-assets' ? (
|
||||||
<AdminEditorAssetQueryPage
|
<AdminEditorAssetQueryPage
|
||||||
token={token}
|
token={token}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
ReceiptText,
|
ReceiptText,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Star,
|
Star,
|
||||||
Swords,
|
|
||||||
Table2,
|
Table2,
|
||||||
TicketCheck,
|
TicketCheck,
|
||||||
TicketPercent,
|
TicketPercent,
|
||||||
@@ -41,7 +40,6 @@ const routeIcons = {
|
|||||||
tables: Database,
|
tables: Database,
|
||||||
debug: Bug,
|
debug: Bug,
|
||||||
tracking: Table2,
|
tracking: Table2,
|
||||||
'agc-tracking': Table2,
|
|
||||||
'error-reports': Bug,
|
'error-reports': Bug,
|
||||||
'gray-release': GitBranch,
|
'gray-release': GitBranch,
|
||||||
redeem: TicketPercent,
|
redeem: TicketPercent,
|
||||||
@@ -53,7 +51,6 @@ const routeIcons = {
|
|||||||
'editor-generation-pricing': Coins,
|
'editor-generation-pricing': Coins,
|
||||||
'editor-showcase': Star,
|
'editor-showcase': Star,
|
||||||
'game-distribution': Gamepad2,
|
'game-distribution': Gamepad2,
|
||||||
'game-management': Swords,
|
|
||||||
'editor-assets': Images,
|
'editor-assets': Images,
|
||||||
'project-snapshots': FolderArchive,
|
'project-snapshots': FolderArchive,
|
||||||
accounts: Users,
|
accounts: Users,
|
||||||
|
|||||||
@@ -8,22 +8,6 @@ import {
|
|||||||
routeHash,
|
routeHash,
|
||||||
} from './adminRoutes';
|
} from './adminRoutes';
|
||||||
|
|
||||||
test('客户端埋点路由遵守成员页签权限', () => {
|
|
||||||
expect(resolveAdminRoute('#agc-tracking')).toBe('agc-tracking');
|
|
||||||
expect(
|
|
||||||
getAccessibleAdminRoutes({
|
|
||||||
accountRole: 'member',
|
|
||||||
tabPermissions: ['agc-tracking'],
|
|
||||||
}).map((route) => route.id),
|
|
||||||
).toEqual(['agc-tracking']);
|
|
||||||
expect(
|
|
||||||
getAccessibleAdminRoutes({
|
|
||||||
accountRole: 'member',
|
|
||||||
tabPermissions: ['tracking'],
|
|
||||||
}).some((route) => route.id === 'agc-tracking'),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('后台默认进入 Dashboard', () => {
|
test('后台默认进入 Dashboard', () => {
|
||||||
expect(adminRoutes[0]).toEqual({
|
expect(adminRoutes[0]).toEqual({
|
||||||
id: 'dashboard',
|
id: 'dashboard',
|
||||||
@@ -59,6 +43,12 @@ test('后台灰度发布路由可通过导航和 hash 访问', () => {
|
|||||||
expect(routeHash('gray-release')).toBe('#gray-release');
|
expect(routeHash('gray-release')).toBe('#gray-release');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('后台不再暴露旧创作模板管理路由', () => {
|
||||||
|
expect(resolveAdminRoute('#creation-entry')).toBe('dashboard');
|
||||||
|
expect(resolveAdminRoute('#creation-announcement')).toBe('dashboard');
|
||||||
|
expect(resolveAdminRoute('#work-visibility')).toBe('dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
test('后台素材查询路由可通过导航和 hash 访问', () => {
|
test('后台素材查询路由可通过导航和 hash 访问', () => {
|
||||||
expect(adminRoutes).toContainEqual({
|
expect(adminRoutes).toContainEqual({
|
||||||
id: 'editor-assets',
|
id: 'editor-assets',
|
||||||
@@ -168,16 +158,6 @@ test('后台游戏审核路由可通过导航和 hash 访问', () => {
|
|||||||
expect(routeHash('game-distribution')).toBe('#game-distribution');
|
expect(routeHash('game-distribution')).toBe('#game-distribution');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('后台游戏管理路由可通过导航和 hash 访问', () => {
|
|
||||||
expect(adminRoutes).toContainEqual({
|
|
||||||
id: 'game-management',
|
|
||||||
label: '游戏管理',
|
|
||||||
hash: '#game-management',
|
|
||||||
});
|
|
||||||
expect(resolveAdminRoute('#game-management')).toBe('game-management');
|
|
||||||
expect(routeHash('game-management')).toBe('#game-management');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('member 可单独获得游戏审核 Tab 权限', () => {
|
test('member 可单独获得游戏审核 Tab 权限', () => {
|
||||||
const routes = getAccessibleAdminRoutes({
|
const routes = getAccessibleAdminRoutes({
|
||||||
accountRole: 'member',
|
accountRole: 'member',
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ export type AdminRouteId =
|
|||||||
| 'tables'
|
| 'tables'
|
||||||
| 'debug'
|
| 'debug'
|
||||||
| 'tracking'
|
| 'tracking'
|
||||||
| 'agc-tracking'
|
|
||||||
| 'error-reports'
|
| 'error-reports'
|
||||||
| 'gray-release'
|
| 'gray-release'
|
||||||
| 'redeem'
|
| 'redeem'
|
||||||
@@ -17,7 +16,6 @@ export type AdminRouteId =
|
|||||||
| 'editor-generation-pricing'
|
| 'editor-generation-pricing'
|
||||||
| 'editor-showcase'
|
| 'editor-showcase'
|
||||||
| 'game-distribution'
|
| 'game-distribution'
|
||||||
| 'game-management'
|
|
||||||
| 'editor-assets'
|
| 'editor-assets'
|
||||||
| 'project-snapshots'
|
| 'project-snapshots'
|
||||||
| 'agc-models'
|
| 'agc-models'
|
||||||
@@ -43,7 +41,6 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
|||||||
{ id: 'tables', label: '表查询', hash: '#tables' },
|
{ id: 'tables', label: '表查询', hash: '#tables' },
|
||||||
{ id: 'debug', label: 'API 调试', hash: '#debug' },
|
{ id: 'debug', label: 'API 调试', hash: '#debug' },
|
||||||
{ id: 'tracking', label: '埋点数据', hash: '#tracking' },
|
{ id: 'tracking', label: '埋点数据', hash: '#tracking' },
|
||||||
{ id: 'agc-tracking', label: '客户端埋点', hash: '#agc-tracking' },
|
|
||||||
{ id: 'error-reports', label: '错误报告', hash: '#error-reports' },
|
{ id: 'error-reports', label: '错误报告', hash: '#error-reports' },
|
||||||
{ id: 'gray-release', label: '灰度发布', hash: '#gray-release' },
|
{ id: 'gray-release', label: '灰度发布', hash: '#gray-release' },
|
||||||
{ id: 'redeem', label: '兑换码', hash: '#redeem' },
|
{ id: 'redeem', label: '兑换码', hash: '#redeem' },
|
||||||
@@ -61,7 +58,6 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
|||||||
{ id: 'agc-templates', label: '模板管理', hash: '#agc-templates' },
|
{ id: 'agc-templates', label: '模板管理', hash: '#agc-templates' },
|
||||||
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
|
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
|
||||||
{ id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' },
|
{ id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' },
|
||||||
{ id: 'game-management', label: '游戏管理', hash: '#game-management' },
|
|
||||||
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
|
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
|
||||||
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
|
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
|
||||||
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ const detail: AdminUserDetailResponse = {
|
|||||||
phoneBound: true,
|
phoneBound: true,
|
||||||
wechatBound: true,
|
wechatBound: true,
|
||||||
historicalConsumedPoints: 1234,
|
historicalConsumedPoints: 1234,
|
||||||
cumulativeRechargedCents: 128800,
|
|
||||||
canReconcileConsumption: true,
|
canReconcileConsumption: true,
|
||||||
wallet,
|
wallet,
|
||||||
rechargeOrders: [
|
rechargeOrders: [
|
||||||
@@ -63,7 +62,6 @@ const detail: AdminUserDetailResponse = {
|
|||||||
productTitle: '60泥点',
|
productTitle: '60泥点',
|
||||||
productKind: 'points',
|
productKind: 'points',
|
||||||
amountCents: 600,
|
amountCents: 600,
|
||||||
paidAmountCents: 600,
|
|
||||||
status: 'paid',
|
status: 'paid',
|
||||||
paymentChannel: 'wechat_native',
|
paymentChannel: 'wechat_native',
|
||||||
paidAtMicros: 1_720_000_000_000_000,
|
paidAtMicros: 1_720_000_000_000_000,
|
||||||
@@ -126,11 +124,7 @@ test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退
|
|||||||
expect(screen.getByText('25', { selector: 'strong' })).toBeTruthy();
|
expect(screen.getByText('25', { selector: 'strong' })).toBeTruthy();
|
||||||
expect(screen.getByText('历史花费')).toBeTruthy();
|
expect(screen.getByText('历史花费')).toBeTruthy();
|
||||||
expect(screen.getByText('1234', { selector: 'strong' })).toBeTruthy();
|
expect(screen.getByText('1234', { selector: 'strong' })).toBeTruthy();
|
||||||
expect(screen.getByText('累计充值')).toBeTruthy();
|
|
||||||
expect(screen.getByText('¥1288.00')).toBeTruthy();
|
|
||||||
expect(screen.getByText('order-1')).toBeTruthy();
|
expect(screen.getByText('order-1')).toBeTruthy();
|
||||||
expect(screen.getByRole('columnheader', { name: '实付' })).toBeTruthy();
|
|
||||||
expect(screen.getByRole('columnheader', { name: '发放泥点' })).toBeTruthy();
|
|
||||||
|
|
||||||
await user.keyboard('{Escape}');
|
await user.keyboard('{Escape}');
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
@@ -139,29 +133,6 @@ test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退
|
|||||||
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('累计充值读取不到时展示未知,不用订单列表近似', async () => {
|
|
||||||
vi.mocked(getAdminUserDetail).mockResolvedValue({
|
|
||||||
...detail,
|
|
||||||
cumulativeRechargedCents: null,
|
|
||||||
});
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<AdminUserReferenceButton
|
|
||||||
token="admin-token"
|
|
||||||
userId="user-1"
|
|
||||||
onUnauthorized={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: '查看用户信息' }));
|
|
||||||
await screen.findByText('陶泥用户');
|
|
||||||
|
|
||||||
expect(screen.getByText('累计充值')).toBeTruthy();
|
|
||||||
expect(screen.getByText('累计充值').nextElementSibling?.textContent).toBe(
|
|
||||||
'未知',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('只有陶泥号时按 publicUserCode 查询用户', async () => {
|
test('只有陶泥号时按 publicUserCode 查询用户', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -364,7 +364,6 @@ export function AdminUserDetailDialog({
|
|||||||
<th>订单</th>
|
<th>订单</th>
|
||||||
<th>商品</th>
|
<th>商品</th>
|
||||||
<th>实付</th>
|
<th>实付</th>
|
||||||
<th>发放泥点</th>
|
|
||||||
<th>退款</th>
|
<th>退款</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -378,13 +377,11 @@ export function AdminUserDetailDialog({
|
|||||||
</span>
|
</span>
|
||||||
<small>{formatMicros(order.createdAtMicros)}</small>
|
<small>{formatMicros(order.createdAtMicros)}</small>
|
||||||
</td>
|
</td>
|
||||||
<td>{order.productTitle || order.productId}</td>
|
|
||||||
<td>
|
<td>
|
||||||
{order.paidAmountCents > 0
|
{order.productTitle || order.productId}
|
||||||
? formatMoney(order.paidAmountCents)
|
<small>发放 {order.pointsDelta} 泥点</small>
|
||||||
: '未支付'}
|
|
||||||
</td>
|
</td>
|
||||||
<td>{order.pointsDelta} 泥点</td>
|
<td>{formatMoney(order.amountCents)}</td>
|
||||||
<td>
|
<td>
|
||||||
{formatMoney(order.cumulativeSuccessRefundCents)}
|
{formatMoney(order.cumulativeSuccessRefundCents)}
|
||||||
<small>欠账 {order.unrecoveredPoints} 泥点</small>
|
<small>欠账 {order.unrecoveredPoints} 泥点</small>
|
||||||
@@ -438,14 +435,6 @@ function UserIdentityHeader({ detail }: { detail: AdminUserDetailResponse }) {
|
|||||||
<dt>登录方式</dt>
|
<dt>登录方式</dt>
|
||||||
<dd>{detail.loginMethod || '-'}</dd>
|
<dd>{detail.loginMethod || '-'}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<dt>累计充值</dt>
|
|
||||||
<dd>
|
|
||||||
{typeof detail.cumulativeRechargedCents === 'number'
|
|
||||||
? formatMoney(detail.cumulativeRechargedCents)
|
|
||||||
: '未知'}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<dt>绑定状态</dt>
|
<dt>绑定状态</dt>
|
||||||
<dd>
|
<dd>
|
||||||
|
|||||||
@@ -339,10 +339,7 @@ test.each([409, 503])(
|
|||||||
await confirmWrite();
|
await confirmWrite();
|
||||||
const message = await screen.findByRole('alert');
|
const message = await screen.findByRole('alert');
|
||||||
const feedback = message.parentElement!;
|
const feedback = message.parentElement!;
|
||||||
// 聚焦发生在 React passive effect 里(AdminAgcTemplatesPage 的 feedback 聚焦 useEffect),
|
expect(document.activeElement).toBe(feedback);
|
||||||
// 而 findByRole 在 alert 节点一挂上就返回,可能早于该 effect 执行;这里等聚焦落地,
|
|
||||||
// 避免在 CI 负载下抢跑。断言口径不变:焦点最终必须落在提示区而不是弹窗面板。
|
|
||||||
await waitFor(() => expect(document.activeElement).toBe(feedback));
|
|
||||||
expect(feedback.tabIndex).toBe(-1);
|
expect(feedback.tabIndex).toBe(-1);
|
||||||
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
|
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||||
expect(viewport.scrollTop).toBe(20);
|
expect(viewport.scrollTop).toBe(20);
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
import {
|
|
||||||
cleanup,
|
|
||||||
fireEvent,
|
|
||||||
render,
|
|
||||||
screen,
|
|
||||||
waitFor,
|
|
||||||
within,
|
|
||||||
} from '@testing-library/react';
|
|
||||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
|
||||||
AdminApiError,
|
|
||||||
listAdminAgcTrackingEvents,
|
|
||||||
} from '../api/adminApiClient';
|
|
||||||
import type { AdminAgcTrackingEventEntry } from '../api/adminApiTypes';
|
|
||||||
import { AdminAgcTrackingPage } from './AdminAgcTrackingPage';
|
|
||||||
|
|
||||||
vi.mock('../api/adminApiClient', async (original) => ({
|
|
||||||
...(await original<typeof import('../api/adminApiClient')>()),
|
|
||||||
listAdminAgcTrackingEvents: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock('../components/AdminUserReferenceButton', () => ({
|
|
||||||
AdminUserReferenceButton: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const entry: AdminAgcTrackingEventEntry = {
|
|
||||||
eventId: 'event-1',
|
|
||||||
schemaVersion: 1,
|
|
||||||
eventName: 'project_save',
|
|
||||||
eventTime: '2026-09-21T12:00:00.123Z',
|
|
||||||
userId: 'user-1',
|
|
||||||
editorSessionId: 'session-1',
|
|
||||||
projectId: 'project-1',
|
|
||||||
creativeTaskId: 'project-1',
|
|
||||||
agentRunId: null,
|
|
||||||
agentTurnId: null,
|
|
||||||
status: 'success',
|
|
||||||
errorCode: null,
|
|
||||||
source: 'gui',
|
|
||||||
clientVersion: '1.0',
|
|
||||||
properties: { reason: 'manual' },
|
|
||||||
batchId: 'batch-1',
|
|
||||||
receivedAt: '2026-09-21T12:15:00.000Z',
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.mocked(listAdminAgcTrackingEvents).mockReset();
|
|
||||||
});
|
|
||||||
afterEach(cleanup);
|
|
||||||
|
|
||||||
test('翻页沿用游标,筛选和刷新从第一页重新查询,详情保留 null 和事件属性', async () => {
|
|
||||||
vi.mocked(listAdminAgcTrackingEvents)
|
|
||||||
.mockResolvedValueOnce({ entries: [entry], nextCursor: 'cursor-page-2' })
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
entries: [{ ...entry, eventId: 'event-2' }],
|
|
||||||
nextCursor: null,
|
|
||||||
})
|
|
||||||
.mockResolvedValue({ entries: [entry], nextCursor: 'cursor-new' });
|
|
||||||
render(<AdminAgcTrackingPage token="admin-token" onUnauthorized={vi.fn()} />);
|
|
||||||
await screen.findByText('保存项目', { selector: 'td' });
|
|
||||||
expect(listAdminAgcTrackingEvents).toHaveBeenLastCalledWith('admin-token', {
|
|
||||||
cursor: undefined,
|
|
||||||
limit: 50,
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByText('查看详情'));
|
|
||||||
const dialog = screen.getByRole('dialog');
|
|
||||||
expect(within(dialog).getAllByText('—').length).toBe(3);
|
|
||||||
expect(within(dialog).getByText(/"reason": "manual"/)).toBeTruthy();
|
|
||||||
expect(within(dialog).getByText(entry.eventTime)).toBeTruthy();
|
|
||||||
fireEvent.click(within(dialog).getByLabelText('关闭详情'));
|
|
||||||
fireEvent.click(screen.getByText('下一页'));
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(listAdminAgcTrackingEvents).toHaveBeenLastCalledWith('admin-token', {
|
|
||||||
cursor: 'cursor-page-2',
|
|
||||||
limit: 50,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(screen.getByText('查询').closest('button')?.disabled).toBe(false),
|
|
||||||
);
|
|
||||||
fireEvent.click(screen.getByText('上一页'));
|
|
||||||
await screen.findByText('第 1 页');
|
|
||||||
expect(listAdminAgcTrackingEvents).toHaveBeenCalledTimes(2);
|
|
||||||
fireEvent.change(screen.getByLabelText('项目 ID'), {
|
|
||||||
target: { value: 'project-2' },
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByText('查询'));
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(listAdminAgcTrackingEvents).toHaveBeenLastCalledWith(
|
|
||||||
'admin-token',
|
|
||||||
expect.objectContaining({
|
|
||||||
projectId: 'project-2',
|
|
||||||
cursor: undefined,
|
|
||||||
limit: 50,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(screen.getByText('第 1 页')).toBeTruthy();
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(screen.getByText('刷新').closest('button')?.disabled).toBe(false),
|
|
||||||
);
|
|
||||||
fireEvent.click(screen.getByText('刷新'));
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(listAdminAgcTrackingEvents).toHaveBeenCalledTimes(4),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('空结果、权限失败与登录失效沿用后台反馈', async () => {
|
|
||||||
const unauthorized = vi.fn();
|
|
||||||
vi.mocked(listAdminAgcTrackingEvents)
|
|
||||||
.mockResolvedValueOnce({ entries: [], nextCursor: null })
|
|
||||||
.mockRejectedValueOnce(
|
|
||||||
new AdminApiError({ status: 403, message: '无权访问客户端埋点' }),
|
|
||||||
)
|
|
||||||
.mockRejectedValueOnce(
|
|
||||||
new AdminApiError({ status: 401, message: '登录失效' }),
|
|
||||||
);
|
|
||||||
render(<AdminAgcTrackingPage token="token" onUnauthorized={unauthorized} />);
|
|
||||||
await screen.findByText('暂无客户端埋点数据');
|
|
||||||
fireEvent.click(screen.getByText('刷新'));
|
|
||||||
expect((await screen.findByRole('alert')).textContent).toContain(
|
|
||||||
'无权访问客户端埋点',
|
|
||||||
);
|
|
||||||
fireEvent.click(screen.getByText('刷新'));
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(unauthorized).toHaveBeenCalledWith('登录状态已失效'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,379 +0,0 @@
|
|||||||
import { Modal } from '@genarrative/shared/components';
|
|
||||||
import { FormEvent, useEffect, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
import { listAdminAgcTrackingEvents } from '../api/adminApiClient';
|
|
||||||
import type {
|
|
||||||
AdminAgcTrackingEventEntry,
|
|
||||||
AdminAgcTrackingEventListResponse,
|
|
||||||
AdminAgcTrackingEventQuery,
|
|
||||||
} from '../api/adminApiTypes';
|
|
||||||
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
|
|
||||||
import { handlePageError } from './pageUtils';
|
|
||||||
|
|
||||||
const eventLabels: Record<string, string> = {
|
|
||||||
editor_session_start: '编辑器会话开始',
|
|
||||||
editor_session_end: '编辑器会话结束',
|
|
||||||
editor_focus_start: '编辑器获得焦点',
|
|
||||||
editor_focus_end: '编辑器失去焦点',
|
|
||||||
project_create_success: '项目创建成功',
|
|
||||||
project_open: '打开项目',
|
|
||||||
creative_task_submit: '首次提交创作目标',
|
|
||||||
agent_run_completed: 'Agent 运行完成',
|
|
||||||
agent_run_failed: 'Agent 运行失败',
|
|
||||||
project_revision_created: '项目产生修改',
|
|
||||||
preview_ready: '预览就绪',
|
|
||||||
project_save: '保存项目',
|
|
||||||
};
|
|
||||||
|
|
||||||
const fieldLabels: Record<keyof AdminAgcTrackingEventEntry, string> = {
|
|
||||||
eventId: '事件 ID',
|
|
||||||
schemaVersion: '事件版本',
|
|
||||||
eventName: '事件类型',
|
|
||||||
eventTime: '发生时间',
|
|
||||||
userId: '用户 ID',
|
|
||||||
editorSessionId: '编辑器会话 ID',
|
|
||||||
projectId: '项目 ID',
|
|
||||||
creativeTaskId: '创作目标 ID',
|
|
||||||
agentRunId: 'Agent run ID',
|
|
||||||
agentTurnId: 'Agent turn ID',
|
|
||||||
status: '结果',
|
|
||||||
errorCode: '错误码',
|
|
||||||
source: '来源',
|
|
||||||
clientVersion: '客户端版本',
|
|
||||||
properties: '事件属性',
|
|
||||||
batchId: '批次 ID',
|
|
||||||
receivedAt: '入库时间',
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatTime(value: string) {
|
|
||||||
const date = new Date(value);
|
|
||||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdminAgcTrackingPage({
|
|
||||||
token,
|
|
||||||
onUnauthorized,
|
|
||||||
}: {
|
|
||||||
token: string;
|
|
||||||
onUnauthorized: (message?: string) => void;
|
|
||||||
}) {
|
|
||||||
const [filters, setFilters] = useState({
|
|
||||||
userId: '',
|
|
||||||
projectId: '',
|
|
||||||
eventName: '',
|
|
||||||
startTime: '',
|
|
||||||
endTime: '',
|
|
||||||
});
|
|
||||||
const [query, setQuery] = useState<AdminAgcTrackingEventQuery>({});
|
|
||||||
const [cursors, setCursors] = useState<Array<string | undefined>>([
|
|
||||||
undefined,
|
|
||||||
]);
|
|
||||||
const [page, setPage] = useState(0);
|
|
||||||
const [refresh, setRefresh] = useState(0);
|
|
||||||
const [entries, setEntries] = useState<AdminAgcTrackingEventEntry[]>([]);
|
|
||||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [detail, setDetail] = useState<AdminAgcTrackingEventEntry | null>(null);
|
|
||||||
const cursor = cursors[page];
|
|
||||||
// 首页没有入站游标,返回首页时保留原快照,刷新才获取新数据。
|
|
||||||
const firstPage = useRef<{
|
|
||||||
token: string;
|
|
||||||
query: AdminAgcTrackingEventQuery;
|
|
||||||
response: AdminAgcTrackingEventListResponse;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const saved = firstPage.current;
|
|
||||||
if (!cursor && saved?.token === token && saved.query === query) {
|
|
||||||
setEntries(saved.response.entries);
|
|
||||||
setNextCursor(saved.response.nextCursor);
|
|
||||||
setError('');
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let active = true;
|
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
setEntries([]);
|
|
||||||
setNextCursor(null);
|
|
||||||
void listAdminAgcTrackingEvents(token, { ...query, cursor, limit: 50 })
|
|
||||||
.then((response) => {
|
|
||||||
if (!active) return;
|
|
||||||
if (!cursor) firstPage.current = { token, query, response };
|
|
||||||
setEntries(response.entries);
|
|
||||||
setNextCursor(response.nextCursor);
|
|
||||||
})
|
|
||||||
.catch((failure: unknown) => {
|
|
||||||
if (active) handlePageError(failure, onUnauthorized, setError);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (active) setLoading(false);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, [token, query, cursor, refresh, onUnauthorized]);
|
|
||||||
|
|
||||||
function resetPages() {
|
|
||||||
firstPage.current = null;
|
|
||||||
setCursors([undefined]);
|
|
||||||
setPage(0);
|
|
||||||
setDetail(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function search(event: FormEvent<HTMLFormElement>) {
|
|
||||||
event.preventDefault();
|
|
||||||
if (
|
|
||||||
filters.startTime &&
|
|
||||||
filters.endTime &&
|
|
||||||
new Date(filters.startTime) >= new Date(filters.endTime)
|
|
||||||
) {
|
|
||||||
setError('结束时间必须晚于开始时间');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resetPages();
|
|
||||||
setQuery({
|
|
||||||
userId: filters.userId.trim(),
|
|
||||||
projectId: filters.projectId.trim(),
|
|
||||||
eventName: filters.eventName,
|
|
||||||
startTime: filters.startTime
|
|
||||||
? new Date(filters.startTime).toISOString()
|
|
||||||
: undefined,
|
|
||||||
endTime: filters.endTime
|
|
||||||
? new Date(filters.endTime).toISOString()
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function related(
|
|
||||||
key: 'creativeTaskId' | 'agentRunId' | 'clientVersion',
|
|
||||||
value: string,
|
|
||||||
) {
|
|
||||||
resetPages();
|
|
||||||
setFilters({
|
|
||||||
userId: '',
|
|
||||||
projectId: '',
|
|
||||||
eventName: '',
|
|
||||||
startTime: '',
|
|
||||||
endTime: '',
|
|
||||||
});
|
|
||||||
setQuery({ [key]: value });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="admin-page admin-page-wide">
|
|
||||||
<div className="admin-page-heading">
|
|
||||||
<div>
|
|
||||||
<h2>客户端埋点</h2>
|
|
||||||
<p>按发生时间倒序展示</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="admin-secondary-button"
|
|
||||||
disabled={loading}
|
|
||||||
onClick={() => {
|
|
||||||
resetPages();
|
|
||||||
setRefresh((value) => value + 1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
刷新
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form className="admin-panel admin-form" onSubmit={search}>
|
|
||||||
<div className="admin-filter-grid">
|
|
||||||
{(['userId', 'projectId'] as const).map((key) => (
|
|
||||||
<label key={key} className="admin-field">
|
|
||||||
<span>{fieldLabels[key]}</span>
|
|
||||||
<input
|
|
||||||
value={filters[key]}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFilters({ ...filters, [key]: event.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
<label className="admin-field">
|
|
||||||
<span>事件类型</span>
|
|
||||||
<select
|
|
||||||
value={filters.eventName}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFilters({ ...filters, eventName: event.target.value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">全部</option>
|
|
||||||
{Object.entries(eventLabels).map(([value, label]) => (
|
|
||||||
<option key={value} value={value}>
|
|
||||||
{label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{(['startTime', 'endTime'] as const).map((key) => (
|
|
||||||
<label key={key} className="admin-field">
|
|
||||||
<span>
|
|
||||||
{key === 'startTime'
|
|
||||||
? '发生时间起点(含)'
|
|
||||||
: '发生时间终点(不含)'}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
value={filters[key]}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFilters({ ...filters, [key]: event.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="admin-action-row">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="admin-primary-button"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
查询
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{(['creativeTaskId', 'agentRunId', 'clientVersion'] as const)
|
|
||||||
.filter((key) => query[key])
|
|
||||||
.map((key) => (
|
|
||||||
<p key={key}>
|
|
||||||
{fieldLabels[key]}:{query[key]}(重新查询可清除)
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</form>
|
|
||||||
{error ? (
|
|
||||||
<p role="alert" className="admin-error-message">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<div className="admin-panel">
|
|
||||||
<div className="admin-table-wrap">
|
|
||||||
<table className="admin-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
{[
|
|
||||||
'入库时间',
|
|
||||||
'发生时间',
|
|
||||||
'用户',
|
|
||||||
'事件名称',
|
|
||||||
'项目',
|
|
||||||
'来源',
|
|
||||||
'结果',
|
|
||||||
'客户端版本',
|
|
||||||
'详情',
|
|
||||||
].map((label) => (
|
|
||||||
<th key={label}>{label}</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{entries.map((entry) => (
|
|
||||||
<tr key={entry.eventId}>
|
|
||||||
<td>{formatTime(entry.receivedAt)}</td>
|
|
||||||
<td>{formatTime(entry.eventTime)}</td>
|
|
||||||
<td>
|
|
||||||
{entry.userId}
|
|
||||||
<AdminUserReferenceButton
|
|
||||||
token={token}
|
|
||||||
userId={entry.userId}
|
|
||||||
onUnauthorized={onUnauthorized}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>{eventLabels[entry.eventName] ?? entry.eventName}</td>
|
|
||||||
<td>{entry.projectId ?? '—'}</td>
|
|
||||||
<td>{entry.source}</td>
|
|
||||||
<td>{entry.status ?? '—'}</td>
|
|
||||||
<td>{entry.clientVersion}</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="admin-ghost-button"
|
|
||||||
onClick={() => setDetail(entry)}
|
|
||||||
>
|
|
||||||
查看详情
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{loading ? (
|
|
||||||
<p role="status">加载中…</p>
|
|
||||||
) : !error && entries.length === 0 ? (
|
|
||||||
<p>暂无客户端埋点数据</p>
|
|
||||||
) : null}
|
|
||||||
<div className="admin-action-row">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="admin-secondary-button"
|
|
||||||
disabled={loading || page === 0}
|
|
||||||
onClick={() => setPage((value) => value - 1)}
|
|
||||||
>
|
|
||||||
上一页
|
|
||||||
</button>
|
|
||||||
<span>第 {page + 1} 页</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="admin-secondary-button"
|
|
||||||
disabled={loading || !nextCursor}
|
|
||||||
onClick={() => {
|
|
||||||
if (!nextCursor) return;
|
|
||||||
setCursors([...cursors.slice(0, page + 1), nextCursor]);
|
|
||||||
setPage(page + 1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{detail ? (
|
|
||||||
<Modal
|
|
||||||
open
|
|
||||||
title="客户端埋点详情"
|
|
||||||
closeLabel="关闭详情"
|
|
||||||
onClose={() => setDetail(null)}
|
|
||||||
className="genarrative-ui"
|
|
||||||
>
|
|
||||||
<dl>
|
|
||||||
{(
|
|
||||||
Object.keys(fieldLabels) as Array<
|
|
||||||
keyof AdminAgcTrackingEventEntry
|
|
||||||
>
|
|
||||||
)
|
|
||||||
.filter((key) => key !== 'properties')
|
|
||||||
.map((key) => (
|
|
||||||
<div key={key}>
|
|
||||||
<dt>{fieldLabels[key]}</dt>
|
|
||||||
<dd style={{ overflowWrap: 'anywhere' }}>
|
|
||||||
{detail[key] == null ? '—' : String(detail[key])}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
<div className="admin-action-row">
|
|
||||||
{(['creativeTaskId', 'agentRunId', 'clientVersion'] as const).map(
|
|
||||||
(key) =>
|
|
||||||
detail[key] ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={key}
|
|
||||||
className="admin-secondary-button"
|
|
||||||
onClick={() => related(key, detail[key]!)}
|
|
||||||
>
|
|
||||||
查询同一{fieldLabels[key]}
|
|
||||||
</button>
|
|
||||||
) : null,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<h3>事件属性</h3>
|
|
||||||
<pre style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
|
|
||||||
{JSON.stringify(detail.properties, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</Modal>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -34,7 +34,6 @@ const dashboardResponse: AdminDashboardResponse = {
|
|||||||
metrics: {
|
metrics: {
|
||||||
generatedAssets: 12,
|
generatedAssets: 12,
|
||||||
consumedMudPoints: 88,
|
consumedMudPoints: 88,
|
||||||
refundedMudPoints: 24,
|
|
||||||
totalRegisteredUsers: 1200,
|
totalRegisteredUsers: 1200,
|
||||||
newRegisteredUsers: 16,
|
newRegisteredUsers: 16,
|
||||||
newUserPaymentConversion: {
|
newUserPaymentConversion: {
|
||||||
@@ -106,8 +105,6 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () =
|
|||||||
expect(await screen.findByText('总计数据')).toBeTruthy();
|
expect(await screen.findByText('总计数据')).toBeTruthy();
|
||||||
expect(screen.getByText('时段数据')).toBeTruthy();
|
expect(screen.getByText('时段数据')).toBeTruthy();
|
||||||
expect(screen.getByText('本日生产素材数')).toBeTruthy();
|
expect(screen.getByText('本日生产素材数')).toBeTruthy();
|
||||||
expect(screen.getByText('本日消耗泥点数')).toBeTruthy();
|
|
||||||
expect(screen.getByText('本日退还泥点数')).toBeTruthy();
|
|
||||||
expect(screen.getByText('总注册用户')).toBeTruthy();
|
expect(screen.getByText('总注册用户')).toBeTruthy();
|
||||||
expect(screen.getByText('本日新增用户数')).toBeTruthy();
|
expect(screen.getByText('本日新增用户数')).toBeTruthy();
|
||||||
expect(screen.getByText('新增用户转化与留存')).toBeTruthy();
|
expect(screen.getByText('新增用户转化与留存')).toBeTruthy();
|
||||||
|
|||||||
@@ -128,12 +128,6 @@ export function AdminDashboardPage({
|
|||||||
value: metrics?.consumedMudPoints ?? 0,
|
value: metrics?.consumedMudPoints ?? 0,
|
||||||
unit: '泥点',
|
unit: '泥点',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'refunded-mud-points',
|
|
||||||
label: `${rangePrefix(granularity)}退还泥点数`,
|
|
||||||
value: metrics?.refundedMudPoints ?? 0,
|
|
||||||
unit: '泥点',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'new-registered-users',
|
id: 'new-registered-users',
|
||||||
label: `${rangePrefix(granularity)}新增用户数`,
|
label: `${rangePrefix(granularity)}新增用户数`,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user