合并 master 更新到 AI 游戏创作分支

合入 master 的画布 Agent、精选素材审核、外部编辑器 API 与认证投影更新
保留当前分支的 AI 游戏创作壳、配置项和 LLM 运行接口改造
解决图片编辑器、LLM、去背景完成、外部生成排序和文档冲突
沿用 master 的 SpacetimeDB auth_store_projection 迁移与生成绑定
This commit is contained in:
AIGameCreator App
2026-07-06 11:47:19 +08:00
309 changed files with 24952 additions and 3100 deletions
@@ -0,0 +1,332 @@
---
name: genarrative-external-editor-api
description: Guide use of Genarrative's external editor/canvas OpenAPI. Use when a user describes a canvas/editor integration need and Codex must infer the right `/api/external/v1` API automatically, prepare canvas and asset-library context, abstract reusable art specs before generating assets, upload references, draft curl/HTTP/SDK requests, or set up and safely handle a Genarrative developer API Key.
---
# Genarrative External Editor API
Use the live OpenAPI contract as the source of truth: `GET https://www.genarrative.world/api/external/v1/openapi.json`. In this repository, the same contract is `docs/openapi/genarrative-external-v1.openapi.json`. If exact fields or enums matter, read the contract before emitting final code.
Prefer the bundled Python helper for runnable examples: `scripts/genarrative_external_api.py`. It uses only Python stdlib, reads the local JSON API Key file, fixes the production base URL, and wraps upload/confirm/generation routes.
## Workflow
1. At the start of a new conversation, ask the user for the canvas name before the first generation call unless an existing session is already provided. Create or use a project with that name and an asset-library folder with the same name. Keep `canvasName`, `projectId`, `assetFolderId`, and the current art spec in conversation state.
2. Before any art asset generation, abstract the user's request into a reusable art spec. Ask only for missing spec fields required by the selected asset type. If a current spec already exists and the user does not request a new style/spec, reuse it automatically.
3. Classify the user's natural-language intent. Do not ask the user to choose an API:
- "生成/生图/做一张图" -> image generation
- "重绘/修改这张图" -> image edit
- "用这张参考图/基于本地图生成" -> upload local reference image, then generation or edit
- "上传本地素材" -> upload ticket, OSS form upload, object confirm
- "保存画板/更新布局" -> canvas save
- "读取私有素材" -> signed read URL
4. Ask only for missing inputs that affect the request body or an actually ambiguous route:
- credentials JSON path only if the user cannot use the default local path
- canvas name when no current canvas session exists; existing `projectId`, folder/resource IDs only when resuming a known project
- media type, prompt, references, dimensions, model, ratio, duration, and resolution
- whether referenced media is already uploaded as `objectKey` or still local
5. Every external generation must write to both the canvas and the asset library. Include `projectId`, `assetFolderId`, a display label, and `canvasCompletion` whenever the target endpoint supports them. For character animation, use the helper's two-step fallback: generate with `projectId` + `canvasCompletion`, then create a library asset from the first returned frame in the session folder.
6. If the user lacks an API Key, guide setup before request design.
7. Read `references/api-selection.md` before finalizing any request. Use the core table below for fast routing, then verify details in the reference.
8. Use `scripts/genarrative_external_api.py` when the user wants runnable Python, reference image upload, canvas/folder session setup, art-spec carrying, or a chain that should execute with fewer hand-written curl steps.
9. Keep to `/api/external/v1` unless the user explicitly asks for internal profile/admin APIs.
## Core Routes
| Intent | Method and path | Required fields |
| --- | --- | --- |
| List/create projects | `GET/POST /api/external/v1/editor/projects` | create: optional `title` |
| Save canvas | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers` |
| Upload local media | `POST /api/external/v1/assets/direct-upload-tickets` -> OSS form -> `POST /api/external/v1/assets/objects/confirm` | ticket: `legacyPrefix`, `fileName`; confirm: `objectKey`, `assetKind` |
| Read private media | `GET /api/external/v1/assets/read-url` | `objectKey` or `legacyPublicPath` |
| Image generation | `POST /api/external/v1/editor/images/generations` | `prompt` |
| Image edit/redraw | `POST /api/external/v1/editor/images/edits` | `prompt`, `sourceImageSrc` |
| Icon spritesheet | `POST /api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` |
| UI asset extraction | `POST /api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize`; use `assetFolderId` for library folder |
| Character animation | `POST /api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` |
| Video generation | `POST /api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` |
| Sound effect | `POST /api/external/v1/editor/audios/sound-effects/generations` | `prompt`, `duration` |
| Background music | `POST /api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` |
## Art Spec Interface
Maintain one current art spec per conversation. A compact spec is enough:
```json
{
"assetType": "character | background | prop | ui | icon | animation | video | audio",
"subject": "要生成的主体",
"style": "画风/材质/时代/参考风格",
"palette": "主色与禁用色",
"composition": "构图、镜头、姿态或布局",
"format": "比例、尺寸、分辨率、帧数、时长",
"constraints": "必须保留/禁止出现/透明或绿幕要求",
"references": ["objectKey 或本地路径说明"]
}
```
For a first spec, infer fields from the user's words and ask only for missing fields that block the selected API. Examples: character animation needs source image/layer, dimensions, motion, ratio, frame count, and duration; UI extraction needs source design image plus target density; icon spritesheet needs reference image and icon descriptions. After a spec exists, reuse it for later assets unless the user changes style, subject family, palette, format, or constraints.
## API Key
The external OpenAPI uses:
```text
Authorization: Bearer <tnr_sk_...>
```
The OpenAPI JSON endpoint is public; every other external endpoint requires the Bearer API Key.
Use this fixed production base URL:
```text
https://www.genarrative.world/
```
Guide the user to create a key from the logged-in product UI under `开发者 API Key`. The raw key is shown only once; never ask the user to paste it into chat. Tell them to store it in this local private JSON file, outside the repository:
```text
~/.config/genarrative/external-editor-api.json
```
```json
{
"apiKey": "tnr_sk_..."
}
```
Set the file readable only by the current user where possible: `chmod 600 ~/.config/genarrative/external-editor-api.json`. Do not use environment variables for this API.
Smoke test by reading the JSON file, without printing the key:
```bash
api_key="$(node -e 'const fs=require("fs"); const p=process.argv[1]; const c=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(c.apiKey || "");' "$HOME/.config/genarrative/external-editor-api.json")"
curl -fsS "https://www.genarrative.world/api/external/v1/editor/projects" \
-H "Authorization: Bearer $api_key"
```
For generated client code, read `apiKey` from the JSON file, fail with a clear missing-config error, and redact keys in logs.
Python smoke without printing the key:
```bash
python3 .codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py list-projects
```
## Request Patterns
For Python callers, prefer:
```python
from genarrative_external_api import GenarrativeExternalClient
client = GenarrativeExternalClient()
session = client.prepare_canvas_session("新画板")
art_spec = {
"assetType": "background",
"subject": "幻想森林主视觉",
"style": "手绘游戏概念图",
"palette": "翡翠绿、金色光斑,避免低饱和灰",
"composition": "16:9 横版,中心留出角色站位",
"format": "16:9, 1K",
"constraints": "无文字、无 UI 按钮",
"references": [],
}
client.generate_image(
"生成幻想森林背景",
canvasSession=session,
assetLabel="森林背景",
aspectRatio="16:9",
imageSize="1K",
artSpec=art_spec,
)
```
Use the helper directly from this skill path, or copy it into the caller's project. Do not change the fixed base URL or move the API Key into environment variables.
Use this shared base:
```bash
api="https://www.genarrative.world"
credentials_file="$HOME/.config/genarrative/external-editor-api.json"
api_key="$(node -e 'const fs=require("fs"); const p=process.argv[1]; const c=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(c.apiKey || "");' "$credentials_file")"
auth=(-H "Authorization: Bearer $api_key")
json=(-H "Content-Type: application/json")
```
Create a project:
```bash
curl -fsS "$api/api/external/v1/editor/projects" \
"${auth[@]}" "${json[@]}" \
-d '{"title":"新画板"}'
```
Generate an image and save it into both the canvas and the asset-library folder:
```json
{
"prompt": "一张横版幻想森林背景,适合游戏主视觉",
"kind": "spec",
"aspectRatio": "16:9",
"imageSize": "1K",
"projectId": "<projectId>",
"assetFolderId": "<assetFolderId>",
"assetLabel": "森林背景",
"generationInputs": {
"artSpec": {
"assetType": "background",
"style": "手绘游戏概念图"
}
},
"canvasCompletion": {
"title": "森林背景",
"placeholder": {
"x": 0,
"y": 0,
"width": 1024,
"height": 576,
"originalWidth": 1024,
"originalHeight": 576
}
}
}
```
Then call `POST /api/external/v1/editor/images/generations`.
For direct HTTP/curl, create or find the folder first with `GET /api/external/v1/editor/assets/library` and `POST /api/external/v1/editor/assets/folders`. The folder label should match the canvas name.
## Reference Images
When the user provides a local reference image path/file, upload it first; do not ask the user to convert it to base64.
Python helper path:
```python
from genarrative_external_api import GenarrativeExternalClient
client = GenarrativeExternalClient()
session = client.prepare_canvas_session("新画板")
ref = client.upload_reference_image("/path/to/reference.png")
client.generate_image(
"基于参考图生成一张 16:9 游戏背景",
canvasSession=session,
assetLabel="参考图背景",
aspectRatio="16:9",
imageSize="1K",
referenceImageSrcs=[ref["objectKey"]],
)
```
Use the normal upload flow with:
```json
{
"legacyPrefix": "generated-character-drafts",
"pathSegments": ["editor", "external-editor-references"],
"fileName": "<original-file-name>",
"contentType": "image/png",
"access": "private"
}
```
After OSS form upload, confirm the object with `assetKind: "editor_reference_image"`. Put the returned `objectKey` into the generation request:
- image generation: `referenceImageSrcs`
- image edit/redraw: `sourceImageSrc`; extra references go in `referenceImageSrcs`
- icon spritesheet: `referenceImageSrc`
- UI asset extraction: `sourceImageSrc`; extra references go in `referenceImageSrcs`
- character animation: `sourceImageSrc`
- video generation image references: `referenceImageSrcs`
Use `signedUrl` only for display/download. For generation requests, use `objectKey`, project resource ID, asset ID, public URL, or Data URL as the endpoint allows; prefer uploaded `objectKey` for local/private reference images.
OSS form upload shape, using the ticket response saved as `ticket.json`. The default response has `upload`; if the caller explicitly requested the API response envelope, use `data.upload`:
```bash
node - <<'NODE' ticket.json /path/to/reference.png
const fs = require('fs');
const path = require('path');
(async () => {
const body = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const ticket = body.upload || body.data?.upload;
if (!ticket) throw new Error('Upload ticket response missing upload payload');
const filePath = process.argv[3];
const form = new FormData();
for (const [key, value] of Object.entries(ticket.formFields)) {
if (value != null) form.append(key, value);
}
const bytes = fs.readFileSync(filePath);
form.append(
'file',
new Blob([bytes], { type: ticket.contentType || 'application/octet-stream' }),
path.basename(filePath),
);
const response = await fetch(ticket.host, { method: 'POST', body: form });
if (!response.ok) {
throw new Error(`OSS upload failed: ${response.status} ${await response.text()}`);
}
})().catch((error) => {
console.error(error.message);
process.exit(1);
});
NODE
```
Then confirm with `contentLength`:
```json
{
"objectKey": "<ticket upload.objectKey>",
"contentType": "image/png",
"contentLength": 12345,
"assetKind": "editor_reference_image",
"accessPolicy": "private"
}
```
`contentLength` is a JSON number from the local file byte size, not a quoted string.
For character animation from an uploaded local image, set:
```json
{
"sourceLayerId": "external-reference-hero",
"sourceImageSrc": "<uploaded objectKey>",
"sourceWidth": 720,
"sourceHeight": 1280,
"promptText": "让角色自然呼吸并轻微转身",
"resolution": "720p",
"ratio": "9:16",
"frameCount": 40,
"durationSeconds": 5,
"model": "seedance2.0-fast"
}
```
Use an existing canvas layer ID when the image came from a project layer. If it came only from a local upload, derive a stable synthetic `sourceLayerId` from the file name, for example `external-reference-hero`. Read `sourceWidth` and `sourceHeight` from the actual image before upload; ask the user only if the dimensions cannot be determined.
The helper uses a 420 second timeout for generation calls, including character animation and video. Direct HTTP clients should not use a 70 second request timeout for animation.
Character animation currently returns canvas completion data but not a direct `asset` payload. To keep the "canvas + asset library" invariant, call `client.animate_character(..., canvasSession=session, canvasTitle="...")`; the helper creates a library asset from the first returned frame in the session folder after the animation call succeeds.
For video generation, always include `mode: "std"`. When using image/video/audio references, default to `model: "seedance2.0-fast"` unless the user asks for another listed model, because reference media support is limited to the Seedance 2.0 family.
For image edit/redraw that should replace an existing canvas layer, pass `projectId` and `targetLayerId`. If the user instead gives an explicit `canvasCompletion`, let that placement win.
For sound effects and BGM, `assetFolderId` and `assetLabel` can write the generated audio to the account asset library, same as image/video generation.
## Guardrails
- Do not invent endpoints outside the OpenAPI, especially internal worker or runtime task-list routes.
- Do not omit canvas/library context for generation. New generated assets should enter both the canvas and the asset-library folder named after the canvas.
- Do not put API Keys in repository files, generated project files, command history snippets with literal secrets, logs, docs, commits, or screenshots. The only default storage is the user's local private JSON credentials file.
- Do not use account JWT endpoints as the default external integration path. The profile API can create/revoke keys for logged-in product users, but it is not part of the external editor OpenAPI.
- When an endpoint returns `project`, `resource`, or `asset`, treat those as the authoritative updated project/resource/asset snapshots.
## Resources
- `references/api-selection.md`: intent routing and required-field cheat sheet.
- `scripts/genarrative_external_api.py`: stdlib Python helper for OpenAPI fetch, API Key loading, local reference upload, object confirm, project/canvas calls, and generation requests.
@@ -0,0 +1,6 @@
interface:
display_name: "Genarrative External Editor API"
short_description: "Auto-route canvas API generation"
default_prompt: "Use $genarrative-external-editor-api to prepare a canvas session, infer the right API, and generate assets into the canvas and library."
policy:
allow_implicit_invocation: true
@@ -0,0 +1,194 @@
# External Editor API Routing
Source of truth: `docs/openapi/genarrative-external-v1.openapi.json`.
## Base
- Fixed base URL: `https://www.genarrative.world/`.
- Public contract: `GET /api/external/v1/openapi.json`.
- Authenticated calls: `Authorization: Bearer <tnr_sk_...>`.
- Default credentials file: `~/.config/genarrative/external-editor-api.json` with an `apiKey` string.
- Generation clients should allow long-running responses. Use at least 420 seconds for character animation and video; 70 seconds is too short for animation.
## Canvas Session and Art Spec
At the start of a new conversation, ask for a canvas name before the first generation call unless the user already supplied `projectId` and `assetFolderId`. Create or reuse:
1. `POST /api/external/v1/editor/projects` with `title` = canvas name.
2. `GET /api/external/v1/editor/assets/library`; if no folder has the same label, `POST /api/external/v1/editor/assets/folders` with `label` = canvas name.
3. Keep `canvasName`, `projectId`, `assetFolderId`, and the current art spec in conversation state.
Before generating art assets, normalize the user's request into a current art spec with `assetType`, `subject`, `style`, `palette`, `composition`, `format`, `constraints`, and `references`. Ask follow-up questions only for missing fields that block the selected endpoint. Reuse the current spec automatically when the user asks for another asset without changing style/spec requirements. Put the spec in `generationInputs.artSpec` and summarize it in the prompt when useful.
## Intent Routing
Infer the endpoint from the user's description. Do not present this as a menu unless the request is genuinely ambiguous.
| User says | Route |
| --- | --- |
| "生成图片", "生图", "做一张背景/角色/宣发图" | `POST /api/external/v1/editor/images/generations` |
| "重绘", "调整这张图", "基于这张图修改" | `POST /api/external/v1/editor/images/edits` |
| "用这张参考图", "参考本地图片生成", "基于本地图做图" | Upload local image first, then pass returned `objectKey` into the generation/edit reference field |
| "按规范图生成图标", "拆图标" | `POST /api/external/v1/editor/icon-spritesheets/generations` |
| "从 UI 设计图提取素材" | `POST /api/external/v1/editor/ui-designs/assets/extractions` |
| "让角色动起来", "生成角色动画帧" | `POST /api/external/v1/editor/character-animations/generations` |
| "生成视频" | `POST /api/external/v1/editor/videos/generations` |
| "生成音效" | `POST /api/external/v1/editor/audios/sound-effects/generations` |
| "生成背景音乐/BGM" | `POST /api/external/v1/editor/audios/background-music/generations` |
| "上传本地素材/图片/音频/视频" | Upload flow: direct upload ticket -> OSS form upload -> object confirm |
| "保存画板布局" | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` |
| "创建/读取/删除画板项目" | Project endpoints |
| "素材库/文件夹/素材记录" | Asset library endpoints |
| "读取私有素材/拿可访问链接" | `GET /api/external/v1/assets/read-url` |
Ask a follow-up only when two routes could both be correct and produce different artifacts, for example "处理这张图" without saying edit, extract UI assets, or use it as a reference for new generation.
## Endpoint Map
| User intent | Endpoint | Minimum request |
| --- | --- | --- |
| Read contract | `GET /api/external/v1/openapi.json` | No auth required |
| List projects | `GET /api/external/v1/editor/projects` | API Key |
| Create project | `POST /api/external/v1/editor/projects` | Optional `title` |
| Load recent project | `GET /api/external/v1/editor/projects/recent` | API Key |
| Get/delete project | `GET` or `DELETE /api/external/v1/editor/projects/{projectId}` | `projectId` |
| Rename project | `PATCH /api/external/v1/editor/projects/{projectId}/metadata` | `title` |
| Save canvas layout | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers` |
| Add project resource | `POST /api/external/v1/editor/projects/{projectId}/resources` | `imageSrc`, `width`, `height`, `sourceType` |
| Create upload ticket | `POST /api/external/v1/assets/direct-upload-tickets` | `legacyPrefix`, `fileName` |
| Confirm uploaded object | `POST /api/external/v1/assets/objects/confirm` | `objectKey`, `assetKind` |
| Get signed read URL | `GET /api/external/v1/assets/read-url` | `objectKey` or `legacyPublicPath` |
| Read asset library | `GET /api/external/v1/editor/assets/library` | API Key |
| Create/update/delete folder | `POST /api/external/v1/editor/assets/folders`, `PATCH`/`DELETE /api/external/v1/editor/assets/folders/{folderId}` | create: `label`; update: `label` or `collapsed` |
| Create asset record | `POST /api/external/v1/editor/assets` | `folderId`, `label`, `imageSrc`, `width`, `height`, `sourceType` |
| Update/delete asset | `PATCH`/`DELETE /api/external/v1/editor/assets/{assetId}` | update: `label` or `folderId` |
## Generation Endpoints
| User intent | Endpoint | Required fields | Common optional fields |
| --- | --- | --- | --- |
| Generate image/spec/character/UI/publication material | `POST /api/external/v1/editor/images/generations` | `prompt` | `kind`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Edit/redraw image | `POST /api/external/v1/editor/images/edits` | `prompt`, `sourceImageSrc` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `sourceResourceId`, `targetLayerId`, `canvasCompletion` |
| Generate icon spritesheet | `POST /api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` | `referenceImageSrcs`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `canvasCompletion` |
| Extract assets from UI design | `POST /api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` |
| Generate character animation | `POST /api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `canvasCompletion`; then create a library asset from the first returned frame |
| Generate video | `POST /api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| Generate sound effect | `POST /api/external/v1/editor/audios/sound-effects/generations` | `prompt`, `duration` | `model`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Generate background music | `POST /api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` | `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
All generation requests should be placed into both the current canvas and its same-name asset-library folder. For endpoints that support `assetLabel`, pass it. For UI extraction, use `spritesheetLabel`. For icon spritesheet, the folder is enough. For character animation, the endpoint does not return `asset`; after success call `POST /api/external/v1/editor/assets` using the first returned frame as `imageSrc`, the session `assetFolderId`, and `assetKind: "character-animation"`.
## Reference Image Upload
If the user provides a local file as a reference image, run upload before the generation request:
1. `POST /api/external/v1/assets/direct-upload-tickets`.
Use `legacyPrefix: "generated-character-drafts"`, `pathSegments: ["editor", "external-editor-references"]`, original `fileName`, detected image `contentType`, and `access: "private"`.
2. Upload the file to the returned OSS form endpoint with all returned `formFields`.
3. `POST /api/external/v1/assets/objects/confirm` with returned `objectKey`, detected `contentType`, `contentLength` if known, `assetKind: "editor_reference_image"`, and `accessPolicy: "private"`.
4. Use the returned `objectKey` in the actual editor request.
OSS form upload uses `upload.host` and every non-null `upload.formFields` entry, then the file part named `file`. Default responses expose `upload`; envelope responses expose `data.upload`. Save the upload ticket response as `ticket.json`:
```bash
node - <<'NODE' ticket.json /path/to/reference.png
const fs = require('fs');
const path = require('path');
(async () => {
const body = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const ticket = body.upload || body.data?.upload;
if (!ticket) throw new Error('Upload ticket response missing upload payload');
const filePath = process.argv[3];
const form = new FormData();
for (const [key, value] of Object.entries(ticket.formFields)) {
if (value != null) form.append(key, value);
}
const bytes = fs.readFileSync(filePath);
form.append(
'file',
new Blob([bytes], { type: ticket.contentType || 'application/octet-stream' }),
path.basename(filePath),
);
const response = await fetch(ticket.host, { method: 'POST', body: form });
if (!response.ok) {
throw new Error(`OSS upload failed: ${response.status} ${await response.text()}`);
}
})().catch((error) => {
console.error(error.message);
process.exit(1);
});
NODE
```
Field mapping after upload:
| Target API | Put uploaded `objectKey` in |
| --- | --- |
| Image generation | `referenceImageSrcs` |
| Image edit/redraw | `sourceImageSrc`; additional references in `referenceImageSrcs` |
| Icon spritesheet | `referenceImageSrc`; additional style refs in `referenceImageSrcs` |
| UI design extraction | `sourceImageSrc`; additional refs in `referenceImageSrcs` |
| Character animation | `sourceImageSrc` |
| Video generation with image references | `referenceImageSrcs` |
Do not put the signed read URL into generation fields. Signed URLs are for user-visible preview/download; generation fields should use the stable `objectKey` for uploaded private references.
## Common Enums
- Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`.
- Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`.
- Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`.
- Image `imageSize`: `0.5K`, `1K`, `2K`.
- Video `model`: `seedance2.0`, `seedance2.0-fast`, `kling3.0`, `kling3.0-omni`, `veo3.1`, `veo3.1-fast`.
- Video `aspectRatio`: `16:9`, `9:16`, `1:1`, `4:3`, `3:4`, `21:9`.
- Video `resolution`: `480p`, `720p`, `1080p`.
- Video `mode`: always `std`.
- Video `sound`: `on`, `off`.
- Character animation `model`: always `seedance2.0-fast`.
- Character animation `resolution`: `480p`, `720p`; `frameCount`: `32`, `40`, `48`; `durationSeconds`: `4`, `5`, `6`; `ratio`: `same`, `1:1`, `4:3`, `16:9`, `9:16`, `3:4`.
## Local Reference Media Details
- `contentLength` in object confirm is a JSON number from local byte size, not a string.
- For character animation, use an existing project layer ID as `sourceLayerId` when available.
- If the source is only an uploaded local image, derive `sourceLayerId` from the file name, such as `external-reference-hero`, and keep it stable across retries.
- Read `sourceWidth` and `sourceHeight` from the local image. If dimensions cannot be read, ask instead of inventing dimensions.
- UI design extraction uses fixed `aspectRatio: "1:1"`; choose `imageSize: "1K"` for normal/small extractions and `2K` for dense designs.
- Video image/video/audio references are supported only by the Seedance 2.0 family; default referenced-media video requests to `model: "seedance2.0-fast"`, `mode: "std"`, and explicit `sound`.
- Image edit/redraw can pass `targetLayerId` with `projectId` to replace an existing canvas layer when no explicit `canvasCompletion` is supplied.
- Image, edit, video, sound effect, and BGM generation can pass `assetFolderId` and `assetLabel`; response `asset` is the created/updated library record.
- Icon spritesheet and UI extraction can pass `assetFolderId`; UI extraction can also pass `spritesheetLabel`.
## Canvas Completion
Use `canvasCompletion` for generation in this skill so the generated result is written back into the project canvas by the backend.
Required:
```json
{
"title": "素材名称",
"placeholder": {
"x": 0,
"y": 0,
"width": 512,
"height": 512,
"originalWidth": 512,
"originalHeight": 512
}
}
```
`dialogId` is optional. If the response includes `project`, `resource`, or `asset`, use those snapshots instead of reconstructing canvas/resource/library state locally.
## Upload Flow
For a local file that should become a project resource or library asset:
1. `POST /api/external/v1/assets/direct-upload-tickets` with `legacyPrefix`, `fileName`, and optional `contentType`, `access`, `maxSizeBytes`.
2. Submit the file to the returned OSS form endpoint with returned `formFields`.
3. `POST /api/external/v1/assets/objects/confirm` with returned `objectKey` and an `assetKind`.
4. Create a project resource or library asset with the confirmed `assetObjectId`/`objectKey`.
For reading private/generated assets, call `GET /api/external/v1/assets/read-url?objectKey=...` and use the returned `signedUrl`.
+11 -11
View File
@@ -1,11 +1,12 @@
# Server-side OpenAI-compatible LLM endpoint base URL.
LLM_BASE_URL="https://ark.cn-beijing.volces.com/api/v3"
LLM_BASE_URL="https://api.vectorengine.cn/v1"
# Server-side API key used by the local Vite proxy.
# Recommended: set `LLM_API_KEY` or `ARK_API_KEY`.
# Recommended: set `LLM_API_KEY` locally, or use `VECTOR_ENGINE_API_KEY`
# through the Rust api-server proxy.
# Legacy compatibility: `VITE_LLM_API_KEY` is still supported by the proxy,
# but it should not be relied on by browser code.
LLM_API_KEY="YOUR_API_KEY"
LLM_API_KEY=""
# Optional frontend override for the local proxy path.
VITE_LLM_PROXY_BASE_URL="/api/llm"
@@ -116,7 +117,11 @@ WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID="m5z7BkkBhJGbcH0cdDeHaeRU2tViDE
WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE="formal"
# Model name for chat completions.
VITE_LLM_MODEL="doubao-1-5-pro-32k-character-250715"
VITE_LLM_MODEL="gpt-5.4-mini"
GENARRATIVE_LLM_PROVIDER="openai-compatible"
GENARRATIVE_LLM_BASE_URL="https://api.vectorengine.cn/v1"
GENARRATIVE_LLM_API_KEY=""
GENARRATIVE_LLM_MODEL="gpt-5.4-mini"
# Optional: enable upstream web search for RPG story text generation.
RPG_LLM_WEB_SEARCH_ENABLED="true"
@@ -125,13 +130,8 @@ RPG_LLM_WEB_SEARCH_ENABLED="true"
DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1"
DASHSCOPE_API_KEY="YOUR_DASHSCOPE_API_KEY"
# APIMart Responses config for creative-agent text/multimodal understanding.
APIMART_BASE_URL="https://api.apimart.ai/v1"
APIMART_API_KEY="YOUR_APIMART_API_KEY"
APIMART_IMAGE_REQUEST_TIMEOUT_MS="180000"
# VectorEngine GPT-image-2 / Gemini image generation config.
VECTOR_ENGINE_BASE_URL="https://api.vectorengine.ai"
# VectorEngine LLM and GPT-image-2 / Gemini image generation config.
VECTOR_ENGINE_BASE_URL="https://api.vectorengine.cn"
VECTOR_ENGINE_API_KEY=""
VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS="1000000"
+12
View File
@@ -16,6 +16,18 @@ _Avoid_: 在玩法页面内手写上传、参考图、重绘、预览、删除
独立 `/editor` 中可保存、恢复和继续编辑的图片画布工作状态,包含画布视图、图层布局和资源引用;用于多图对比、生成结果衍生和画布级编辑,不替代玩法页面内的单图资产编辑。
_Avoid_: 玩法结果页单图槽位、发布态作品、只存在前端内存里的临时画布
**画布Agent对话**:
图片画布工程右侧的对话式编辑器工具,用户通过自然语言调度画布已有的图片类生成与编辑能力(生成图片、生成角色形象、生成图标素材、生成 UI 设计图、基于附件的图片修改),并可附加画布素材或素材库图片作为参考;对话归属单个图片画布工程,可保存历史、新开会话和软删会话。属于画布域工具,不承接玩法创作、不产出玩法作品或模板,与「表单/图片输入创作工作台」的 Avoid 边界不冲突。
_Avoid_: 对话式玩法创作工作台、绕过模型定价收口的生成入口、把对话消息当作画布布局真相、复用拼图专用 creative-agent 内存会话
**画布Agent会话记录**:
画布Agent对话的持久化形态:SpacetimeDB 表只存会话元数据(会话 ID、所属工程、属主、标题、软删标记、聊天记录 OSS 对象引用、时间戳),完整消息内容以会话粒度 JSON 对象存 OSS,追加消息即整体重写对象。
_Avoid_: api-server 内存会话、消息全文入 SpacetimeDB 表、对话混入工程布局快照、每条消息一个 OSS 对象
**画布Agent对话附件**:
画布Agent对话消息携带的图片参考,统一为画布资源 / 素材库对象引用(resourceId / assetId + 可选 objectKey),单条消息上限 9 张;上传图片若从对话入口进入,必须复用素材库 / 画布资源登记链路,在上传格未落地前只从已有画布资源和账号素材库选择,不存在只属于对话的第三种图。
_Avoid_: 对话私有图片副本、内嵌 base64 附件、音视频附件
**画布资源**:
图片画布工程中可被一个或多个图层引用的图片资源记录,保存 OSS 对象引用、上传 / 生成来源、提示词、模型、任务和尺寸等资源元数据;同一资源可以在工程布局中出现多次。
_Avoid_: 图层位置、前端 hover / selected 状态、直接内嵌图片二进制
+152
View File
@@ -11,6 +11,14 @@ import type {
AdminDatabaseTableListResponse,
AdminDatabaseTableRowsQuery,
AdminDatabaseTableRowsResponse,
AdminEditorAssetListQuery,
AdminEditorAssetListResponse,
AdminEditorShowcaseAssetResponse,
AdminEditorShowcaseCampaignResponse,
AdminEditorShowcaseDisplayRequest,
AdminEditorShowcaseListQuery,
AdminEditorShowcaseListResponse,
AdminEditorShowcaseReviewRequest,
AdminLoginResponse,
AdminMeResponse,
AdminOverviewResponse,
@@ -19,6 +27,7 @@ import type {
AdminTrackingEventListResponse,
AdminUpdateWorkVisibilityRequest,
AdminUpdateWorkVisibilityResponse,
AdminUpsertEditorShowcaseCampaignRequest,
AdminUpsertProfileInviteCodeRequest,
AdminUpsertProfileRechargeProductRequest,
AdminUpsertProfileRedeemCodeRequest,
@@ -54,6 +63,23 @@ interface AdminRequestOptions {
signal?: AbortSignal;
}
interface AdminAssetReadUrlQuery {
objectKey?: string | null;
legacyPublicPath?: string | null;
expireSeconds?: number | null;
}
export interface AdminAssetReadUrlResponse {
read?: {
objectKey?: string;
signedUrl?: string;
expiresAt?: string;
};
signedUrl?: string;
objectKey?: string;
expiresAt?: string;
}
export class AdminApiError extends Error {
status: number;
code: string;
@@ -293,6 +319,81 @@ export function updateAdminWorkVisibility(
);
}
export function getAdminAssetReadUrl(query: AdminAssetReadUrlQuery) {
return request<AdminAssetReadUrlResponse>(
`/api/assets/read-url${buildAssetReadUrlQuery(query)}`,
);
}
export function listAdminEditorAssets(
token: string,
query: AdminEditorAssetListQuery = {},
) {
return request<AdminEditorAssetListResponse>(
`/admin/api/editor-assets${buildEditorAssetListQuery(query)}`,
{ token },
);
}
export function listAdminEditorShowcaseAssets(
token: string,
query: AdminEditorShowcaseListQuery = {},
) {
return request<AdminEditorShowcaseListResponse>(
`/admin/api/editor-showcase/assets${buildEditorShowcaseListQuery(query)}`,
{ token },
);
}
export function reviewAdminEditorShowcaseAsset(
token: string,
payload: AdminEditorShowcaseReviewRequest,
) {
return request<AdminEditorShowcaseAssetResponse>(
'/admin/api/editor-showcase/assets/review',
{
method: 'POST',
token,
body: payload,
},
);
}
export function updateAdminEditorShowcaseDisplay(
token: string,
payload: AdminEditorShowcaseDisplayRequest,
) {
return request<AdminEditorShowcaseAssetResponse>(
'/admin/api/editor-showcase/assets/display',
{
method: 'POST',
token,
body: payload,
},
);
}
export function getAdminEditorShowcaseCampaign(token: string) {
return request<AdminEditorShowcaseCampaignResponse>(
'/admin/api/editor-showcase/campaign',
{ token },
);
}
export function upsertAdminEditorShowcaseCampaign(
token: string,
payload: AdminUpsertEditorShowcaseCampaignRequest,
) {
return request<AdminEditorShowcaseCampaignResponse>(
'/admin/api/editor-showcase/campaign',
{
method: 'POST',
token,
body: payload,
},
);
}
export function listProfileRedeemCodes(token: string) {
return request<ProfileRedeemCodeAdminListResponse>(
'/admin/api/profile/redeem-codes',
@@ -432,6 +533,29 @@ function buildRequestUrl(path: string) {
return `${ADMIN_API_BASE_URL}${normalizedPath}`;
}
function buildAssetReadUrlQuery(query: AdminAssetReadUrlQuery) {
const params = new URLSearchParams();
const objectKey = query.objectKey?.trim().replace(/^\/+/u, '') ?? '';
const legacyPublicPath = query.legacyPublicPath?.trim() ?? '';
if (objectKey) {
params.set('objectKey', objectKey);
} else if (legacyPublicPath) {
params.set(
'legacyPublicPath',
`/${legacyPublicPath.replace(/^\/+/u, '')}`,
);
}
if (
typeof query.expireSeconds === 'number' &&
Number.isFinite(query.expireSeconds) &&
query.expireSeconds > 0
) {
params.set('expireSeconds', String(Math.floor(query.expireSeconds)));
}
const queryString = params.toString();
return queryString ? `?${queryString}` : '';
}
function buildQueryString(query: AdminTrackingEventListQuery) {
const params = new URLSearchParams();
appendQueryParam(params, 'eventKey', query.eventKey);
@@ -471,6 +595,34 @@ function buildDatabaseTableRowsQuery(query: AdminDatabaseTableRowsQuery) {
return queryString ? `?${queryString}` : '';
}
function buildEditorAssetListQuery(query: AdminEditorAssetListQuery) {
const params = new URLSearchParams();
appendQueryParam(params, 'cursor', query.cursor);
appendQueryParam(params, 'ownerUserId', query.ownerUserId);
appendQueryParam(params, 'keyword', query.keyword);
appendQueryParam(params, 'createdAfter', query.createdAfter);
appendQueryParam(params, 'createdBefore', query.createdBefore);
if (typeof query.limit === 'number' && Number.isFinite(query.limit)) {
params.set('limit', String(query.limit));
}
const queryString = params.toString();
return queryString ? `?${queryString}` : '';
}
function buildEditorShowcaseListQuery(query: AdminEditorShowcaseListQuery) {
const params = new URLSearchParams();
appendQueryParam(params, 'cursor', query.cursor);
appendQueryParam(params, 'ownerUserId', query.ownerUserId);
appendQueryParam(params, 'reviewStatus', query.reviewStatus);
appendQueryParam(params, 'submittedAfter', query.submittedAfter);
appendQueryParam(params, 'submittedBefore', query.submittedBefore);
if (typeof query.limit === 'number' && Number.isFinite(query.limit)) {
params.set('limit', String(query.limit));
}
const queryString = params.toString();
return queryString ? `?${queryString}` : '';
}
function appendQueryParam(
params: URLSearchParams,
key: string,
+141
View File
@@ -346,6 +346,136 @@ export interface AdminUpdateWorkVisibilityResponse {
entry: AdminWorkVisibilityEntryPayload;
}
export interface AdminEditorAssetListQuery {
cursor?: string | null;
ownerUserId?: string | null;
keyword?: string | null;
createdAfter?: string | null;
createdBefore?: string | null;
limit?: number | null;
}
export interface AdminEditorAssetPayload {
assetId: string;
ownerUserId: string;
authorDisplayName?: string | null;
authorPublicUserCode?: string | null;
folderId: string;
label: string;
assetObjectId?: string | null;
imageSrc: string;
objectKey?: string | null;
width: number;
height: number;
sourceType: string;
prompt?: string | null;
actualPrompt?: string | null;
model?: string | null;
provider?: string | null;
taskId?: string | null;
assetKind?: string | null;
generationInputs?: Record<string, unknown> | null;
sourceResourceId?: string | null;
thumbnailSrc?: string | null;
generationCostMudPoints: number;
createdAt: string;
updatedAt: string;
}
export interface AdminEditorAssetListResponse {
entries: AdminEditorAssetPayload[];
nextCursor?: string | null;
}
export interface AdminEditorShowcaseListQuery {
cursor?: string | null;
ownerUserId?: string | null;
reviewStatus?: string | null;
submittedAfter?: string | null;
submittedBefore?: string | null;
limit?: number | null;
}
export interface AdminEditorShowcaseAssetPayload {
showcaseId: string;
assetId: string;
ownerUserId: string;
authorDisplayName?: string | null;
authorPublicUserCode?: string | null;
label: string;
imageSrc: string;
objectKey?: string | null;
width: number;
height: number;
prompt?: string | null;
actualPrompt?: string | null;
model?: string | null;
provider?: string | null;
taskId?: string | null;
assetKind?: string | null;
generationInputs?: Record<string, unknown> | null;
generationCostMudPoints: number;
refundMudPoints: number;
reviewStatus: 'pending' | 'approved' | 'rejected' | string;
displayEnabled: boolean;
likeCount: number;
assetDeletedWhilePending: boolean;
reviewedByAdminUserId?: string | null;
reviewNote?: string | null;
refundLedgerId?: string | null;
refundCompletedAt?: string | null;
submittedAt: string;
reviewedAt?: string | null;
approvedAt?: string | null;
rejectedAt?: string | null;
updatedAt: string;
showcaseCategory?: string | null;
}
export interface AdminEditorShowcaseListResponse {
entries: AdminEditorShowcaseAssetPayload[];
nextCursor?: string | null;
}
export interface AdminEditorShowcaseReviewRequest {
showcaseId: string;
reviewStatus: 'approved' | 'rejected';
reviewNote?: string | null;
}
export interface AdminEditorShowcaseDisplayRequest {
showcaseId: string;
displayEnabled: boolean;
showcaseCategory?: string | null;
}
export interface AdminEditorShowcaseAssetResponse {
entry: AdminEditorShowcaseAssetPayload;
}
export interface AdminEditorShowcaseCampaignPayload {
enabled: boolean;
title: string;
imageSrc: string;
prompt: string;
author: string;
costText: string;
updatedAt?: string;
}
export interface AdminEditorShowcaseCampaignResponse {
campaign?: AdminEditorShowcaseCampaignPayload | null;
}
export interface AdminUpsertEditorShowcaseCampaignRequest {
enabled: boolean;
title: string;
imageSrc: string;
prompt: string;
author: string;
costText: string;
}
export interface AdminUpsertProfileRedeemCodeRequest {
code: string;
mode: ProfileRedeemCodeMode;
@@ -416,8 +546,18 @@ export interface ProfileRedeemCodeAdminResponse {
updatedAt: string;
}
export interface ProfileCodeOperationAdminResponse {
operationId: string;
codeKind: 'redeem' | 'invite' | string;
code: string;
action: 'create' | 'update' | 'disable' | string;
operatorUserId: string;
createdAt: string;
}
export interface ProfileRedeemCodeAdminListResponse {
entries: ProfileRedeemCodeAdminResponse[];
operations: ProfileCodeOperationAdminResponse[];
}
export interface ProfileInviteCodeAdminResponse {
@@ -433,6 +573,7 @@ export interface ProfileInviteCodeAdminResponse {
export interface ProfileInviteCodeAdminListResponse {
entries: ProfileInviteCodeAdminResponse[];
operations: ProfileCodeOperationAdminResponse[];
}
export interface ProfileTaskConfigAdminResponse {
+14 -15
View File
@@ -8,9 +8,7 @@ import {
} from '../api/adminApiClient';
import type {
AdminSessionPayload,
ProfileInviteCodeAdminResponse,
ProfileRechargeProductConfigAdminResponse,
ProfileRedeemCodeAdminResponse,
ProfileTaskConfigAdminResponse,
ProfileWalletConfigAdminResponse,
} from '../api/adminApiTypes';
@@ -26,6 +24,8 @@ import {AdminDatabaseTablesPage} from '../pages/AdminDatabaseTablesPage';
import {AdminInviteCodePage} from '../pages/AdminInviteCodePage';
import {AdminLoginPage} from '../pages/AdminLoginPage';
import {AdminEditorGenerationPricingPage} from '../pages/AdminEditorGenerationPricingPage';
import {AdminEditorAssetQueryPage} from '../pages/AdminEditorAssetQueryPage';
import {AdminEditorShowcaseReviewPage} from '../pages/AdminEditorShowcaseReviewPage';
import {AdminOverviewPage} from '../pages/AdminOverviewPage';
import {AdminProfileWalletConfigPage} from '../pages/AdminProfileWalletConfigPage';
import {AdminRechargeProductPage} from '../pages/AdminRechargeProductPage';
@@ -47,11 +47,6 @@ export function AdminApp() {
resolveAdminRoute(window.location.hash),
);
const [loginNotice, setLoginNotice] = useState('');
// 兑换码页会随页签切换卸载,最近操作记录需要放在会话层保留。
const [redeemResult, setRedeemResult] =
useState<ProfileRedeemCodeAdminResponse | null>(null);
const [inviteResult, setInviteResult] =
useState<ProfileInviteCodeAdminResponse | null>(null);
const [taskConfigResult, setTaskConfigResult] =
useState<ProfileTaskConfigAdminResponse | null>(null);
const [profileWalletConfigResult, setProfileWalletConfigResult] =
@@ -63,8 +58,6 @@ export function AdminApp() {
clearStoredAdminToken();
setToken('');
setAdmin(null);
setRedeemResult(null);
setInviteResult(null);
setTaskConfigResult(null);
setProfileWalletConfigResult(null);
setRechargeProductResult(null);
@@ -134,8 +127,6 @@ export function AdminApp() {
setStoredAdminToken(response.token);
setToken(response.token);
setAdmin(response.admin);
setRedeemResult(null);
setInviteResult(null);
setTaskConfigResult(null);
setProfileWalletConfigResult(null);
setRechargeProductResult(null);
@@ -197,18 +188,14 @@ export function AdminApp() {
) : null}
{routeId === 'redeem' ? (
<AdminRedeemCodePage
result={redeemResult}
token={token}
onUnauthorized={handleUnauthorized}
onResultChange={setRedeemResult}
/>
) : null}
{routeId === 'invite' ? (
<AdminInviteCodePage
result={inviteResult}
token={token}
onUnauthorized={handleUnauthorized}
onResultChange={setInviteResult}
/>
) : null}
{routeId === 'creation-announcement' ? (
@@ -260,6 +247,18 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'editor-showcase' ? (
<AdminEditorShowcaseReviewPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'editor-assets' ? (
<AdminEditorAssetQueryPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
</AdminShell>
);
}
+4
View File
@@ -7,6 +7,8 @@ import {
LogOut,
Megaphone,
Eye,
Images,
Star,
WalletCards,
ShieldCheck,
ListChecks,
@@ -42,6 +44,8 @@ const routeIcons = {
tasks: ListChecks,
'recharge-products': BadgeDollarSign,
'editor-generation-pricing': Coins,
'editor-showcase': Star,
'editor-assets': Images,
'creation-announcement': Megaphone,
'creation-entry': SlidersHorizontal,
'work-visibility': Eye,
@@ -39,3 +39,23 @@ test('后台模型定价路由可通过导航和 hash 访问', () => {
'#editor-generation-pricing',
);
});
test('后台素材查询路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'editor-assets',
label: '素材查询',
hash: '#editor-assets',
});
expect(resolveAdminRoute('#editor-assets')).toBe('editor-assets');
expect(routeHash('editor-assets')).toBe('#editor-assets');
});
test('后台精选审核路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'editor-showcase',
label: '精选审核',
hash: '#editor-showcase',
});
expect(resolveAdminRoute('#editor-showcase')).toBe('editor-showcase');
expect(routeHash('editor-showcase')).toBe('#editor-showcase');
});
+4
View File
@@ -11,6 +11,8 @@ export type AdminRouteId =
| 'tasks'
| 'recharge-products'
| 'editor-generation-pricing'
| 'editor-showcase'
| 'editor-assets'
| 'creation-announcement'
| 'creation-entry'
| 'work-visibility';
@@ -34,6 +36,8 @@ export const adminRoutes: AdminRouteDefinition[] = [
{id: 'tasks', label: '任务配置', hash: '#tasks'},
{id: 'recharge-products', label: '充值商品', hash: '#recharge-products'},
{id: 'editor-generation-pricing', label: '模型定价', hash: '#editor-generation-pricing'},
{id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase'},
{id: 'editor-assets', label: '素材查询', hash: '#editor-assets'},
{id: 'creation-announcement', label: '入口公告', hash: '#creation-announcement'},
{id: 'creation-entry', label: '入口开关', hash: '#creation-entry'},
{id: 'work-visibility', label: '作品可见性', hash: '#work-visibility'},
@@ -703,6 +703,8 @@ const databaseTableColumnLabelMap: Record<string, string> = {
event_id: '事件ID',
event_key: '事件键',
event_title: '事件名称',
operation_id: '操作ID',
code_kind: '码类型',
scope_kind: '范围类型',
scope_id: '范围ID',
day_key: '日期键',
@@ -811,6 +813,7 @@ const databaseTableColumnLabelMap: Record<string, string> = {
record_id: '记录ID',
created_by: '创建人',
updated_by: '更新人',
operator_user_id: '操作人ID',
total_count: '总数',
max_uses: '最大使用次数',
global_used_count: '全局使用次数',
@@ -837,6 +840,8 @@ const databaseTableColumnDescriptionMap: Record<string, string> = {
event_id: '当前埋点事件的唯一标识',
event_key: '埋点事件键',
event_title: '埋点事件展示名称',
operation_id: '后台操作记录的唯一标识',
code_kind: '码类型,redeem 表示兑换码,invite 表示邀请码',
scope_kind: '埋点统计范围类型',
scope_id: '埋点统计范围标识',
day_key: '按天聚合时使用的日期键',
@@ -943,6 +948,7 @@ const databaseTableColumnDescriptionMap: Record<string, string> = {
record_id: '记录标识',
created_by: '创建该记录的主体',
updated_by: '最后更新该记录的主体',
operator_user_id: '执行后台操作的用户标识',
total_count: '累计总数',
max_uses: '允许的最大使用次数',
global_used_count: '当前已使用次数',
@@ -1188,6 +1194,7 @@ const databaseTableLabelMap: Record<string, string> = {
profile_task_reward_claim: '个人任务领奖',
profile_redeem_code: '兑换码',
profile_redeem_code_usage: '兑换码使用记录',
profile_code_operation: '码操作记录',
profile_invite_code: '邀请码',
profile_referral_relation: '邀请关系',
profile_played_world: '已玩世界',
@@ -1269,6 +1276,7 @@ const databaseTableDescriptionMap: Record<string, string> = {
profile_task_reward_claim: '个人任务领奖记录表',
profile_redeem_code: '运营兑换码表',
profile_redeem_code_usage: '兑换码使用记录表',
profile_code_operation: '兑换码/邀请码后台操作记录表',
profile_invite_code: '用户邀请中心邀请码表',
profile_referral_relation: '邀请关系记录表',
profile_played_world: '用户已玩世界记录表',
@@ -0,0 +1,200 @@
/* @vitest-environment jsdom */
import {fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {beforeEach, expect, test, vi} from 'vitest';
import {
getAdminAssetReadUrl,
listAdminEditorAssets,
} from '../api/adminApiClient';
import type {AdminEditorAssetPayload} from '../api/adminApiTypes';
import {AdminEditorAssetQueryPage} from './AdminEditorAssetQueryPage';
vi.mock('../api/adminApiClient', () => ({
getAdminAssetReadUrl: vi.fn(),
listAdminEditorAssets: vi.fn(),
}));
const generatedAsset: AdminEditorAssetPayload = {
assetId: 'asset-1',
ownerUserId: 'user-1',
authorDisplayName: '作者昵称',
authorPublicUserCode: 'SY-00000042',
folderId: 'folder-1',
label: '角色形象 1',
assetObjectId: 'asset-object-1',
imageSrc: '/generated-character-drafts/editor/spec.png',
objectKey: 'generated-character-drafts/editor/spec.png',
width: 1024,
height: 1024,
sourceType: 'generated',
prompt: '完整提示词内容',
actualPrompt: null,
model: 'gpt-image-2',
provider: 'character',
taskId: 'task-1',
assetKind: 'character',
generationInputs: {style: 'clay'},
sourceResourceId: 'resource-1',
thumbnailSrc: null,
generationCostMudPoints: 12,
createdAt: '2026-07-04T10:00:00Z',
updatedAt: '2026-07-04T10:00:00Z',
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(listAdminEditorAssets).mockResolvedValue({
entries: [generatedAsset],
nextCursor: null,
});
vi.mocked(getAdminAssetReadUrl).mockResolvedValue({
read: {
objectKey: 'generated-character-drafts/editor/spec.png',
signedUrl:
'https://signed.example.com/generated-character-drafts/editor/spec.png',
expiresAt: '2026-07-04T11:00:00Z',
},
});
});
test('后台素材查询展示作者昵称和陶泥号', async () => {
render(
<AdminEditorAssetQueryPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
expect(await screen.findByText('作者昵称')).toBeTruthy();
expect(screen.getByText('SY-00000042')).toBeTruthy();
expect(screen.queryByText('user-1')).toBeNull();
});
test('后台素材查询按用户、搜索和时间调用查询接口', async () => {
render(
<AdminEditorAssetQueryPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByRole('img', {name: '素材:角色形象 1'});
fireEvent.change(screen.getByLabelText('用户 ID'), {
target: {value: 'user-1'},
});
fireEvent.change(screen.getByLabelText('搜索'), {
target: {value: '陶泥角色'},
});
fireEvent.change(screen.getByLabelText('开始时间'), {
target: {value: '2026-07-01'},
});
fireEvent.change(screen.getByLabelText('结束时间'), {
target: {value: '2026-07-04'},
});
await waitFor(() => {
expect(listAdminEditorAssets).toHaveBeenLastCalledWith('admin-token', {
ownerUserId: 'user-1',
keyword: '陶泥角色',
createdAfter: '2026-07-01T00:00:00+08:00',
createdBefore: '2026-07-04T23:59:59.999+08:00',
limit: 80,
});
});
});
test('后台素材查询不展示分类筛选和分类列', async () => {
render(
<AdminEditorAssetQueryPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
expect(await screen.findByText('角色形象 1')).toBeTruthy();
expect(screen.queryByLabelText('分类')).toBeNull();
expect(screen.queryByRole('columnheader', {name: '分类'})).toBeNull();
});
test('后台素材查询缩略图使用 objectKey 换签后展示', async () => {
render(
<AdminEditorAssetQueryPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
const image = await screen.findByRole('img', {name: '素材:角色形象 1'});
await waitFor(() => {
expect(image.getAttribute('src')).toBe(
'https://signed.example.com/generated-character-drafts/editor/spec.png',
);
});
expect(getAdminAssetReadUrl).toHaveBeenCalledWith({
objectKey: 'generated-character-drafts/editor/spec.png',
expireSeconds: 300,
});
});
test('后台素材查询格式化微秒时间文本', async () => {
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
entries: [
{
...generatedAsset,
createdAt: '1783231493.573727Z',
updatedAt: '1783231493.573727Z',
},
],
nextCursor: null,
});
render(
<AdminEditorAssetQueryPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
expect(await screen.findByText('角色形象 1')).toBeTruthy();
expect(screen.queryByText('1783231493.573727Z')).toBeNull();
});
test('后台素材查询可查看素材详情', async () => {
render(
<AdminEditorAssetQueryPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
fireEvent.click(await screen.findByRole('button', {name: '详情'}));
const dialog = screen.getByRole('dialog', {name: '素材详情'});
expect(within(dialog).getByText('asset-1')).toBeTruthy();
expect(within(dialog).getByText('1024 x 1024')).toBeTruthy();
expect(within(dialog).getByText('12 泥点')).toBeTruthy();
expect(within(dialog).getByText(/"style": "clay"/u)).toBeTruthy();
});
test('后台素材查询可打开弹窗查看完整提示词', async () => {
render(
<AdminEditorAssetQueryPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
fireEvent.click(await screen.findByRole('button', {name: '完整提示词内容'}));
const dialog = screen.getByRole('dialog', {name: '完整提示词'});
expect(within(dialog).getByText('完整提示词内容')).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', {name: '关闭完整提示词'}));
await waitFor(() => {
expect(screen.queryByRole('dialog', {name: '完整提示词'})).toBeNull();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,323 @@
/* @vitest-environment jsdom */
import {fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {beforeEach, expect, test, vi} from 'vitest';
import {
getAdminAssetReadUrl,
getAdminEditorShowcaseCampaign,
listAdminEditorShowcaseAssets,
reviewAdminEditorShowcaseAsset,
updateAdminEditorShowcaseDisplay,
upsertAdminEditorShowcaseCampaign,
} from '../api/adminApiClient';
import type {AdminEditorShowcaseAssetPayload} from '../api/adminApiTypes';
import {AdminEditorShowcaseReviewPage} from './AdminEditorShowcaseReviewPage';
vi.mock('../api/adminApiClient', () => ({
getAdminAssetReadUrl: vi.fn(),
getAdminEditorShowcaseCampaign: vi.fn(),
listAdminEditorShowcaseAssets: vi.fn(),
reviewAdminEditorShowcaseAsset: vi.fn(),
updateAdminEditorShowcaseDisplay: vi.fn(),
upsertAdminEditorShowcaseCampaign: vi.fn(),
}));
const pendingShowcaseAsset: AdminEditorShowcaseAssetPayload = {
showcaseId: 'showcase-1',
assetId: 'asset-1',
ownerUserId: 'user-1',
authorDisplayName: '作者昵称',
authorPublicUserCode: 'SY-00000042',
label: '角色形象 1',
imageSrc: '/generated-character-drafts/editor/spec.png',
objectKey: 'generated-character-drafts/editor/spec.png',
width: 1024,
height: 1024,
prompt: '完整提示词内容',
actualPrompt: null,
model: 'gpt-image-2',
provider: 'character',
taskId: 'task-1',
assetKind: 'character',
generationInputs: {style: 'clay'},
generationCostMudPoints: 12,
refundMudPoints: 6,
reviewStatus: 'pending',
displayEnabled: false,
likeCount: 0,
assetDeletedWhilePending: false,
reviewedByAdminUserId: null,
reviewNote: null,
refundLedgerId: null,
refundCompletedAt: null,
submittedAt: '2026-07-04T10:00:00Z',
reviewedAt: null,
approvedAt: null,
rejectedAt: null,
updatedAt: '2026-07-04T10:00:00Z',
showcaseCategory: null,
};
const approvedShowcaseAsset: AdminEditorShowcaseAssetPayload = {
...pendingShowcaseAsset,
showcaseId: 'showcase-2',
assetId: 'asset-2',
label: '角色形象 2',
reviewStatus: 'approved',
displayEnabled: true,
showcaseCategory: 'characters',
reviewedAt: '2026-07-04T10:10:00Z',
approvedAt: '2026-07-04T10:10:00Z',
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValue({
entries: [pendingShowcaseAsset],
nextCursor: null,
});
vi.mocked(getAdminEditorShowcaseCampaign).mockResolvedValue({
campaign: {
enabled: true,
title: '活动卡',
imageSrc: '/campaign.png',
prompt: '活动提示词',
author: '官方',
costText: '12 泥点',
updatedAt: '2026-07-04T10:00:00Z',
},
});
vi.mocked(getAdminAssetReadUrl).mockResolvedValue({
read: {
objectKey: 'generated-character-drafts/editor/spec.png',
signedUrl: 'https://signed.example.com/spec.png',
expiresAt: '2026-07-04T11:00:00Z',
},
});
vi.mocked(reviewAdminEditorShowcaseAsset).mockResolvedValue({
entry: {
...pendingShowcaseAsset,
reviewStatus: 'approved',
displayEnabled: false,
reviewedAt: '2026-07-04T10:10:00Z',
approvedAt: '2026-07-04T10:10:00Z',
},
});
vi.mocked(updateAdminEditorShowcaseDisplay).mockResolvedValue({
entry: {
...approvedShowcaseAsset,
displayEnabled: false,
},
});
vi.mocked(upsertAdminEditorShowcaseCampaign).mockResolvedValue({
campaign: {
enabled: false,
title: '新活动卡',
imageSrc: '/new-campaign.png',
prompt: '新活动提示词',
author: '官方',
costText: '6 泥点',
updatedAt: '2026-07-04T10:20:00Z',
},
});
});
test('后台精选审核展示待审核素材和活动卡配置', async () => {
render(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
expect(await screen.findByText('作者昵称')).toBeTruthy();
expect(screen.getByText('SY-00000042')).toBeTruthy();
expect(screen.getAllByText('待审核').length).toBeGreaterThanOrEqual(2);
expect(screen.getByText('12 泥点')).toBeTruthy();
expect(await screen.findByDisplayValue('活动卡')).toBeTruthy();
expect(screen.getByLabelText('状态')).toHaveProperty('value', 'pending');
expect(listAdminEditorShowcaseAssets).toHaveBeenCalledWith('admin-token', {
ownerUserId: null,
reviewStatus: 'pending',
submittedAfter: null,
submittedBefore: null,
limit: 80,
});
});
test('后台精选审核格式化微秒时间并显示素材名', async () => {
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({
entries: [
{
...pendingShowcaseAsset,
submittedAt: '1783231493.573727Z',
},
],
nextCursor: null,
});
render(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
expect(await screen.findByText('角色形象 1')).toBeTruthy();
expect(screen.queryByText('showcase-1')).toBeNull();
expect(screen.queryByText('1783231493.573727Z')).toBeNull();
});
test('后台精选审核可以通过素材并查看完整提示词', async () => {
render(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
fireEvent.change(
await screen.findByLabelText('审核备注:角色形象 1'),
{target: {value: '质量通过'}},
);
fireEvent.click(screen.getByRole('button', {name: '通过'}));
await waitFor(() => {
expect(reviewAdminEditorShowcaseAsset).toHaveBeenCalledWith('admin-token', {
showcaseId: 'showcase-1',
reviewStatus: 'approved',
reviewNote: '质量通过',
});
});
fireEvent.click(screen.getByRole('button', {name: '完整提示词内容'}));
const dialog = screen.getByRole('dialog', {name: '完整提示词'});
expect(within(dialog).getByText('完整提示词内容')).toBeTruthy();
});
test('后台精选审核可以切换展示和保存活动卡', async () => {
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({
entries: [approvedShowcaseAsset],
nextCursor: null,
});
render(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
fireEvent.click(await screen.findByRole('button', {name: '隐藏'}));
await waitFor(() => {
expect(updateAdminEditorShowcaseDisplay).toHaveBeenCalledWith(
'admin-token',
{
showcaseId: 'showcase-2',
displayEnabled: false,
showcaseCategory: 'characters',
},
);
});
fireEvent.change(await screen.findByLabelText('标题'), {
target: {value: '新活动卡'},
});
fireEvent.change(screen.getByLabelText('图片地址'), {
target: {value: '/new-campaign.png'},
});
fireEvent.change(screen.getByLabelText('成本文案'), {
target: {value: '6 泥点'},
});
fireEvent.click(screen.getByRole('button', {name: '保存活动卡'}));
await waitFor(() => {
expect(upsertAdminEditorShowcaseCampaign).toHaveBeenCalledWith(
'admin-token',
expect.objectContaining({
title: '新活动卡',
imageSrc: '/new-campaign.png',
costText: '6 泥点',
}),
);
});
});
test('后台精选审核已通过素材可以设置精选分类', async () => {
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({
entries: [
{
...approvedShowcaseAsset,
showcaseCategory: null,
displayEnabled: false,
},
],
nextCursor: null,
});
vi.mocked(updateAdminEditorShowcaseDisplay).mockResolvedValueOnce({
entry: {
...approvedShowcaseAsset,
showcaseCategory: 'ui',
displayEnabled: false,
},
});
render(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
fireEvent.change(await screen.findByLabelText('精选分类:角色形象 2'), {
target: {value: 'ui'},
});
await waitFor(() => {
expect(updateAdminEditorShowcaseDisplay).toHaveBeenCalledWith(
'admin-token',
{
showcaseId: 'showcase-2',
displayEnabled: false,
showcaseCategory: 'ui',
},
);
});
});
test('后台精选审核清空精选分类时自动隐藏素材', async () => {
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({
entries: [approvedShowcaseAsset],
nextCursor: null,
});
vi.mocked(updateAdminEditorShowcaseDisplay).mockResolvedValueOnce({
entry: {
...approvedShowcaseAsset,
showcaseCategory: null,
displayEnabled: false,
},
});
render(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
fireEvent.change(await screen.findByLabelText('精选分类:角色形象 2'), {
target: {value: ''},
});
await waitFor(() => {
expect(updateAdminEditorShowcaseDisplay).toHaveBeenCalledWith(
'admin-token',
{
showcaseId: 'showcase-2',
displayEnabled: false,
showcaseCategory: null,
},
);
});
});
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,7 @@ import {
} from '../api/adminApiClient';
import type {
AdminUpsertProfileInviteCodeRequest,
ProfileCodeOperationAdminResponse,
ProfileInviteCodeAdminResponse,
} from '../api/adminApiTypes';
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
@@ -14,16 +15,12 @@ import {handlePageError} from './pageUtils';
interface AdminInviteCodePageProps {
token: string;
result: ProfileInviteCodeAdminResponse | null;
onUnauthorized: (message?: string) => void;
onResultChange: (result: ProfileInviteCodeAdminResponse) => void;
}
export function AdminInviteCodePage({
token,
result,
onUnauthorized,
onResultChange,
}: AdminInviteCodePageProps) {
const [inviteCode, setInviteCode] = useState('');
const [startsAt, setStartsAt] = useState('');
@@ -33,6 +30,7 @@ export function AdminInviteCodePage({
const [errorMessage, setErrorMessage] = useState('');
const [listErrorMessage, setListErrorMessage] = useState('');
const [entries, setEntries] = useState<ProfileInviteCodeAdminResponse[]>([]);
const [operations, setOperations] = useState<ProfileCodeOperationAdminResponse[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const {confirmWrite, confirmDialog} = useAdminWriteConfirm();
@@ -48,6 +46,7 @@ export function AdminInviteCodePage({
try {
const response = await listProfileInviteCodes(token);
setEntries(response.entries);
setOperations(response.operations ?? []);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setListErrorMessage);
} finally {
@@ -89,9 +88,8 @@ export function AdminInviteCodePage({
expiresAt: expiresAt ? toIsoDateTime(expiresAt) : null,
};
const response = await upsertProfileInviteCode(token, payload);
onResultChange(response);
upsertEntry(response);
fillForm(response);
await refreshInviteCodes();
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
@@ -99,23 +97,6 @@ export function AdminInviteCodePage({
}
}
function upsertEntry(next: ProfileInviteCodeAdminResponse) {
setEntries((current) => {
const rest = current.filter((entry) => entry.inviteCode !== next.inviteCode);
return [...rest, next].sort((left, right) => {
const leftUpdatedAt = Date.parse(left.updatedAt);
const rightUpdatedAt = Date.parse(right.updatedAt);
if (Number.isFinite(leftUpdatedAt) && Number.isFinite(rightUpdatedAt)) {
const updatedCompare = rightUpdatedAt - leftUpdatedAt;
if (updatedCompare !== 0) {
return updatedCompare;
}
}
return left.inviteCode.localeCompare(right.inviteCode);
});
});
}
function fillForm(entry: ProfileInviteCodeAdminResponse) {
setInviteCode(entry.inviteCode);
setStartsAt(toDateTimeLocalValue(entry.startsAt));
@@ -278,42 +259,32 @@ export function AdminInviteCodePage({
<section className="admin-panel admin-result-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{result?.inviteCode ?? '-'}</span>
<h3></h3>
<span>{operations.length}</span>
</div>
{result ? (
<dl className="admin-info-list">
<div>
<dt></dt>
<dd>{result.inviteCode}</dd>
</div>
<div>
<dt></dt>
<dd>{formatValidityWindow(result)}</dd>
</div>
<div>
<dt></dt>
<dd>
<TagList tags={metadataUserTags(result.metadata)} />
</dd>
</div>
<div>
<dt></dt>
<dd>{result.createdAt}</dd>
</div>
<div>
<dt></dt>
<dd>{result.updatedAt}</dd>
</div>
<div>
<dt>Metadata</dt>
<dd>
<pre className="admin-code-block">
{JSON.stringify(result.metadata, null, 2)}
</pre>
</dd>
</div>
</dl>
{operations.length ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-compact">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{operations.map((operation) => (
<tr key={operation.operationId}>
<td>{operationActionLabel(operation.action)}</td>
<td>{operation.code}</td>
<td>{operation.operatorUserId}</td>
<td>{formatDateTime(operation.createdAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="admin-empty-state"></div>
)}
@@ -475,6 +446,19 @@ function formatDateTime(value: string) {
return date.toLocaleString('zh-CN', {hour12: false});
}
function operationActionLabel(action: string) {
if (action === 'create') {
return '新增';
}
if (action === 'update') {
return '更新';
}
if (action === 'disable') {
return '停用';
}
return action;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
@@ -7,6 +7,7 @@ import {
upsertProfileRedeemCode,
} from '../api/adminApiClient';
import type {
ProfileCodeOperationAdminResponse,
ProfileRedeemCodeAdminResponse,
ProfileRedeemCodeMode,
} from '../api/adminApiTypes';
@@ -15,9 +16,7 @@ import {handlePageError, splitLines} from './pageUtils';
interface AdminRedeemCodePageProps {
token: string;
result: ProfileRedeemCodeAdminResponse | null;
onUnauthorized: (message?: string) => void;
onResultChange: (result: ProfileRedeemCodeAdminResponse) => void;
}
const redeemModes: Array<{value: ProfileRedeemCodeMode; label: string}> = [
@@ -28,9 +27,7 @@ const redeemModes: Array<{value: ProfileRedeemCodeMode; label: string}> = [
export function AdminRedeemCodePage({
token,
result,
onUnauthorized,
onResultChange,
}: AdminRedeemCodePageProps) {
const [code, setCode] = useState('');
const [mode, setMode] = useState<ProfileRedeemCodeMode>('public');
@@ -44,6 +41,7 @@ export function AdminRedeemCodePage({
const [disableErrorMessage, setDisableErrorMessage] = useState('');
const [listErrorMessage, setListErrorMessage] = useState('');
const [entries, setEntries] = useState<ProfileRedeemCodeAdminResponse[]>([]);
const [operations, setOperations] = useState<ProfileCodeOperationAdminResponse[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [isDisabling, setIsDisabling] = useState(false);
const [isLoading, setIsLoading] = useState(false);
@@ -60,6 +58,7 @@ export function AdminRedeemCodePage({
try {
const response = await listProfileRedeemCodes(token);
setEntries(response.entries);
setOperations(response.operations ?? []);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setListErrorMessage);
} finally {
@@ -94,9 +93,8 @@ export function AdminRedeemCodePage({
allowedPublicUserCodes:
mode === 'private' ? splitLines(allowedPublicUserCodes) : [],
});
onResultChange(response);
upsertEntry(response);
fillForm(response);
await refreshRedeemCodes();
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
@@ -124,9 +122,8 @@ export function AdminRedeemCodePage({
const response = await disableProfileRedeemCode(token, {
code: disableCode.trim(),
});
onResultChange(response);
upsertEntry(response);
fillForm(response);
await refreshRedeemCodes();
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setDisableErrorMessage);
} finally {
@@ -134,23 +131,6 @@ export function AdminRedeemCodePage({
}
}
function upsertEntry(next: ProfileRedeemCodeAdminResponse) {
setEntries((current) => {
const rest = current.filter((entry) => entry.code !== next.code);
return [...rest, next].sort((left, right) => {
const leftUpdatedAt = Date.parse(left.updatedAt);
const rightUpdatedAt = Date.parse(right.updatedAt);
if (Number.isFinite(leftUpdatedAt) && Number.isFinite(rightUpdatedAt)) {
const updatedCompare = rightUpdatedAt - leftUpdatedAt;
if (updatedCompare !== 0) {
return updatedCompare;
}
}
return left.code.localeCompare(right.code);
});
});
}
function fillForm(entry: ProfileRedeemCodeAdminResponse) {
setCode(entry.code);
setMode(entry.mode);
@@ -354,40 +334,32 @@ export function AdminRedeemCodePage({
<section className="admin-panel admin-result-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{result?.mode ?? '-'}</span>
<h3></h3>
<span>{operations.length}</span>
</div>
{result ? (
<dl className="admin-info-list">
<div>
<dt>Code</dt>
<dd>{result.code}</dd>
</div>
<div>
<dt></dt>
<dd>{result.rewardPoints}</dd>
</div>
<div>
<dt></dt>
<dd>{result.maxUses}</dd>
</div>
<div>
<dt></dt>
<dd>{result.globalUsedCount}</dd>
</div>
<div>
<dt></dt>
<dd>{result.enabled ? '启用' : '停用'}</dd>
</div>
<div>
<dt></dt>
<dd>{result.createdBy}</dd>
</div>
<div>
<dt></dt>
<dd>{result.updatedAt}</dd>
</div>
</dl>
{operations.length ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-compact">
<thead>
<tr>
<th></th>
<th>Code</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{operations.map((operation) => (
<tr key={operation.operationId}>
<td>{operationActionLabel(operation.action)}</td>
<td>{operation.code}</td>
<td>{operation.operatorUserId}</td>
<td>{formatDateTime(operation.createdAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="admin-empty-state"></div>
)}
@@ -407,3 +379,24 @@ function parsePositiveInteger(value: string) {
function redeemModeLabel(value: ProfileRedeemCodeMode) {
return redeemModes.find((item) => item.value === value)?.label ?? value;
}
function operationActionLabel(action: string) {
if (action === 'create') {
return '新增';
}
if (action === 'update') {
return '更新';
}
if (action === 'disable') {
return '停用';
}
return action;
}
function formatDateTime(value: string) {
const date = new Date(value);
if (!Number.isFinite(date.getTime())) {
return value;
}
return date.toLocaleString('zh-CN', {hour12: false});
}
+192
View File
@@ -553,6 +553,49 @@ button:disabled {
gap: 10px;
}
.admin-asset-query-filter-row {
align-items: end;
}
.admin-asset-query-filter-row .admin-field {
min-width: 140px;
}
.admin-asset-query-filter-row .admin-field:last-child {
min-width: 240px;
}
.admin-asset-query-thumb-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
padding: 0;
}
.admin-asset-query-thumb {
border: 1px solid #eaded2;
border-radius: 8px;
width: 72px;
height: 72px;
background: #fffdf9;
object-fit: contain;
object-position: center;
}
.admin-asset-query-thumb-placeholder {
display: block;
}
.admin-asset-query-prompt-text {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-query-action-row {
justify-content: space-between;
}
@@ -905,6 +948,17 @@ button:disabled {
font-size: 12px;
}
.admin-table textarea {
width: min(100%, 220px);
min-height: 64px;
border: 1px solid #dfc8b7;
border-radius: 8px;
color: #3d1f10;
background: #fffdf9;
padding: 8px 10px;
resize: vertical;
}
.admin-muted-text {
color: #a38f80;
}
@@ -939,6 +993,144 @@ button:disabled {
min-width: 1180px;
}
.admin-asset-query-table {
table-layout: fixed;
}
.admin-asset-query-table th:nth-child(1),
.admin-asset-query-table td:nth-child(1) {
width: 10%;
}
.admin-asset-query-table th:nth-child(2),
.admin-asset-query-table td:nth-child(2),
.admin-asset-query-table th:nth-child(3),
.admin-asset-query-table td:nth-child(3) {
width: 14%;
}
.admin-asset-query-table th:nth-child(4),
.admin-asset-query-table td:nth-child(4) {
width: 34%;
}
.admin-asset-query-table th:nth-child(5),
.admin-asset-query-table td:nth-child(5) {
width: 8%;
}
.admin-asset-query-table th:nth-child(6),
.admin-asset-query-table td:nth-child(6) {
width: 10%;
}
.admin-showcase-review-table {
table-layout: fixed;
}
.admin-showcase-review-table th:nth-child(1),
.admin-showcase-review-table td:nth-child(1) {
width: 8%;
text-align: center;
}
.admin-showcase-review-table td:nth-child(1) small {
display: block;
max-width: 100%;
margin-top: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-showcase-review-table td:nth-child(4) select {
width: 100%;
min-width: 0;
}
.admin-showcase-review-table th:nth-child(2),
.admin-showcase-review-table td:nth-child(2),
.admin-showcase-review-table th:nth-child(3),
.admin-showcase-review-table td:nth-child(3) {
width: 13%;
}
.admin-showcase-review-table th:nth-child(4),
.admin-showcase-review-table td:nth-child(4),
.admin-showcase-review-table th:nth-child(5),
.admin-showcase-review-table td:nth-child(5),
.admin-showcase-review-table th:nth-child(7),
.admin-showcase-review-table td:nth-child(7) {
width: 10%;
}
.admin-showcase-review-table th:nth-child(6),
.admin-showcase-review-table td:nth-child(6) {
width: 21%;
}
.admin-showcase-review-table th:nth-child(8),
.admin-showcase-review-table td:nth-child(8) {
width: 15%;
}
.admin-showcase-review-actions {
display: grid;
gap: 8px;
}
.admin-showcase-review-actions input {
min-width: 0;
}
.admin-showcase-campaign-grid {
align-items: end;
}
.admin-showcase-campaign-grid .admin-field {
min-width: 160px;
}
.admin-showcase-campaign-prompt {
min-width: min(100%, 320px);
}
.admin-asset-query-detail-dialog,
.admin-asset-query-prompt-dialog {
width: min(100%, 860px);
}
.admin-asset-query-prompt-dialog .admin-panel-heading > div,
.admin-asset-query-detail-dialog .admin-panel-heading > div {
min-width: 0;
}
.admin-asset-query-prompt-dialog .admin-panel-heading span,
.admin-asset-query-detail-dialog .admin-panel-heading span {
display: block;
max-width: 100%;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-asset-query-prompt-full {
max-height: min(68dvh, 520px);
}
.admin-asset-query-detail-layout {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.admin-asset-query-detail-layout > .admin-asset-query-thumb {
width: 220px;
height: 220px;
}
.admin-database-table {
width: max-content;
min-width: 100%;
+5
View File
@@ -40,6 +40,11 @@ export default defineConfig(({mode}) => {
changeOrigin: true,
secure: false,
},
'/api/assets': {
target: apiTarget,
changeOrigin: true,
secure: false,
},
'/healthz': {
target: apiTarget,
changeOrigin: true,
+2 -2
View File
@@ -49,8 +49,8 @@ GENARRATIVE_SPACETIME_POOL_SIZE=8
GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=45
GENARRATIVE_LLM_PROVIDER=openai-compatible
GENARRATIVE_LLM_BASE_URL=
GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1
GENARRATIVE_LLM_API_KEY=
GENARRATIVE_LLM_MODEL=
GENARRATIVE_LLM_MODEL=gpt-5.4-mini
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=
+2 -6
View File
@@ -57,16 +57,12 @@ GENARRATIVE_SPACETIME_POOL_SIZE=8
GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=45
GENARRATIVE_LLM_PROVIDER=openai-compatible
GENARRATIVE_LLM_BASE_URL=
GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1
GENARRATIVE_LLM_API_KEY=
GENARRATIVE_LLM_MODEL=
GENARRATIVE_LLM_MODEL=gpt-5.4-mini
GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED=false
GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED=false
APIMART_BASE_URL=
APIMART_API_KEY=
APIMART_IMAGE_REQUEST_TIMEOUT_MS=180000
VECTOR_ENGINE_BASE_URL=https://api.vectorengine.cn
VECTOR_ENGINE_API_KEY=
VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1000000
+1 -1
View File
@@ -1,7 +1,7 @@
[Unit]
Description=Genarrative Rust API Server
After=network-online.target spacetimedb.service
Wants=network-online.target
Wants=network-online.target genarrative-external-generation-controller.service
Requires=spacetimedb.service
[Service]
+3
View File
@@ -9,6 +9,7 @@
- [审计与复盘](./audits/README.md):工程审查、文本/乱码审计、专项落地审计。
- [系统设计](./design/README.md):玩法、关系、物品与对话设计。
- [技术方案](./technical/README.md):动画、服务端、外部产品形态拆解。
- [架构决策](./adr/):记录已经接受的跨模块、长期有效架构取舍;对应长期摘要仍应同步到 `docs/project-memory/shared-memory/decision-log.md`
- [规划与优先级](./planning/README.md):当前阶段的迭代排序与落地优先级;创作流程统一总计划见 [【玩法创作】创作流程统一总计划-2026-05-30.md](./planning/%E3%80%90%E7%8E%A9%E6%B3%95%E5%88%9B%E4%BD%9C%E3%80%91%E5%88%9B%E4%BD%9C%E6%B5%81%E7%A8%8B%E7%BB%9F%E4%B8%80%E6%80%BB%E8%AE%A1%E5%88%92-2026-05-30.md)。
- [参考目录](./reference/README.md):脚本/Function 速查入口。
重点补充:RPG 创作与运行时脚本职责地图见 [RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md](./reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md)。
@@ -32,6 +33,8 @@ Expo React Native 移动壳和 Tauri 桌面壳的工程结构、同源 WebView
`/editor/canvas` 图片画布编辑器的画布素材 ZIP 导出能力,入口放在右上角标题栏下载图标内,采用前端 JSZip 打包画布中有效图层引用的上传图、生成图、修改结果和角色动作序列帧;动作图层右键“导出为”提供序列帧 ZIP(含前端生成的 `preview.gif`)与 Spine JSON ZIP 两个二级选项,方案见 [【前端架构】图片画布素材导出方案-2026-06-15.md](./technical/【前端架构】图片画布素材导出方案-2026-06-15.md)。
`/editor/canvas` 右侧画布 Agent 对话面板、会话持久化、SSE 事件、附件与生成落画板例外见 [【编辑器】画布Agent对话面板-2026-07-03.md](./【编辑器】画布Agent对话面板-2026-07-03.md);消息正文存 OSS、元数据进 SpacetimeDB 的取舍见 [【ADR】画布Agent会话消息存OSS-2026-07-03.md](./adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md)。
图片画布生成类面板的模型泥点默认 JSON、运行时 override、后台“模型定价”页面和主站动态下发口径见 [【编辑器】模型定价配置管理方案-2026-06-22.md](./%E3%80%90%E7%BC%96%E8%BE%91%E5%99%A8%E3%80%91%E6%A8%A1%E5%9E%8B%E5%AE%9A%E4%BB%B7%E9%85%8D%E7%BD%AE%E7%AE%A1%E7%90%86%E6%96%B9%E6%A1%88-2026-06-22.md)。
React 组件测试的用户行为、稳定契约、hook / model 分层断言口径,以及避免内部 DOM 探针、图标 class 和完整对象快照式断言的规则见 [【前端测试】React组件测试准则-2026-06-26.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%B5%8B%E8%AF%95%E3%80%91React%E7%BB%84%E4%BB%B6%E6%B5%8B%E8%AF%95%E5%87%86%E5%88%99-2026-06-26.md)。

Some files were not shown because too many files have changed in this diff Show More