Merge remote-tracking branch 'origin/master' into fix/multi-select
Project CI / Native shell tests (pull_request) Successful in 11m34s
Project CI / Frontend tests (pull_request) Failing after 12s
Project CI / Repository checks (pull_request) Failing after 46s
Project CI / Backend tests (pull_request) Failing after 2m37s

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
2026-07-31 16:18:47 +08:00
677 changed files with 413609 additions and 5097 deletions
@@ -36,7 +36,7 @@ Prefer the bundled Python helper for runnable examples: `scripts/genarrative_ext
| 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` |
| Save canvas | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers`, `expectedRevision` |
| 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` |
@@ -115,6 +115,8 @@ python3 .codex/skills/genarrative-external-editor-api/scripts/genarrative_extern
## Request Patterns
For image and icon generation, the request-body top-level `style` field controls deterministic post-processing and is distinct from `generationInputs.artSpec.style`, which describes visual style for prompting. Pass `style="pixelArt"` in Python or `"style": "pixelArt"` in JSON to enable pixel-art snapping on supported generation types; use `"none"` or omit the field otherwise. Verify compatibility and fallback semantics in `references/api-selection.md`.
For Python callers, prefer:
```python
@@ -142,6 +144,20 @@ client.generate_image(
)
```
For a transparent game/UI atlas, call the dedicated helper instead of ordinary image generation:
```python
client.generate_icon_spritesheet(
"editor-resource-current-art-spec",
["蛇头四方向", "直身与四种转角", "尾部四方向", "四类可区分食物"],
canvasSession=session,
assetLabel="贪吃蛇透明图集",
screenColor="auto",
)
```
Pass the registered visual-spec resource ID as `reference_image_src`; do not pass the UI prototype or a local path.
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:
@@ -324,7 +340,19 @@ Character image generation (including character redraw through `kind: "character
- Apply the returned `project` and media snapshots before interpreting optional derivatives: character responses use `resource` / `asset`, while icon spritesheet and UI extraction responses use `spritesheetResource` / `spritesheetAsset`. When `warning.code` is `postprocess-failed-source-preserved`, the saved provider source image is the authoritative main result. Character output has no transparent derivative; icon spritesheet and UI extraction output have neither a transparent spritesheet nor slices. Display `warning.reason` directly, and do not synthesize missing derivatives or restart generation.
- `sliceWarning` is a separate condition used only when transparent spritesheet post-processing succeeded but automatic slicing failed. Keep `sliceWarning.reason` as the original diagnostic and continue using the complete transparent spritesheet; a UI may add context when displaying it, but must not rewrite the stored reason.
- The service contract keeps `warning` and `sliceWarning` mutually exclusive. As defensive handling for a malformed response containing both, treat the general `warning` as authoritative and do not misclassify the source-preserved result as a slicing-only warning.
- `warning` and `sliceWarning` are mutually exclusive only for `postprocess-failed-source-preserved`, because a failed transparent post-process never reaches slicing. Since 2026-07-29 a general `warning` may also come from image-style normalization (`unsupported-image-style`) or pixel-art snapping, and those can coexist with `sliceWarning` in the same response. Display both reasons; do not drop either one and do not misclassify a source-preserved result as a slicing-only warning.
For reusable transparent game/UI sheets, do not substitute ordinary image generation merely because it can draw several objects in one image. Use icon spritesheet generation when a stable visual-spec reference and `iconDescriptions` exist; use UI extraction only for an existing annotated UI design. Pass `screenColor: "auto"` unless the art direction requires one of the supported solid chroma colors. A client must verify the returned full sheet really contains transparency before treating it as a transparent spritesheet. If a source-preserved `warning` is present, do not register the opaque provider source as the requested transparent deliverable. When only `sliceWarning` is present, the full transparent sheet remains usable, but no individual slices may be claimed.
## AI Game Creator Canonical Visual DAG
The AI game creator reuses its existing 16-task manifest; do not add a parallel task system or collapse the following artifacts into one ordinary generation request:
1. `art-director` generates `assets/art-spec.png` with `POST /api/external/v1/editor/images/generations`, `kind: "spec"`, and registers it as `assetKind: "icon-spec"`. This is the real visual-spec image. The JSON value in `generationInputs.artSpec` is supporting structured context and does not replace this image.
2. `design-foundation` uses the registered External Editor resource ID for `assets/art-spec.png` in `referenceImageSrcs`, then generates the complete `assets/ui-prototype.png` through `POST /api/external/v1/editor/images/generations` with `kind: "ui-design"`.
3. `art-asset-plan` uses the same registered `assets/art-spec.png` resource ID as the required `referenceImageSrc` for `POST /api/external/v1/editor/icon-spritesheets/generations`, supplies concrete `iconDescriptions`, and registers the transparent full result as `assets/art-spritesheet.png`.
Never use `assets/ui-prototype.png` as the icon spritesheet's visual-spec reference. `POST /api/external/v1/editor/ui-designs/assets/extractions` requires an existing UI design image with red-box annotations; it is not UI generation and is not part of this canonical DAG.
## Guardrails
@@ -20,6 +20,14 @@ At the start of a new conversation, ask for a canvas name before the first gener
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.
For the AI game creator's existing 16-task autonomous build, distinguish that JSON art spec from the required visual-spec image and keep this dependency chain:
1. `art-director` -> `assets/art-spec.png` via `POST /api/external/v1/editor/images/generations`, with `kind=spec` and registered `assetKind=icon-spec`.
2. `design-foundation` -> `assets/ui-prototype.png` via the same image generation endpoint with `kind=ui-design`, using the registered art-spec resource ID in `referenceImageSrcs`.
3. `art-asset-plan` -> transparent `assets/art-spritesheet.png` via `POST /api/external/v1/editor/icon-spritesheets/generations`, using the registered art-spec resource ID as `referenceImageSrc` and providing `iconDescriptions`.
Do not use the UI prototype as the spritesheet specification. UI extraction requires a stable source image with red-box annotations and is outside this canonical DAG.
## Intent Routing
Infer the endpoint from the user's description. Do not present this as a menu unless the request is genuinely ambiguous.
@@ -53,7 +61,7 @@ Ask a follow-up only when two routes could both be correct and produce different
| 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` |
| Save canvas layout | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers`, `expectedRevision` |
| 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` |
@@ -67,15 +75,44 @@ Ask a follow-up only when two routes could both be correct and produce different
| 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` |
| Generate image/spec/character/UI/publication material | `POST /api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `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 icon spritesheet | `POST /api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` | `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| Extract assets from UI design | `POST /api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `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` |
## Image Post-processing Style
The request-body top-level `style` field controls deterministic image post-processing. It is separate from `generationInputs.artSpec.style`, which only describes the requested visual language for prompting.
- Omitted, `null`, an empty string, and `"none"` all disable post-processing without a warning.
- `"pixelArt"` enables deterministic pixel-art snapping for ordinary image generation (omit `kind`), `kind: "character"`, and icon spritesheet generation.
- Unknown strings, or `"pixelArt"` on unsupported image kinds such as `spec`, `quick-edit`, `ui-design`, or `publication-material`, continue without style processing and return `warning.code: "unsupported-image-style"`.
- A non-string JSON value is malformed and returns HTTP `400`. Keep the field extensible; do not treat the current examples as a closed client-side enum.
Image or character generation with pixel-art snapping:
```json
{
"prompt": "生成一个正面站立的像素风冒险者角色",
"kind": "character",
"style": "pixelArt"
}
```
Icon spritesheet generation with pixel-art snapping:
```json
{
"referenceImageSrc": "generated-character-drafts/editor/external-editor-references/icon-spec.png",
"iconDescriptions": ["木剑", "圆盾", "红色药水"],
"style": "pixelArt"
}
```
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"`.
## HTTP 2xx Warning Handling
@@ -84,7 +121,7 @@ Character image generation (including character redraw through `kind: "character
- Consume the returned `project` and media snapshots as authoritative: character responses use `resource` / `asset`, while icon spritesheet and UI extraction responses use `spritesheetResource` / `spritesheetAsset`. `warning.code: "postprocess-failed-source-preserved"` means the saved provider source is the main result. Character output has no transparent derivative, while icon spritesheet and UI extraction have no transparent spritesheet and no slices. Display `warning.reason` directly; do not construct missing assets or retry the provider generation from scratch.
- `sliceWarning` is only for a transparent spritesheet that was created successfully but could not be split automatically. Use the complete transparent spritesheet and preserve `sliceWarning.reason` as the original diagnostic; it is not a post-processing/source-preserved warning.
- The service contract keeps `warning` and `sliceWarning` mutually exclusive. If a malformed response contains both, prioritize the general `warning` over `sliceWarning` defensively.
- `warning` and `sliceWarning` are mutually exclusive only for `postprocess-failed-source-preserved`, because that failure never reaches slicing. A general `warning` produced by image-style normalization (`unsupported-image-style`) or pixel-art snapping can coexist with `sliceWarning`; render both reasons instead of picking one.
## Reference Image Upload
@@ -270,11 +270,21 @@ class GenarrativeExternalClient:
fields[asset_label_field] = normalize_optional_text(asset_label) or "生成素材"
return fields
def save_canvas(self, project_id: str, viewport: dict[str, Any], layers: dict[str, Any]) -> Any:
def save_canvas(
self,
project_id: str,
viewport: dict[str, Any],
layers: dict[str, Any],
expected_revision: int,
) -> Any:
return self.request_json(
"PATCH",
f"/api/external/v1/editor/projects/{urllib.parse.quote(project_id, safe='')}/canvas",
{"viewport": viewport, "layers": layers},
{
"viewport": viewport,
"layers": layers,
"expectedRevision": expected_revision,
},
)
def _apply_art_spec(self, fields: dict[str, Any], prompt: str) -> str:
@@ -450,6 +460,29 @@ class GenarrativeExternalClient:
timeout=GENERATION_REQUEST_TIMEOUT_SECONDS,
)
def generate_icon_spritesheet(
self,
reference_image_src: str,
icon_descriptions: list[str],
**fields: Any,
) -> Any:
descriptions = [item.strip() for item in icon_descriptions if item.strip()]
if not descriptions:
raise GenarrativeApiError("icon_descriptions must contain at least one non-empty item")
label = fields.get("assetLabel", "图标图集")
self._apply_canvas_session_fields(fields, label, 1024, 1024)
fields.setdefault("screenColor", "auto")
return self.request_json(
"POST",
"/api/external/v1/editor/icon-spritesheets/generations",
{
"referenceImageSrc": reference_image_src,
"iconDescriptions": descriptions,
**fields,
},
timeout=GENERATION_REQUEST_TIMEOUT_SECONDS,
)
def extract_ui_assets(self, source_image_src: str, image_size: str = "1K", **fields: Any) -> Any:
fields.pop("aspectRatio", None)
self._apply_canvas_session_fields(fields, fields.get("spritesheetLabel", "UI 素材拆分"), 1024, 1024, "spritesheetLabel")
@@ -619,6 +652,17 @@ def _self_test() -> None:
assert calls[0]["body"]["canvasCompletion"]["title"] == "角色呼吸动画"
assert calls[1]["path"] == "/api/external/v1/editor/assets"
assert result["asset"]["assetId"] == "editor-asset-demo"
calls.clear()
client.generate_icon_spritesheet(
"editor-resource-spec",
["蛇头向上", "蛇身直线", "转角", "尾部", "四类食物"],
canvasSession=session,
assetLabel="贪吃蛇透明图集",
)
assert calls[0]["path"] == "/api/external/v1/editor/icon-spritesheets/generations"
assert calls[0]["body"]["referenceImageSrc"] == "editor-resource-spec"
assert calls[0]["body"]["screenColor"] == "auto"
assert calls[0]["body"]["iconDescriptions"][0] == "蛇头向上"
print("self-test ok")
+46 -2
View File
@@ -103,6 +103,9 @@ jobs:
- name: Install npm dependencies
run: npm ci
- name: Install AI game creator dependencies
run: npm ci --prefix apps/ai-game-creator-shell
- name: Run frontend and script tests
run: npm run test
@@ -161,6 +164,23 @@ jobs:
- name: Check server-rs boundaries
run: npm run check:server-rs-ddd
- name: Prepare server-rs Rust dependencies
shell: bash
run: |
set -euo pipefail
for attempt in $(seq 1 5); do
if cargo fetch --locked \
--target x86_64-unknown-linux-gnu \
--manifest-path server-rs/Cargo.toml; then
break
fi
if [[ "${attempt}" -eq 5 ]]; then
echo 'server-rs Cargo dependency fetch failed after 5 attempts.' >&2
exit 1
fi
sleep $((attempt * 2))
done
- name: Run server-rs workspace tests
run: cargo test --locked --workspace --no-fail-fast --manifest-path server-rs/Cargo.toml
@@ -186,8 +206,32 @@ jobs:
- name: Install npm dependencies
run: npm ci
- name: Install AI game creator dependencies
run: npm ci --prefix apps/ai-game-creator-shell
- name: Prepare native Rust dependencies
shell: bash
run: |
set -euo pipefail
for manifest_path in \
apps/desktop-shell/src-tauri/Cargo.toml \
apps/ai-game-creator-shell/src-tauri/Cargo.toml; do
for attempt in $(seq 1 5); do
if cargo fetch --locked \
--target x86_64-unknown-linux-gnu \
--manifest-path "${manifest_path}"; then
break
fi
if [[ "${attempt}" -eq 5 ]]; then
echo "Cargo dependency fetch failed after 5 attempts: ${manifest_path}" >&2
exit 1
fi
sleep $((attempt * 2))
done
done
- name: Run native shell gates
run: npm run check:native-shells
- name: Ensure native lockfile is unchanged
run: git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock
- name: Ensure native lockfiles are unchanged
run: git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock apps/ai-game-creator-shell/src-tauri/Cargo.lock
+7
View File
@@ -31,6 +31,12 @@ temp*build*/
/apps/desktop-shell/src-tauri/target/
/apps/desktop-shell/src-tauri/gen/
/apps/desktop-shell/src-tauri/permissions/autogenerated/
/apps/ai-game-creator-shell/src-tauri/target/
/apps/ai-game-creator-shell/src-tauri/gen/
/apps/ai-game-creator-shell/src-tauri/logs/
/apps/ai-game-creator-shell/logs/
/apps/ai-game-creator-shell/.llm-drafts/
/apps/ai-game-creator-shell/game-creator.config.local.json
/apps/mobile-shell/.expo/
/apps/mobile-shell/.expo-export-smoke/
/server-rs/.spacetimedb/
@@ -47,6 +53,7 @@ temp*build*/
/.playwright-cli/
**/.playwright-cli/
/output/playwright/
/output/external-api-smoke/
/server-rs/crates/*/logs/
.worktrees/
.rag/
+1
View File
@@ -33,6 +33,7 @@
- Issue 使用自托管 Gitea;优先用 Gitea UI/API 或 `tea` CLI,不使用 GitHub `gh` 或 GitLab `glab`,除非仓库已迁移。默认 triage 标签:`needs-triage``needs-info``ready-for-agent``ready-for-human``wontfix`
- 需要仓库级 Hermes skills/plugins 时,再读取 [`.hermes/README.md`](.hermes/README.md)。
- 涉及 AI 游戏创作独立 App、多智能体 Runtime、本地项目产物或本地 HTTP 预览时,先读取 [`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`](docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)。
- 新增、补齐、迁移或重构玩法入口、玩法类型、创作工作台、生成页、结果页、发布、运行态、作品架、广场或公开 read model 前,必须读取并按 [`genarrative-play-type-integration`](.codex/skills/genarrative-play-type-integration/SKILL.md) 执行。
- 涉及 `npm run dev` / `npm run dev:spacetime` / `npm run dev:api-server` / `npm run dev:web` / `npm run dev:admin-web` 的端口探测、端口漂移、SpacetimeDB publish server、api-server 环境变量、Vite 代理目标或后台 dev 端口时,按 [`.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md`](.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md) 执行。
- 涉及 SpacetimeDB 的设计、实现、脚本、调试、发布、绑定生成、schema、reducer、procedure、view 或 Rust API 时,必须读取并按 [`spacetimedb-cli`](.codex/skills/spacetimedb-cli/SKILL.md)、[`spacetimedb-rust`](.codex/skills/spacetimedb-rust/SKILL.md)、[`spacetimedb-concepts`](.codex/skills/spacetimedb-concepts/SKILL.md) 中相关 skill 执行。
+4
View File
@@ -16,6 +16,10 @@ _Avoid_: 在玩法页面内手写上传、参考图、重绘、预览、删除
独立 `/editor` 中可保存、恢复和继续编辑的图片画布工作状态,包含画布视图、图层布局和资源引用;用于多图对比、生成结果衍生和画布级编辑,不替代玩法页面内的单图资产编辑。
_Avoid_: 玩法结果页单图槽位、发布态作品、只存在前端内存里的临时画布
**项目开发画布**:
GameAgent 独立客户端中某个本地游戏项目的开发工作区概念,用于承载项目名、路径、首条需求、附件导入结果、最近 run 状态,以及后续真正的项目开发画布与 Agent 协作界面;当前首页改造阶段先落占位页。它属于 AI 游戏创作本地项目域,不等同于 `/editor` 的图片画布工程。
_Avoid_: `/editor` 图片画布工程、画布资源 / 图层布局、启动器 / 主窗口切换概念、只用于首页输入的临时草稿
**画布Agent对话**:
图片画布工程右侧的对话式编辑器工具,用户通过自然语言调度画布已有的图片类生成与编辑能力(生成图片、生成角色形象、生成图标素材、生成 UI 设计图、基于附件的图片修改),并可附加画布素材或素材库图片作为参考;对话归属单个图片画布工程,可保存历史、新开会话和软删会话。属于画布域工具,不承接玩法创作、不产出玩法作品或模板,与「表单/图片输入创作工作台」的 Avoid 边界不冲突。
_Avoid_: 对话式玩法创作工作台、绕过模型定价收口的生成入口、把对话消息当作画布布局真相、复用拼图专用 creative-agent 内存会话
+68 -1
View File
@@ -6,17 +6,19 @@ import {
getAdminFeatureGateConfig,
getAdminUserDetail,
listAdminRechargeOrders,
reconcileAdminUserConsumption,
resolveAdminRechargeRefundManualReview,
updateAdminAccount,
uploadAdminEditorShowcaseCampaignImage,
upsertAdminFeatureGateConfig,
upsertProfileWalletConfig,
} from './adminApiClient';
afterEach(() => {
vi.unstubAllGlobals();
});
test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () => {
test('后台账号创建和更新同时携带 Tab 与独立操作权限', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ account: { accountId: 'member-1' } }), {
@@ -31,11 +33,13 @@ test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () =>
displayName: '运营',
password: 'secret123',
tabPermissions: ['dashboard', 'tracking'],
actionPermissions: ['profile-wallet-consumption-reconcile'],
enabled: true,
});
await updateAdminAccount('owner-token', 'member/1', {
displayName: '运营二组',
tabPermissions: ['tracking'],
actionPermissions: [],
enabled: false,
});
@@ -44,6 +48,14 @@ test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () =>
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: 'Bearer owner-token' }),
body: JSON.stringify({
username: 'operator',
displayName: '运营',
password: 'secret123',
tabPermissions: ['dashboard', 'tracking'],
actionPermissions: ['profile-wallet-consumption-reconcile'],
enabled: true,
}),
}),
);
expect(fetchMock.mock.calls[1]?.[0]).toBe('/admin/api/accounts/member%2F1');
@@ -53,12 +65,40 @@ test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () =>
body: JSON.stringify({
displayName: '运营二组',
tabPermissions: ['tracking'],
actionPermissions: [],
enabled: false,
}),
}),
);
});
test('账号配置一次提交初始和每日免费泥点', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({configId: 'profile_wallet'}), {
status: 200,
headers: {'content-type': 'application/json'},
}),
);
vi.stubGlobal('fetch', fetchMock);
await upsertProfileWalletConfig('owner-token', {
initialMudPoints: 100,
dailyFreePointsPerDay: 35,
});
expect(fetchMock).toHaveBeenCalledWith(
'/admin/api/profile/wallet-config',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({Authorization: 'Bearer owner-token'}),
body: JSON.stringify({
initialMudPoints: 100,
dailyFreePointsPerDay: 35,
}),
}),
);
});
test('灰度配置读写只使用通用 feature-gates 管理接口', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
@@ -238,6 +278,33 @@ test('用户详情只发送实际提供的用户定位字段', async () => {
});
});
test('历史花费手动对账使用独立管理员写接口', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
userId: 'user-1',
historicalConsumedPoints: 1300,
changed: true,
}),
{ status: 200 },
),
);
vi.stubGlobal('fetch', fetchMock);
await reconcileAdminUserConsumption('token-1', { userId: 'user-1' });
expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
'/admin/api/profile/users/reconcile-consumption',
);
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: 'Bearer token-1' }),
body: JSON.stringify({ userId: 'user-1' }),
}),
);
});
test('退款执行使用独立 execute 管理员路由', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ outRefundNo: 'refund-1' }), {
+12
View File
@@ -48,6 +48,8 @@ import type {
AdminUpsertProfileRedeemCodeRequest,
AdminUpsertProfileTaskConfigRequest,
AdminUpsertProfileWalletConfigRequest,
AdminUserConsumptionReconcileRequest,
AdminUserConsumptionReconcileResponse,
AdminUserDetailQuery,
AdminUserDetailResponse,
AdminWalletRestrictionRequest,
@@ -577,6 +579,16 @@ export function getAdminUserDetail(token: string, query: AdminUserDetailQuery) {
);
}
export function reconcileAdminUserConsumption(
token: string,
payload: AdminUserConsumptionReconcileRequest,
) {
return request<AdminUserConsumptionReconcileResponse>(
'/admin/api/profile/users/reconcile-consumption',
{ method: 'POST', token, body: payload },
);
}
export function previewAdminRechargeRefund(
token: string,
payload: AdminRechargeRefundPreviewRequest,
+20
View File
@@ -38,6 +38,7 @@ export interface AdminSessionPayload {
roles: string[];
accountRole: 'owner' | 'member';
tabPermissions: string[];
actionPermissions: string[];
issuedAt: string;
expiresAt: string;
}
@@ -48,6 +49,7 @@ export interface AdminAccountPayload {
displayName: string;
accountRole: 'owner' | 'member';
tabPermissions: string[];
actionPermissions: string[];
enabled: boolean;
tokenVersion: number;
createdBy: string;
@@ -65,6 +67,7 @@ export interface AdminCreateAccountRequest {
displayName: string;
password: string;
tabPermissions: string[];
actionPermissions: string[];
enabled: boolean;
}
@@ -76,6 +79,7 @@ export interface AdminUpdateAccountRequest {
displayName: string;
password?: string;
tabPermissions: string[];
actionPermissions: string[];
enabled: boolean;
}
@@ -556,6 +560,7 @@ export interface AdminUpsertProfileRechargeProductRequest {
export interface AdminUpsertProfileWalletConfigRequest {
initialMudPoints: number;
dailyFreePointsPerDay: number;
}
export interface ProfileRedeemCodeAdminResponse {
@@ -657,6 +662,7 @@ export interface ProfileRechargeProductConfigAdminListResponse {
export interface ProfileWalletConfigAdminResponse {
configId: string;
initialMudPoints: number;
dailyFreePointsPerDay: number;
createdBy: string;
createdByDisplayName: string;
createdAt: string;
@@ -819,10 +825,24 @@ export interface AdminUserDetailResponse {
bindingStatus: string;
phoneBound: boolean;
wechatBound: boolean;
historicalConsumedPoints: number;
canReconcileConsumption: boolean;
wallet: AdminProfileWalletPayload;
rechargeOrders: AdminRechargeOrderEntryPayload[];
}
export interface AdminUserConsumptionReconcileRequest {
userId: string;
}
export interface AdminUserConsumptionReconcileResponse {
userId: string;
previousHistoricalConsumedPoints?: number | null;
historicalConsumedPoints: number;
changed: boolean;
reconciledAtMicros: number;
}
export interface AdminRechargeRefundPreviewRequest {
orderId: string;
refundAmountCents: number;
@@ -6,6 +6,7 @@ import {beforeEach, expect, test, vi} from 'vitest';
import {
getAdminUserDetail,
reconcileAdminUserConsumption,
updateAdminWalletRestriction,
} from '../api/adminApiClient';
import type {
@@ -20,6 +21,7 @@ vi.mock('../api/adminApiClient', () => ({
),
getAdminUserDetail: vi.fn(),
isAdminApiError: vi.fn(() => false),
reconcileAdminUserConsumption: vi.fn(),
updateAdminWalletRestriction: vi.fn(),
}));
@@ -48,6 +50,8 @@ const detail: AdminUserDetailResponse = {
bindingStatus: 'bound',
phoneBound: true,
wechatBound: true,
historicalConsumedPoints: 1234,
canReconcileConsumption: true,
wallet,
rechargeOrders: [
{
@@ -82,6 +86,13 @@ const detail: AdminUserDetailResponse = {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getAdminUserDetail).mockResolvedValue(detail);
vi.mocked(reconcileAdminUserConsumption).mockResolvedValue({
userId: 'user-1',
previousHistoricalConsumedPoints: 1234,
historicalConsumedPoints: 1300,
changed: true,
reconciledAtMicros: 1_720_000_000_000_000,
});
vi.mocked(updateAdminWalletRestriction).mockResolvedValue({wallet});
});
@@ -111,6 +122,8 @@ test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退
expect(screen.getByText('138****5678')).toBeTruthy();
expect(screen.getByText('退款欠账限制')).toBeTruthy();
expect(screen.getByText('25', {selector: 'strong'})).toBeTruthy();
expect(screen.getByText('历史花费')).toBeTruthy();
expect(screen.getByText('1234', {selector: 'strong'})).toBeTruthy();
expect(screen.getByText('order-1')).toBeTruthy();
await user.keyboard('{Escape}');
@@ -136,6 +149,50 @@ test('只有陶泥号时按 publicUserCode 查询用户', async () => {
});
});
test('历史花费支持手动对账并用权威结果校准展示', async () => {
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('陶泥用户');
await user.click(screen.getByRole('button', {name: '手动对账历史花费'}));
await user.click(screen.getByRole('button', {name: '确认'}));
await waitFor(() => {
expect(reconcileAdminUserConsumption).toHaveBeenCalledWith('admin-token', {
userId: 'user-1',
});
});
expect(await screen.findByText('对账完成,历史花费已校准')).toBeTruthy();
expect(screen.getByText('1300', {selector: 'strong'})).toBeTruthy();
});
test('没有独立操作权限时不显示历史花费对账按钮', async () => {
vi.mocked(getAdminUserDetail).mockResolvedValue({
...detail,
canReconcileConsumption: false,
});
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.queryByRole('button', {name: '手动对账历史花费'})).toBeNull();
});
test('人工冻结和解除人工冻结分别提交原因且不解除退款欠账限制', async () => {
const user = userEvent.setup();
const manuallyFrozenWallet: AdminProfileWalletPayload = {
@@ -6,6 +6,7 @@ import {
formatAdminApiError,
getAdminUserDetail,
isAdminApiError,
reconcileAdminUserConsumption,
updateAdminWalletRestriction,
} from '../api/adminApiClient';
import type {
@@ -34,6 +35,8 @@ export function AdminUserDetailDialog({
const [errorMessage, setErrorMessage] = useState('');
const [restrictionReason, setRestrictionReason] = useState('');
const [isSavingRestriction, setIsSavingRestriction] = useState(false);
const [isReconcilingConsumption, setIsReconcilingConsumption] = useState(false);
const [reconcileMessage, setReconcileMessage] = useState('');
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
const requestVersionRef = useRef(0);
const {confirmWrite, confirmDialog, isConfirming} = useAdminWriteConfirm();
@@ -60,7 +63,12 @@ export function AdminUserDetailDialog({
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !isSavingRestriction && !isConfirming) {
if (
event.key === 'Escape' &&
!isSavingRestriction &&
!isReconcilingConsumption &&
!isConfirming
) {
event.preventDefault();
onClose();
}
@@ -69,13 +77,14 @@ export function AdminUserDetailDialog({
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [isConfirming, isSavingRestriction, onClose]);
}, [isConfirming, isReconcilingConsumption, isSavingRestriction, onClose]);
async function loadDetail() {
const requestVersion = requestVersionRef.current + 1;
requestVersionRef.current = requestVersion;
setIsLoading(true);
setErrorMessage('');
setReconcileMessage('');
try {
const response = await getAdminUserDetail(token, {
userId: userId?.trim() || undefined,
@@ -144,6 +153,47 @@ export function AdminUserDetailDialog({
}
}
async function handleConsumptionReconcile() {
if (!detail || isReconcilingConsumption) {
return;
}
const confirmed = await confirmWrite({
action: '手动对账历史花费',
target: `${detail.displayName || detail.publicUserCode} / ${detail.userId}`,
});
if (!confirmed) {
return;
}
setIsReconcilingConsumption(true);
setErrorMessage('');
setReconcileMessage('');
try {
const response = await reconcileAdminUserConsumption(token, {
userId: detail.userId,
});
setDetail((current) =>
current
? {
...current,
historicalConsumedPoints: response.historicalConsumedPoints,
}
: current,
);
setReconcileMessage(
response.changed ? '对账完成,历史花费已校准' : '对账完成,数据一致',
);
} catch (error: unknown) {
if (isAdminApiError(error) && error.status === 401) {
onUnauthorized('登录状态已失效');
} else {
setErrorMessage(formatAdminApiError(error));
}
} finally {
setIsReconcilingConsumption(false);
}
}
if (typeof document === 'undefined') {
return null;
}
@@ -158,6 +208,7 @@ export function AdminUserDetailDialog({
if (
event.target === event.currentTarget &&
!isSavingRestriction &&
!isReconcilingConsumption &&
!isConfirming
) {
onClose();
@@ -174,7 +225,7 @@ export function AdminUserDetailDialog({
<button
aria-label="刷新用户信息"
className="admin-ghost-button"
disabled={isLoading}
disabled={isLoading || isReconcilingConsumption}
title="刷新"
type="button"
onClick={() => void loadDetail()}
@@ -185,7 +236,7 @@ export function AdminUserDetailDialog({
ref={closeButtonRef}
aria-label="关闭用户详情"
className="admin-ghost-button"
disabled={isSavingRestriction}
disabled={isSavingRestriction || isReconcilingConsumption}
title="关闭"
type="button"
onClick={onClose}
@@ -222,7 +273,15 @@ export function AdminUserDetailDialog({
{errorMessage}
</div>
) : null}
<WalletSection wallet={detail.wallet} />
<WalletSection
wallet={detail.wallet}
historicalConsumedPoints={detail.historicalConsumedPoints}
canReconcileConsumption={detail.canReconcileConsumption}
isBusy={isReconcilingConsumption || isSavingRestriction}
isReconciling={isReconcilingConsumption}
reconcileMessage={reconcileMessage}
onReconcile={() => void handleConsumptionReconcile()}
/>
<section className="admin-user-restriction-section">
<div className="admin-panel-heading">
@@ -251,7 +310,7 @@ export function AdminUserDetailDialog({
<span></span>
<input
aria-label="人工冻结操作原因"
disabled={isSavingRestriction}
disabled={isSavingRestriction || isReconcilingConsumption}
value={restrictionReason}
onChange={(event) => setRestrictionReason(event.target.value)}
/>
@@ -262,7 +321,11 @@ export function AdminUserDetailDialog({
? 'admin-secondary-button'
: 'admin-danger-button'
}
disabled={isSavingRestriction || !restrictionReason.trim()}
disabled={
isSavingRestriction ||
isReconcilingConsumption ||
!restrictionReason.trim()
}
type="button"
onClick={() => void handleRestrictionChange()}
>
@@ -369,7 +432,23 @@ function UserIdentityHeader({detail}: {detail: AdminUserDetailResponse}) {
);
}
function WalletSection({wallet}: {wallet: AdminProfileWalletPayload}) {
function WalletSection({
wallet,
historicalConsumedPoints,
canReconcileConsumption,
isBusy,
isReconciling,
reconcileMessage,
onReconcile,
}: {
wallet: AdminProfileWalletPayload;
historicalConsumedPoints: number;
canReconcileConsumption: boolean;
isBusy: boolean;
isReconciling: boolean;
reconcileMessage: string;
onReconcile: () => void;
}) {
const metrics = [
['总余额', wallet.totalBalance],
['可消费', wallet.spendableBalance],
@@ -378,19 +457,41 @@ function WalletSection({wallet}: {wallet: AdminProfileWalletPayload}) {
['会员限时', wallet.membershipLimitedPoints],
['退款占用', wallet.heldPoints],
['退款欠账', wallet.refundDebtPoints],
['历史花费', historicalConsumedPoints],
] as const;
return (
<section className="admin-user-wallet-section">
<div className="admin-panel-heading">
<h3></h3>
<div className="admin-tag-list">
{wallet.manualFrozen ? <span className="admin-tag"></span> : null}
{wallet.refundDebtFrozen ? (
<span className="admin-tag">退</span>
<div className="admin-detail-actions">
{canReconcileConsumption ? (
<button
aria-label="手动对账历史花费"
className="admin-ghost-button admin-user-wallet-reconcile-button"
disabled={isBusy}
type="button"
onClick={onReconcile}
>
<RefreshCcw size={15} aria-hidden="true" />
<span>{isReconciling ? '对账中' : '手动对账'}</span>
</button>
) : null}
{!wallet.walletFrozen ? <span className="admin-status admin-status-ok"></span> : null}
<div className="admin-tag-list">
{wallet.manualFrozen ? <span className="admin-tag"></span> : null}
{wallet.refundDebtFrozen ? (
<span className="admin-tag">退</span>
) : null}
{!wallet.walletFrozen ? (
<span className="admin-status admin-status-ok"></span>
) : null}
</div>
</div>
</div>
{reconcileMessage ? (
<div className="admin-alert" role="status">
{reconcileMessage}
</div>
) : null}
<div className="admin-user-wallet-grid">
{metrics.map(([label, value]) => (
<div className="admin-recharge-metric" key={label}>
@@ -17,6 +17,7 @@ interface AdminAccountsPageProps {
}
const assignableRoutes = adminRoutes.filter((route) => !route.ownerOnly);
const consumptionReconcilePermission = 'profile-wallet-consumption-reconcile';
export function AdminAccountsPage({
token,
@@ -29,6 +30,7 @@ export function AdminAccountsPage({
const [password, setPassword] = useState('');
const [enabled, setEnabled] = useState(true);
const [tabPermissions, setTabPermissions] = useState<string[]>([]);
const [actionPermissions, setActionPermissions] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
@@ -65,6 +67,7 @@ export function AdminAccountsPage({
setPassword('');
setEnabled(true);
setTabPermissions([]);
setActionPermissions([]);
setErrorMessage('');
}
@@ -75,6 +78,7 @@ export function AdminAccountsPage({
setPassword('');
setEnabled(account.enabled);
setTabPermissions(account.tabPermissions);
setActionPermissions(account.actionPermissions ?? []);
setErrorMessage('');
}
@@ -127,6 +131,7 @@ export function AdminAccountsPage({
displayName: normalizedDisplayName,
...(password ? {password} : {}),
tabPermissions,
actionPermissions,
enabled,
})
: await createAdminAccount(token, {
@@ -134,6 +139,7 @@ export function AdminAccountsPage({
displayName: normalizedDisplayName,
password,
tabPermissions,
actionPermissions,
enabled,
});
setAccounts((current) => {
@@ -289,6 +295,28 @@ export function AdminAccountsPage({
</div>
</fieldset>
<fieldset className="admin-permission-fieldset">
<legend></legend>
<div className="admin-permission-grid">
<label>
<input
checked={actionPermissions.includes(
consumptionReconcilePermission,
)}
type="checkbox"
onChange={(event) =>
setActionPermissions(
event.target.checked
? [consumptionReconcilePermission]
: [],
)
}
/>
<span></span>
</label>
</div>
</fieldset>
<button
className="admin-primary-button"
disabled={isSaving}
@@ -0,0 +1,58 @@
/* @vitest-environment jsdom */
import {fireEvent, render, screen, waitFor} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {beforeEach, expect, test, vi} from 'vitest';
import {getProfileWalletConfig, upsertProfileWalletConfig} from '../api/adminApiClient';
import type {ProfileWalletConfigAdminResponse} from '../api/adminApiTypes';
import {AdminProfileWalletConfigPage} from './AdminProfileWalletConfigPage';
vi.mock('../api/adminApiClient', () => ({
formatAdminApiError: vi.fn((error: unknown) => error instanceof Error ? error.message : '请求失败'),
getProfileWalletConfig: vi.fn(),
isAdminApiError: vi.fn(() => false),
upsertProfileWalletConfig: vi.fn(),
}));
const configResponse: ProfileWalletConfigAdminResponse = {
configId: 'profile_wallet', initialMudPoints: 100, dailyFreePointsPerDay: 20,
createdBy: 'owner-1', createdByDisplayName: '管理员',
createdAt: '2026-07-31T01:00:00Z', updatedBy: 'owner-1',
updatedByDisplayName: '管理员', updatedAt: '2026-07-31T01:00:00Z',
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getProfileWalletConfig).mockResolvedValue(configResponse);
vi.mocked(upsertProfileWalletConfig).mockResolvedValue({...configResponse, initialMudPoints: 120, dailyFreePointsPerDay: 35});
});
test('账号配置页加载并展示每日免费泥点', async () => {
render(<AdminProfileWalletConfigPage token="admin-token" result={configResponse} onUnauthorized={vi.fn()} onResultChange={vi.fn()} />);
expect((await screen.findByLabelText('每日免费泥点数') as HTMLInputElement).value).toBe('20');
expect(getProfileWalletConfig).toHaveBeenCalledWith('admin-token');
expect(screen.getByText('每日免费泥点')).toBeTruthy();
});
test('账号配置页一次保存初始和每日免费泥点', async () => {
const user = userEvent.setup();
const onResultChange = vi.fn();
render(<AdminProfileWalletConfigPage token="admin-token" result={configResponse} onUnauthorized={vi.fn()} onResultChange={onResultChange} />);
await screen.findByLabelText('每日免费泥点数');
fireEvent.change(screen.getByLabelText('账号初始泥点数'), {target: {value: '120'}});
fireEvent.change(screen.getByLabelText('每日免费泥点数'), {target: {value: '35'}});
await user.click(screen.getByRole('button', {name: '保存'}));
expect(screen.getByText('初始 120 泥点,每日免费 35 泥点')).toBeTruthy();
await user.click(screen.getByRole('button', {name: '确认'}));
await waitFor(() => expect(upsertProfileWalletConfig).toHaveBeenCalledWith('admin-token', {initialMudPoints: 120, dailyFreePointsPerDay: 35}));
expect(onResultChange).toHaveBeenLastCalledWith(expect.objectContaining({initialMudPoints: 120, dailyFreePointsPerDay: 35}));
});
test('账号配置页拒绝非正整数每日免费额度', async () => {
render(<AdminProfileWalletConfigPage token="admin-token" result={configResponse} onUnauthorized={vi.fn()} onResultChange={vi.fn()} />);
const input = await screen.findByLabelText('每日免费泥点数');
fireEvent.change(input, {target: {value: '1.5'}});
expect((screen.getByRole('button', {name: '保存'}) as HTMLButtonElement).disabled).toBe(true);
expect(upsertProfileWalletConfig).not.toHaveBeenCalled();
});
@@ -23,6 +23,7 @@ export function AdminProfileWalletConfigPage({
onResultChange,
}: AdminProfileWalletConfigPageProps) {
const [initialMudPoints, setInitialMudPoints] = useState('100');
const [dailyFreePointsPerDay, setDailyFreePointsPerDay] = useState('20');
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [loadErrorMessage, setLoadErrorMessage] = useState('');
@@ -41,6 +42,7 @@ export function AdminProfileWalletConfigPage({
const response = await getProfileWalletConfig(token);
onResultChange(response);
setInitialMudPoints(String(response.initialMudPoints));
setDailyFreePointsPerDay(String(response.dailyFreePointsPerDay));
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setLoadErrorMessage);
} finally {
@@ -53,6 +55,13 @@ export function AdminProfileWalletConfigPage({
if (isSaving) {
return;
}
const normalizedDailyFreePointsPerDay = parsePositiveInteger(
dailyFreePointsPerDay,
);
if (!normalizedDailyFreePointsPerDay) {
setErrorMessage('每日免费泥点数必须是大于 0 的整数');
return;
}
const normalizedInitialMudPoints = parsePositiveInteger(initialMudPoints);
if (!normalizedInitialMudPoints) {
@@ -63,7 +72,7 @@ export function AdminProfileWalletConfigPage({
setErrorMessage('');
const confirmed = await confirmWrite({
action: '保存账号配置',
target: `${normalizedInitialMudPoints}泥点`,
target: `初始 ${normalizedInitialMudPoints} 泥点,每日免费 ${normalizedDailyFreePointsPerDay} 泥点`,
});
if (!confirmed) {
return;
@@ -73,9 +82,11 @@ export function AdminProfileWalletConfigPage({
try {
const response = await upsertProfileWalletConfig(token, {
initialMudPoints: normalizedInitialMudPoints,
dailyFreePointsPerDay: normalizedDailyFreePointsPerDay,
});
onResultChange(response);
setInitialMudPoints(String(response.initialMudPoints));
setDailyFreePointsPerDay(String(response.dailyFreePointsPerDay));
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
@@ -120,6 +131,17 @@ export function AdminProfileWalletConfigPage({
/>
</label>
<label className="admin-field">
<span></span>
<input
min={1}
step={1}
type="number"
value={dailyFreePointsPerDay}
onChange={(event) => setDailyFreePointsPerDay(event.target.value)}
/>
</label>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
@@ -128,7 +150,11 @@ export function AdminProfileWalletConfigPage({
<button
className="admin-primary-button"
disabled={isSaving || !parsePositiveInteger(initialMudPoints)}
disabled={
isSaving ||
!parsePositiveInteger(initialMudPoints) ||
!parsePositiveInteger(dailyFreePointsPerDay)
}
type="submit"
>
<Save size={17} aria-hidden="true" />
@@ -147,6 +173,10 @@ export function AdminProfileWalletConfigPage({
<dt></dt>
<dd>{result.initialMudPoints}</dd>
</div>
<div>
<dt></dt>
<dd>{result.dailyFreePointsPerDay}</dd>
</div>
<div>
<dt></dt>
<dd>{result.updatedByDisplayName || '-'}</dd>
@@ -169,6 +199,6 @@ export function AdminProfileWalletConfigPage({
}
function parsePositiveInteger(value: string) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0;
}
+7
View File
@@ -1270,6 +1270,13 @@ button:disabled {
background: #f8efe7;
}
.admin-ghost-button.admin-user-wallet-reconcile-button {
width: auto;
min-width: 92px;
padding: 0 10px;
gap: 6px;
}
.admin-ghost-button.admin-query-reset-button {
width: auto;
min-width: 76px;
@@ -0,0 +1,23 @@
{
"llm": {
"apiKey": "",
"baseUrl": "https://api.openai.com/v1",
"model": "gpt-4.1",
"apiKind": "openai_responses",
"reasoningEffort": "high",
"stream": false,
"webSearchEnabled": false,
"contextWindowTokens": 128000,
"autoCompactTokenLimit": 64000,
"toolOutputTokenLimit": 12000,
"requestTimeoutMs": 180000,
"maxRetries": 0,
"retryBackoffMs": 500
},
"agentLlm": {},
"editorApi": {
"baseUrl": "http://127.0.0.1:8082",
"apiKey": ""
},
"mcpServers": {}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," />
<title>AI 游戏创作</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
{
"name": "@genarrative/ai-game-creator-shell",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "npm --prefix ../.. exec tauri -- dev",
"game-chat": "npm --prefix ../.. exec tauri -- dev -- -- --game-chat",
"dev-server": "node scripts/start-dev-server.mjs",
"dev-stack": "node scripts/start-dev-stack.mjs",
"build": "npm --prefix ../.. exec tauri -- build",
"build:game-chat-release": "npm --prefix ../.. exec tauri -- build --config src-tauri/tauri.game-chat-release.conf.json --bundles nsis --features game-chat-release",
"llm-status": "node scripts/run-cli-with-config.mjs --llm-status",
"agent-task": "node scripts/run-cli-with-config.mjs --agent-task",
"chat": "node scripts/run-cli-with-config.mjs --swarm-chat",
"swarm": "node scripts/run-cli-with-config.mjs --swarm-chat",
"config": "node scripts/game-creator-config-wizard.mjs",
"test:chat": "node scripts/agent-swarm-test-chat.mjs --task \"制作一个可直接试玩的原创植物塔防小游戏:玩家选择并放置原创守卫阻挡敌人,完成波次后可以进入下一关并重新开始。主题、单位名称与视觉语言必须原创,不使用任何现有游戏角色、单位名、Logo 或受保护视觉语言。请自主完成正式产物、静态检查和双视口试玩验证。\" --no-open",
"test:chat:manual": "node scripts/agent-swarm-test-chat.mjs",
"agent-run": "node scripts/run-cli-with-config.mjs --agent-run",
"agent-run:smoke": "node scripts/smoke-agent-run-local-provider.mjs",
"agent-runtime:real-e2e": "node scripts/agent-runtime-real-e2e.mjs",
"agent-runtime:collaboration-policy-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-collaboration-policy-mixed-recovery",
"agent-runtime:mixed-swarm-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-static-isolated-autonomous-chat",
"agent-runtime:supervisor-swarm-autonomous-chat-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-autonomous-chat",
"agent-runtime:supervisor-autonomous-playable-lane-defense-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-autonomous-playable-lane-defense",
"agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e": "node scripts/agent-runtime-deterministic-playable-e2e.mjs",
"agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-self-test": "node scripts/agent-runtime-deterministic-playable-e2e.mjs --self-test",
"agent-runtime:supervisor-swarm-transient-retry-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-transient-retry",
"agent-runtime:supervisor-swarm-final-reply-transient-retry-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-final-reply-transient-retry",
"agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-tool-plan-handoff-runner-kill",
"agent-runtime:steer-real-e2e": "node scripts/agent-runtime-steer-real-e2e.mjs",
"agent-runtime:steer-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite steer-runner-kill",
"typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node scripts/check-config.mjs"
},
"dependencies": {
"@lexical/react": "^0.47.0",
"@lexical/utils": "^0.47.0",
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
"@tauri-apps/plugin-opener": "~2",
"@vitejs/plugin-react": "^5.0.4",
"lexical": "^0.47.0",
"lucide-react": "^0.546.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"vite": "^6.2.0",
"zustand": "^5.0.14"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.14",
"@tauri-apps/cli": "^2.11.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.1.14",
"typescript": "~5.8.2"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
import './agent-runtime-real-e2e/entry.mjs';
@@ -0,0 +1,176 @@
import { createHash } from '../dependencies.mjs';
import { isPlainObject } from '../harness/config.mjs';
import {
interactiveCliOutput,
writeInteractiveCliLine,
} from '../harness/process.mjs';
import {
shutdownWaiters,
state,
userInputAnswerText,
} from '../runtime-state.mjs';
import { disposableProjectPathVariants } from './runtime.mjs';
export async function answerRemainingInteractiveQuestions(session) {
let answeredPromptCount = 1;
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
const output = interactiveCliOutput(session);
if (output.includes(`[\u5df2\u56de\u7b54] ${state.userInput.requestId}`))
return;
const promptCount = output.split('或直接输入其他答案:').length - 1;
while (answeredPromptCount < promptCount && answeredPromptCount < 3) {
writeInteractiveCliLine(session, userInputAnswerText);
answeredPromptCount += 1;
}
if (output.includes('[待确认]')) {
throw codedError('user-input-unexpected-tool-confirmation');
}
if (session.closed) throw codedError('user-input-cli-closed-before-answer');
await sleep(100);
}
throw codedError('user-input-answer-timeout');
}
export function parseSingleSwarmTurnReport(output, codePrefix) {
const reportLines = output
.split(/\r?\n/u)
.filter((line) => line.startsWith('[turn.report] '));
assert(reportLines.length === 1, `${codePrefix}-turn-report-count-invalid`);
const report = JSON.parse(reportLines[0].slice('[turn.report] '.length));
assert(
isPlainObject(report) &&
JSON.stringify(Object.keys(report).sort()) ===
JSON.stringify(
[
'schemaVersion',
'outcome',
'parentAgentId',
'sessionId',
'parentRunId',
'runtimeCount',
'busyRuntimeCount',
'pendingTaskCount',
'runningTaskCount',
'waitingForConfirmationCount',
'waitingForUserInputCount',
'newAssistantMessageCount',
'finalReplyChars',
'reconciliationAgentCount',
].sort(),
),
`${codePrefix}-turn-report-shape-invalid`,
);
return report;
}
export function isFailedTask(task) {
return (
['failed', 'cancelled', 'budget-exhausted'].includes(task.status) ||
['failed', 'cancelled', 'budget-exhausted'].includes(task.phase)
);
}
export function prerequisiteLabel(name) {
return {
llmConfigured: 'LLM',
chromeAvailable: 'Chrome/Chromium/Edge',
editorApiConfigured: 'editorApi',
}[name];
}
export function recordError(code, error) {
const detail =
error instanceof Error
? `${error.name}:${error.message}`
: String(error ?? code);
state.errors.push({
code,
detailHash: hashValue(redactSecrets(detail)),
safeFailureDiagnostic: error?.safeFailureDiagnostic ?? null,
});
}
export function summarizeRecordedError(error) {
const diagnostic = error.safeFailureDiagnostic;
return {
code: error.code,
detailHash: error.detailHash,
...(diagnostic
? {
failureKind: diagnostic.failureKind,
exitCode: diagnostic.exitCode,
signal: diagnostic.signal,
processErrorCode: diagnostic.processErrorCode,
stderrChars: diagnostic.stderrChars,
stderrSha256: diagnostic.stderrSha256,
}
: {}),
};
}
export function redactSecrets(value) {
let result = value;
for (const secret of state.secrets)
result = result.split(secret).join('[REDACTED]');
if (state.options?.configDir)
result = result.split(state.options.configDir).join('[CONFIG_DIR]');
for (const projectPath of disposableProjectPathVariants()) {
result = result.split(projectPath).join('[PROJECT]');
}
return result;
}
export function hashValue(value) {
if (!value) return null;
return createHash('sha256')
.update(Buffer.isBuffer(value) ? value : String(value))
.digest('hex');
}
export function canonicalJsonValue(value) {
if (Array.isArray(value)) return value.map(canonicalJsonValue);
if (!isPlainObject(value)) return value;
return Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, canonicalJsonValue(value[key])]),
);
}
export function hashJsonValue(value) {
return hashValue(JSON.stringify(canonicalJsonValue(value)));
}
export function codedError(code, cause) {
const error = new Error(code, cause ? { cause } : undefined);
error.code = code;
return error;
}
export function assert(condition, code) {
if (!condition) throw codedError(code);
}
export function throwIfShutdownRequested() {
if (state.shutdownSignal && !state.cleanupInProgress) {
throw codedError(`interrupted-${state.shutdownSignal.toLowerCase()}`);
}
}
export function sleep(milliseconds) {
throwIfShutdownRequested();
return new Promise((resolve, reject) => {
const finish = () => {
shutdownWaiters.delete(interrupt);
resolve();
};
const timer = setTimeout(finish, milliseconds);
const interrupt = () => {
clearTimeout(timer);
shutdownWaiters.delete(interrupt);
reject(codedError(`interrupted-${state.shutdownSignal.toLowerCase()}`));
};
shutdownWaiters.add(interrupt);
});
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,15 @@
export { startLlmTransientFaultProxy } from '../llm-transient-fault-proxy.mjs';
export { withLoopbackNoProxy } from '../llm-transient-fault-proxy.mjs';
export { buildProcessSessionFixtureSource } from '../process-session-real-e2e-fixture.mjs';
export { spawn } from 'node:child_process';
export { createHash } from 'node:crypto';
export { randomUUID } from 'node:crypto';
export { constants as fsConstants } from 'node:fs';
export { createReadStream } from 'node:fs';
export { readFileSync } from 'node:fs';
export { watch as watchFileSystem } from 'node:fs';
export { default as fs } from 'node:fs/promises';
export { default as os } from 'node:os';
export { default as path } from 'node:path';
export { fileURLToPath } from 'node:url';
export { TextDecoder } from 'node:util';

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