Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0388fa0912 |
@@ -32,7 +32,7 @@ Prefer `scripts/genarrative_external_api.py` for runnable REST calls. It uses on
|
|||||||
- Use stable references such as `objectKey`, project resource ID, or asset ID where each operation permits them. Image edit/redraw is stricter: `sourceReferenceId` accepts only a registered project resource ID or asset ID; upload confirmation alone is not enough. Use `/assets/read-url` only for temporary preview/download access.
|
- Use stable references such as `objectKey`, project resource ID, or asset ID where each operation permits them. Image edit/redraw is stricter: `sourceReferenceId` accepts only a registered project resource ID or asset ID; upload confirmation alone is not enough. Use `/assets/read-url` only for temporary preview/download access.
|
||||||
- Preserve both warning channels after completion. A general `warning` can coexist with `sliceWarning`; do not discard either.
|
- Preserve both warning channels after completion. A general `warning` can coexist with `sliceWarning`; do not discard either.
|
||||||
- Do not invent missing derivatives. A source-preserved warning means the main source remains usable but requested post-processing failed. A slice warning means the complete transparent sheet is usable but individual slices are absent.
|
- Do not invent missing derivatives. A source-preserved warning means the main source remains usable but requested post-processing failed. A slice warning means the complete transparent sheet is usable but individual slices are absent.
|
||||||
- Icon spritesheet generation requires an explicit `sliceMode` and has no default. Use `sliceMode="grid"` with the `gridX` and `gridY` the requirement actually names (1-32 each) only for equal grid cells or fixed slots; use `sliceMode="connected-components"` for free-form sheets or an open number of subjects, and constrain the count with `sliceCount` instead of inventing grid dimensions. `connected-components` must not carry `gridX`/`gridY`; an omitted, contradictory, or misapplied declaration returns 400 before billing.
|
- Icon spritesheet generation accepts `sliceMode="connected-components"` (default alpha-connectivity detection) or `sliceMode="grid"`. Grid mode requires `gridX` and `gridY` (1-32); use `sliceCount` only to constrain connected-component output.
|
||||||
- For successful `style="pixelArt"`, treat completed-result and nested resource/asset dimensions as the final logical-grid PNG dimensions. They may differ from `size`, `imageSize`, the provider image, and `canvasCompletion.placeholder`; do not rescale or reject the artifact to match those inputs.
|
- For successful `style="pixelArt"`, treat completed-result and nested resource/asset dimensions as the final logical-grid PNG dimensions. They may differ from `size`, `imageSize`, the provider image, and `canvasCompletion.placeholder`; do not rescale or reject the artifact to match those inputs.
|
||||||
- Keep generated artifacts in the canvas and asset library together. Character animation accepts `assetFolderId` and `assetLabel`; its completed result directly returns the final `assetKind="character-animation"` resource and asset with formal sequence fields. Do not create a duplicate first-frame record.
|
- Keep generated artifacts in the canvas and asset library together. Character animation accepts `assetFolderId` and `assetLabel`; its completed result directly returns the final `assetKind="character-animation"` resource and asset with formal sequence fields. Do not create a duplicate first-frame record.
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ For image edit/redraw, confirming an upload is not sufficient: create a project
|
|||||||
|
|
||||||
The icon-spritesheet primary `referenceId` is intentionally stricter than ordinary image references: it accepts only a current-owner project resource ID or asset ID whose authoritative `assetKind` is `icon-spec`. It does not accept an `objectKey`, URL, Data URL, or Blob URL.
|
The icon-spritesheet primary `referenceId` is intentionally stricter than ordinary image references: it accepts only a current-owner project resource ID or asset ID whose authoritative `assetKind` is `icon-spec`. It does not accept an `objectKey`, URL, Data URL, or Blob URL.
|
||||||
|
|
||||||
`sliceMode` is required and has no default, so every request must state it. Use `"connected-components"` to detect independent opaque regions by alpha connectivity, or `"grid"` with positive `gridX` and `gridY` values (maximum 32 each) only when the requirement names equal grid cells or fixed slots; the dimensions must come from that requirement. `connected-components` must not carry `gridX`/`gridY`, and `sliceCount` constrains the connected-component result instead of expressing a grid. Omitting `sliceMode`, or contradicting the declared mode with grid dimensions, returns 400 before pricing, enqueueing, or any provider call.
|
`sliceMode` controls atlas splitting. Use `"connected-components"` (default) to detect independent opaque regions by alpha connectivity, or `"grid"` with positive `gridX` and `gridY` values (maximum 32 each). `sliceCount` optionally constrains the connected-component result.
|
||||||
|
|
||||||
## Common Values
|
## Common Values
|
||||||
|
|
||||||
|
|||||||
@@ -79,9 +79,9 @@ Keep the existing autonomous-build task graph. Do not add a parallel task system
|
|||||||
|
|
||||||
1. `art-director` generates `assets/art-spec.png` with image generation, `kind: "spec"`, then registers it as `assetKind: "icon-spec"`. This image is the authoritative visual spec; `generationInputs.artSpec` is supporting structured context.
|
1. `art-director` generates `assets/art-spec.png` with image generation, `kind: "spec"`, then registers it as `assetKind: "icon-spec"`. This image is the authoritative visual spec; `generationInputs.artSpec` is supporting structured context.
|
||||||
2. `design-foundation` generates `assets/ui-prototype.png` with `kind: "ui-design"`, using the registered art-spec resource ID in `referenceImageSrcs`.
|
2. `design-foundation` generates `assets/ui-prototype.png` with `kind: "ui-design"`, using the registered art-spec resource ID in `referenceImageSrcs`.
|
||||||
3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. `sliceMode` is required and has no default: send `sliceMode: "grid"` with `gridX`/`gridY` only when the requirement itself fixes the slots or names the column/row count, and otherwise send `sliceMode: "connected-components"` (with `sliceCount` when a subject count must be constrained); never invent a grid to express "kinds of assets", and never send `gridX`/`gridY` with `connected-components`.
|
3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. For a fixed four-category game contract it may send `sliceMode: "grid"`; for free-form assets use `sliceMode: "connected-components"` (the default).
|
||||||
|
|
||||||
For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. When the requirement fixes grid slots, require the response `sliceMode` to match the declared `grid` request and exactly `gridX × gridY` slices before registering the local runtime sheet; a connected-components request is instead judged by its own `sliceCount` or by the requirement, and both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet `<img>`, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation.
|
For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. When using the fixed four-category contract, require response `sliceMode: "grid"` and exactly four slices before registering the local runtime sheet; both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet `<img>`, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation.
|
||||||
|
|
||||||
Never use `assets/ui-prototype.png` as the spritesheet visual-spec reference. UI extraction is outside this canonical DAG.
|
Never use `assets/ui-prototype.png` as the spritesheet visual-spec reference. UI extraction is outside this canonical DAG.
|
||||||
|
|
||||||
|
|||||||
@@ -617,7 +617,6 @@ class GenarrativeExternalClient:
|
|||||||
self,
|
self,
|
||||||
reference_id: str,
|
reference_id: str,
|
||||||
icon_descriptions: list[str],
|
icon_descriptions: list[str],
|
||||||
slice_mode: str,
|
|
||||||
**fields: Any,
|
**fields: Any,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
reference_id = normalize_optional_text(reference_id)
|
reference_id = normalize_optional_text(reference_id)
|
||||||
@@ -626,20 +625,6 @@ class GenarrativeExternalClient:
|
|||||||
descriptions = [item.strip() for item in icon_descriptions if item.strip()]
|
descriptions = [item.strip() for item in icon_descriptions if item.strip()]
|
||||||
if not descriptions:
|
if not descriptions:
|
||||||
raise GenarrativeApiError("icon_descriptions must contain at least one non-empty item")
|
raise GenarrativeApiError("icon_descriptions must contain at least one non-empty item")
|
||||||
slice_mode = normalize_optional_text(slice_mode)
|
|
||||||
if slice_mode not in ("connected-components", "grid"):
|
|
||||||
raise GenarrativeApiError(
|
|
||||||
"slice_mode must be declared explicitly as 'connected-components' or 'grid'; the API has no default"
|
|
||||||
)
|
|
||||||
grid_x = fields.get("gridX")
|
|
||||||
grid_y = fields.get("gridY")
|
|
||||||
if slice_mode == "grid":
|
|
||||||
if grid_x is None or grid_y is None:
|
|
||||||
raise GenarrativeApiError("slice_mode='grid' requires both gridX and gridY")
|
|
||||||
elif grid_x is not None or grid_y is not None:
|
|
||||||
raise GenarrativeApiError(
|
|
||||||
"slice_mode='connected-components' must not carry gridX/gridY"
|
|
||||||
)
|
|
||||||
label = fields.get("assetLabel", "图标图集")
|
label = fields.get("assetLabel", "图标图集")
|
||||||
self._apply_canvas_session_fields(fields, label, 1024, 1024)
|
self._apply_canvas_session_fields(fields, label, 1024, 1024)
|
||||||
fields.setdefault("screenColor", "auto")
|
fields.setdefault("screenColor", "auto")
|
||||||
@@ -650,7 +635,6 @@ class GenarrativeExternalClient:
|
|||||||
**fields,
|
**fields,
|
||||||
"referenceId": reference_id,
|
"referenceId": reference_id,
|
||||||
"iconDescriptions": descriptions,
|
"iconDescriptions": descriptions,
|
||||||
"sliceMode": slice_mode,
|
|
||||||
},
|
},
|
||||||
idempotency_key=idempotency_key,
|
idempotency_key=idempotency_key,
|
||||||
)
|
)
|
||||||
@@ -895,9 +879,9 @@ def _self_test() -> None:
|
|||||||
client.generate_icon_spritesheet(
|
client.generate_icon_spritesheet(
|
||||||
"editor-resource-spec",
|
"editor-resource-spec",
|
||||||
["蛇头向上", "蛇身直线", "转角", "尾部", "四类食物"],
|
["蛇头向上", "蛇身直线", "转角", "尾部", "四类食物"],
|
||||||
"connected-components",
|
|
||||||
canvasSession=session,
|
canvasSession=session,
|
||||||
assetLabel="贪吃蛇透明图集",
|
assetLabel="贪吃蛇透明图集",
|
||||||
|
sliceMode="connected-components",
|
||||||
referenceId="must-not-override-explicit-reference",
|
referenceId="must-not-override-explicit-reference",
|
||||||
iconDescriptions=["不得覆盖显式图标描述"],
|
iconDescriptions=["不得覆盖显式图标描述"],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,5 +4,3 @@
|
|||||||
src/components/AdventurePanel.tsx
|
src/components/AdventurePanel.tsx
|
||||||
src/data/customWorldCharacterLoadout.ts
|
src/data/customWorldCharacterLoadout.ts
|
||||||
dist_check_monster_position/**
|
dist_check_monster_position/**
|
||||||
# 固定上游UTF-8测试刻意包含U+FFFD;upstream-integrity.test.mjs逐字节验证来源hash。
|
|
||||||
apps/ai-game-creator-shell/src-tauri/vendor/codex-utils-path-uri/src/api_path_string_tests.rs
|
|
||||||
|
|||||||
@@ -158,15 +158,6 @@ ALIYUN_OSS_POST_EXPIRE_SECONDS="600"
|
|||||||
ALIYUN_OSS_POST_MAX_SIZE_BYTES="20971520"
|
ALIYUN_OSS_POST_MAX_SIZE_BYTES="20971520"
|
||||||
ALIYUN_OSS_SUCCESS_ACTION_STATUS="200"
|
ALIYUN_OSS_SUCCESS_ACTION_STATUS="200"
|
||||||
|
|
||||||
# AGC 项目定时快照上传目标。对象只落在服务端私有前缀
|
|
||||||
# `agc/project-snapshots/v1/{user}/{project}/` 下,客户端直传票据不覆盖该前缀。
|
|
||||||
# bucket 与凭据可以与资源 bucket 分离;凭据未设置时回退使用 ALIYUN_OSS_ACCESS_KEY_*,
|
|
||||||
# 但 bucket / endpoint 默认指向 AGC 发行 bucket,需要该凭据具备目标 bucket 的 PutObject 权限。
|
|
||||||
GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET="agc-dev"
|
|
||||||
GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT="oss-rg-china-mainland.aliyuncs.com"
|
|
||||||
GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID=""
|
|
||||||
GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET=""
|
|
||||||
|
|
||||||
# BgFilter 受限资源 worker。父 api-server / external-generation-worker 与唯一的
|
# BgFilter 受限资源 worker。父 api-server / external-generation-worker 与唯一的
|
||||||
# `GENARRATIVE_PROCESS_ROLE=bgfilter-worker` 进程必须使用同一个内部 Token。
|
# `GENARRATIVE_PROCESS_ROLE=bgfilter-worker` 进程必须使用同一个内部 Token。
|
||||||
# `npm run dev` 与 `npm run dev:api-server` 都会自动带起并验活唯一 worker,不要再开第二个终端重复启动。
|
# `npm run dev` 与 `npm run dev:api-server` 都会自动带起并验活唯一 worker,不要再开第二个终端重复启动。
|
||||||
@@ -235,10 +226,6 @@ VITE_DEBUG_MODE=""
|
|||||||
# This is read by api-server and exposed through /api/runtime/frontend-config.
|
# This is read by api-server and exposed through /api/runtime/frontend-config.
|
||||||
GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false"
|
GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false"
|
||||||
|
|
||||||
# 官网客户端下载检测渠道:dev、release 或自定义渠道;修改后重启 API 服务。
|
|
||||||
# Windows/macOS 是系统维度,不填写 dev-win/dev-mac。
|
|
||||||
GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL="dev"
|
|
||||||
|
|
||||||
# Optional: official VikingDB credentials for regenerating build-tag similarities
|
# Optional: official VikingDB credentials for regenerating build-tag similarities
|
||||||
# with the Python embedding script. The script auto-loads `.env.local` and uses
|
# with the Python embedding script. The script auto-loads `.env.local` and uses
|
||||||
# the fixed `bge-large-zh` embedding model.
|
# the fixed `bge-large-zh` embedding model.
|
||||||
|
|||||||
@@ -23,6 +23,3 @@
|
|||||||
*.meta text
|
*.meta text
|
||||||
*.anim text
|
*.anim text
|
||||||
*.controller text
|
*.controller text
|
||||||
|
|
||||||
# Rust ts-rs 生成的共享契约:保留在仓库中供 TS 消费,但不作为手写源文件统计。
|
|
||||||
packages/shared/src/contracts/generated/** linguist-generated=true
|
|
||||||
|
|||||||
@@ -31,26 +31,28 @@ env:
|
|||||||
RUSTC_WRAPPER: ''
|
RUSTC_WRAPPER: ''
|
||||||
CARGO_BUILD_RUSTC_WRAPPER: ''
|
CARGO_BUILD_RUSTC_WRAPPER: ''
|
||||||
|
|
||||||
# job 声明顺序就是 runner 领取顺序,因此把两条最长尾的客户端 Rust lane 排在前面,
|
# job 声明顺序就是 runner 领取顺序,因此把最长尾的客户端 Rust 门禁排在前面,
|
||||||
# 让它在最少的等待下占用并发槽位;其余 job 按时长递减排列。
|
# 让它在最少的等待下占用并发槽位;其余 job 按时长递减排列。
|
||||||
#
|
#
|
||||||
# 客户端(微信壳 / Expo 移动壳 / Tauri 桌面壳 / AI 游戏创作壳)门禁原先全部串在
|
# 客户端(微信壳 / Expo 移动壳 / Tauri 桌面壳 / AI 游戏创作壳)门禁原先全部串在
|
||||||
# `Native shell tests` 一个 job 里,实测 18 分 37 秒。现在按门禁组拆成一个分组一个 job:
|
# `Native shell tests` 一个 job 里,实测 18 分 37 秒。现在按门禁组拆成一个分组一个 job:
|
||||||
# `Native shell tests`(契约 + H5 / 微信 / 移动 / 桌面壳门禁 + 发布构建 smoke)、
|
# `Native shell tests`(契约 + H5 / 微信 / 移动 / 桌面壳门禁 + 发布构建 smoke)、
|
||||||
# `AI game creator shell web tests`(typecheck + 壳内测试)、`AI game creator shell
|
# `AI game creator shell web tests`(typecheck + 壳内测试)、`AI game creator shell
|
||||||
# Rust lane 1/2`、`lane 2/2`(每条 lane 顺序运行两片 AGC 壳 bin 单测)、`AI game creator
|
# Rust shard 1/4` 到 `4/4`(AGC 壳 bin 单测按名单分 4 片)、`AI game creator shell
|
||||||
# shell Rust smoke`(agent-run smoke)与 `AI game creator shell Rust crates`(AGC 壳依赖的
|
# Rust smoke`(agent-run smoke)与 `AI game creator shell Rust crates`(AGC 壳依赖的
|
||||||
# 共享 / 平台 crate 测试)。各自的命令与拆分前逐一对应,本地
|
# 共享 / 平台 crate 测试)。各自的命令与拆分前逐一对应,本地
|
||||||
# `npm run check:native-shells` 仍是同一条串行序列。
|
# `npm run check:native-shells` 仍是同一条串行序列。
|
||||||
#
|
#
|
||||||
# AGC 壳的 bin 单测(2466 条)仍按名单分 4 片、片内保持 `--test-threads=1`;两条 lane
|
# AGC 壳的 bin 单测(2466 条)按名单分 4 片、一片一个 job:片内保持 `--test-threads=1`
|
||||||
# 各自顺序运行两片,让一次 job 只做一次依赖预热,同时避免同一容器内多进程并行争抢
|
# (当年线程并行会互相干扰的是进程内后台锁与异步终态),片与片之间靠 job 级并发摊开。
|
||||||
# 共享 HOME、target 与固定临时路径。每个分片调用仍会自校验「片并集等于全集且互斥」。
|
# 不要改回「一个 job 里多进程并行这几片」:同一容器内它们会争抢共享 HOME、target 与固定
|
||||||
|
# 临时路径,实测比整套串行还慢。每个分片 job 都会自校验「片并集等于全集且互斥」。
|
||||||
jobs:
|
jobs:
|
||||||
# AGC 壳自身的 Rust bin 单测由两条 lane 承载,每条 lane 顺序运行两片;这里不装 npm
|
# AGC 壳自身的 Rust bin 单测分片,4 片各自独立 job 并发执行、片内仍保持
|
||||||
# 依赖:壳 Rust 门禁只用 cargo 与 node 内建模块,也只需要 AGC 壳自己那份锁定依赖。
|
# `--test-threads=1`。这里不装 npm 依赖:壳 Rust 门禁只用 cargo 与 node 内建模块,
|
||||||
ai-game-creator-shell-rust-lane-1:
|
# 也只需要 AGC 壳自己那份锁定依赖。
|
||||||
name: AI game creator shell Rust lane 1/2
|
ai-game-creator-shell-rust-shard-1:
|
||||||
|
name: AI game creator shell Rust shard 1/4
|
||||||
runs-on: genarrative-ci
|
runs-on: genarrative-ci
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout full history from Gitea
|
- name: Checkout full history from Gitea
|
||||||
@@ -85,11 +87,41 @@ jobs:
|
|||||||
- name: Run AI game creator shell Rust shard 1/4
|
- name: Run AI game creator shell Rust shard 1/4
|
||||||
run: npm run check:native-shells:agc-rust-shard-1
|
run: npm run check:native-shells:agc-rust-shard-1
|
||||||
|
|
||||||
|
ai-game-creator-shell-rust-shard-2:
|
||||||
|
name: AI game creator shell Rust shard 2/4
|
||||||
|
runs-on: genarrative-ci
|
||||||
|
steps:
|
||||||
|
- name: Checkout full history from Gitea
|
||||||
|
env:
|
||||||
|
GENARRATIVE_GITEA_FETCH_DEPTH: '0'
|
||||||
|
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
|
||||||
|
run: genarrative-gitea-checkout
|
||||||
|
|
||||||
|
- name: Validate preinstalled CI job image and sandbox
|
||||||
|
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
||||||
|
|
||||||
|
- name: Prepare AI game creator shell 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 apps/ai-game-creator-shell/src-tauri/Cargo.toml; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [[ "${attempt}" -eq 5 ]]; then
|
||||||
|
echo 'AI game creator shell Cargo dependency fetch failed after 5 attempts.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep $((attempt * 2))
|
||||||
|
done
|
||||||
|
|
||||||
- name: Run AI game creator shell Rust shard 2/4
|
- name: Run AI game creator shell Rust shard 2/4
|
||||||
run: npm run check:native-shells:agc-rust-shard-2
|
run: npm run check:native-shells:agc-rust-shard-2
|
||||||
|
|
||||||
ai-game-creator-shell-rust-lane-2:
|
ai-game-creator-shell-rust-shard-3:
|
||||||
name: AI game creator shell Rust lane 2/2
|
name: AI game creator shell Rust shard 3/4
|
||||||
runs-on: genarrative-ci
|
runs-on: genarrative-ci
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout full history from Gitea
|
- name: Checkout full history from Gitea
|
||||||
@@ -121,6 +153,36 @@ jobs:
|
|||||||
- name: Run AI game creator shell Rust shard 3/4
|
- name: Run AI game creator shell Rust shard 3/4
|
||||||
run: npm run check:native-shells:agc-rust-shard-3
|
run: npm run check:native-shells:agc-rust-shard-3
|
||||||
|
|
||||||
|
ai-game-creator-shell-rust-shard-4:
|
||||||
|
name: AI game creator shell Rust shard 4/4
|
||||||
|
runs-on: genarrative-ci
|
||||||
|
steps:
|
||||||
|
- name: Checkout full history from Gitea
|
||||||
|
env:
|
||||||
|
GENARRATIVE_GITEA_FETCH_DEPTH: '0'
|
||||||
|
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
|
||||||
|
run: genarrative-gitea-checkout
|
||||||
|
|
||||||
|
- name: Validate preinstalled CI job image and sandbox
|
||||||
|
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
||||||
|
|
||||||
|
- name: Prepare AI game creator shell 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 apps/ai-game-creator-shell/src-tauri/Cargo.toml; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [[ "${attempt}" -eq 5 ]]; then
|
||||||
|
echo 'AI game creator shell Cargo dependency fetch failed after 5 attempts.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep $((attempt * 2))
|
||||||
|
done
|
||||||
|
|
||||||
- name: Run AI game creator shell Rust shard 4/4
|
- name: Run AI game creator shell Rust shard 4/4
|
||||||
run: npm run check:native-shells:agc-rust-shard-4
|
run: npm run check:native-shells:agc-rust-shard-4
|
||||||
|
|
||||||
@@ -159,7 +221,8 @@ jobs:
|
|||||||
- name: Run AI game creator shell agent-run smoke
|
- name: Run AI game creator shell agent-run smoke
|
||||||
run: npm run check:native-shells:agc-rust-smoke
|
run: npm run check:native-shells:agc-rust-smoke
|
||||||
|
|
||||||
# AGC 壳依赖的共享 / 平台和编辑器插件 crate 各自预热独立 manifest,再运行对应测试。
|
# AGC 壳依赖的共享 / 平台 crate 测试用的是 server-rs workspace 与两个无锁独立 crate
|
||||||
|
# 的 manifest,属另一套依赖图,因此单独一个 job 预热、单独跑。
|
||||||
ai-game-creator-shell-rust-crates:
|
ai-game-creator-shell-rust-crates:
|
||||||
name: AI game creator shell Rust crates
|
name: AI game creator shell Rust crates
|
||||||
runs-on: genarrative-ci
|
runs-on: genarrative-ci
|
||||||
@@ -200,15 +263,13 @@ jobs:
|
|||||||
# `cargo test --manifest-path` 单独跑这两个 crate。不在这里预热的话,这两条测试
|
# `cargo test --manifest-path` 单独跑这两个 crate。不在这里预热的话,这两条测试
|
||||||
# 会在测试阶段自己 `Updating crates.io index`,crates.io 一抖动整条 job 就红
|
# 会在测试阶段自己 `Updating crates.io index`,crates.io 一抖动整条 job 就红
|
||||||
# (见 #327 / PR #316 run 1950)。
|
# (见 #327 / PR #316 run 1950)。
|
||||||
# Cocos 插件也使用独立且未提交的锁文件,一并预热。
|
# 两个 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch:
|
||||||
# 这些 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch:
|
|
||||||
# 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内,
|
# 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内,
|
||||||
# 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本
|
# 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本
|
||||||
# 解析,不再触碰 registry index。
|
# 解析,不再触碰 registry index。
|
||||||
for manifest_path in \
|
for manifest_path in \
|
||||||
server-rs/crates/agent-runtime-core/Cargo.toml \
|
server-rs/crates/agent-runtime-core/Cargo.toml \
|
||||||
server-rs/crates/agent-runtime-orchestration/Cargo.toml \
|
server-rs/crates/agent-runtime-orchestration/Cargo.toml; do
|
||||||
plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml; do
|
|
||||||
for attempt in $(seq 1 5); do
|
for attempt in $(seq 1 5); do
|
||||||
if cargo fetch \
|
if cargo fetch \
|
||||||
--target x86_64-unknown-linux-gnu \
|
--target x86_64-unknown-linux-gnu \
|
||||||
@@ -223,40 +284,6 @@ jobs:
|
|||||||
done
|
done
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Prepare Unity plugin 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 plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [[ "${attempt}" -eq 5 ]]; then
|
|
||||||
echo 'Unity plugin Cargo dependency fetch failed after 5 attempts.' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep $((attempt * 2))
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Prepare Godot plugin 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 plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [[ "${attempt}" -eq 5 ]]; then
|
|
||||||
echo 'Godot plugin Cargo dependency fetch failed after 5 attempts.' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep $((attempt * 2))
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Run AI game creator shell shared crate gates
|
- name: Run AI game creator shell shared crate gates
|
||||||
run: npm run check:native-shells:agc-rust-crates
|
run: npm run check:native-shells:agc-rust-crates
|
||||||
|
|
||||||
|
|||||||
-14
@@ -41,22 +41,8 @@ temp*build*/
|
|||||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-resources/
|
/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-resources/
|
||||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-package.json
|
/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-package.json
|
||||||
/apps/ai-game-creator-shell/src-tauri/resources/plugins/
|
/apps/ai-game-creator-shell/src-tauri/resources/plugins/
|
||||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/bin/
|
|
||||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/codex-path/
|
|
||||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/codex-resources/
|
|
||||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/codex-package.json
|
|
||||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/manifest.json
|
|
||||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/NOTICE.md
|
|
||||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/darwin-arm64/
|
|
||||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/darwin-x64/
|
|
||||||
/plugins/agc-cocos-editor/native/payload/
|
/plugins/agc-cocos-editor/native/payload/
|
||||||
/plugins/agc-unity-editor/dotnet/**/bin/
|
|
||||||
/plugins/agc-unity-editor/dotnet/**/obj/
|
|
||||||
/plugins/agc-unity-editor/dotnet/publish/
|
|
||||||
/plugins/agc-unity-editor/dotnet/native-build/
|
|
||||||
/apps/ai-game-creator-shell/logs/
|
/apps/ai-game-creator-shell/logs/
|
||||||
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime/
|
|
||||||
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime-staging-*/
|
|
||||||
/apps/ai-game-creator-shell/.llm-drafts/
|
/apps/ai-game-creator-shell/.llm-drafts/
|
||||||
/apps/ai-game-creator-shell/game-creator.config.local.json
|
/apps/ai-game-creator-shell/game-creator.config.local.json
|
||||||
/apps/mobile-shell/.expo/
|
/apps/mobile-shell/.expo/
|
||||||
|
|||||||
+1
-10
@@ -1,14 +1,5 @@
|
|||||||
{
|
{
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"semi": true,
|
"semi": true,
|
||||||
"trailingComma": "all",
|
"trailingComma": "all"
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"files": "packages/shared/src/contracts/generated/**/*.ts",
|
|
||||||
"options": {
|
|
||||||
"printWidth": 1000,
|
|
||||||
"singleQuote": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,12 @@
|
|||||||
|
|
||||||
- 本文件只保留 Agent 进入仓库后必须立即遵守的最高优先级规则;完整执行细则见 [`docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md`](docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md)。
|
- 本文件只保留 Agent 进入仓库后必须立即遵守的最高优先级规则;完整执行细则见 [`docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md`](docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md)。
|
||||||
- 团队级长期项目记忆位于 [`docs/project-memory/`](docs/project-memory/),供 3 名开发人员和各自本地 Agent 通过 Git 同步。
|
- 团队级长期项目记忆位于 [`docs/project-memory/`](docs/project-memory/),供 3 名开发人员和各自本地 Agent 通过 Git 同步。
|
||||||
- [`.codex/`](.codex/) 保存仓库级 Codex 工具资源,例如 skills、plugins、hooks 和配置模板;长期项目知识写入 `docs/` 与 `docs/project-memory/`。
|
- [`.codex/`](.codex/) 只保存仓库级 Codex 工具资源,例如 skills、plugins、hooks 和配置模板;长期项目知识不要写入 `.codex/`。
|
||||||
- 若 `docs/project-memory/shared-memory/` 与当前代码或最新 `docs/` 冲突,以代码和最新 `docs/` 为准,并同步修正过期共享记忆。
|
- 若 `docs/project-memory/shared-memory/` 与当前代码或最新 `docs/` 冲突,以代码和最新 `docs/` 为准,并同步修正过期共享记忆。
|
||||||
|
|
||||||
## 开始任务前
|
## 开始任务前
|
||||||
|
|
||||||
- 先写清一句话交付结果、验收判据和修改范围,再按“必须项 / 风险项 / 可选项”排序;优先完成修改、定向验证和边界检查组成的最小闭环。设置时间盒和检查点,新增发现只有在影响交付判据时才扩大范围,其余记录为后续事项。
|
- 先写清一句话交付结果、验收判据和不做项,再按“必须项 / 风险项 / 可选项”排序;优先完成修改、定向验证和边界检查组成的最小闭环。设置时间盒和检查点,新增发现只有在影响交付判据时才扩大范围,否则记录为后续事项;工具探测、历史整理或验证便利不能自行改变任务目标。
|
||||||
- Agent 可见内容直接描述当前任务、输入和成功条件,细节按调用需要提供。
|
|
||||||
- 简单自包含任务可以直接执行;复杂开发、跨模块修改、后端 / UI / 文档体系调整前,按顺序读取:
|
- 简单自包含任务可以直接执行;复杂开发、跨模块修改、后端 / UI / 文档体系调整前,按顺序读取:
|
||||||
1. 本文件。
|
1. 本文件。
|
||||||
2. [`docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md`](docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md)。
|
2. [`docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md`](docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md)。
|
||||||
@@ -24,20 +23,24 @@
|
|||||||
|
|
||||||
- 禁止提交个人 `~/.codex` 配置、`.env`、API Key、Token、Cookie、会话记录、认证文件、本地私密路径、构建产物、日志、缓存和数据库 dump。
|
- 禁止提交个人 `~/.codex` 配置、`.env`、API Key、Token、Cookie、会话记录、认证文件、本地私密路径、构建产物、日志、缓存和数据库 dump。
|
||||||
- 不要在 `.gitignore` 中新增 `.env.local`。
|
- 不要在 `.gitignore` 中新增 `.env.local`。
|
||||||
- 现有中文文案、注释、剧情和文档保持中文;看到乱码时先确认真实编码并恢复正确中文。
|
- 不要擅自把现有中文文案、注释、剧情或文档改写成英文;看到中文乱码时先确认真实编码,不要沿用乱码或用英文替换。
|
||||||
- 修改包含中文的文件时优先局部补丁;修改后优先运行仓库编码检查。
|
- 修改包含中文的文件时优先局部补丁,避免整文件重写;修改后优先运行仓库编码检查。
|
||||||
- 新增 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`。
|
- 后续新增 Markdown 文档文件名必须以分类标签开头,格式为 `【标签名】中文标题-日期.md`;历史文档不要求批量重命名,除非本次任务明确涉及。
|
||||||
- 工程修改要同步更新对应 `docs/` 文档;产生长期有效的架构约定、接口变化、排障经验、开发流程或协作规则时,同步更新 `docs/project-memory/shared-memory/`。
|
- 工程修改要同步更新对应 `docs/` 文档;产生长期有效的架构约定、接口变化、排障经验、开发流程或协作规则时,同步更新 `docs/project-memory/shared-memory/`。
|
||||||
- 默认保持系统简洁:优先复用、修改、扩展现有系统、页面和公共组件。
|
- 默认保持系统简洁:优先复用、修改、扩展现有系统、页面和公共组件,不新建平行系统或平行页面。
|
||||||
- UI 开发优先复用现有公共组件;发现跨页面或跨端重复的视觉/交互模式时,先抽取到 `packages/shared` 共享组件库并让现有页面迁移使用。共享组件承载通用表现与交互,领域规则、后端副作用和正式业务状态由后端负责。
|
- UI 开发优先复用现有公共组件;发现跨页面或跨端重复的视觉/交互模式时,先抽取到 `packages/shared` 共享组件库并让现有页面迁移使用,禁止在业务页复制同类 UI。共享组件只承载通用表现与交互,不下沉领域规则、后端副作用或正式业务状态。
|
||||||
- 对已明确退役且不存在现役调用方、公开契约、持久化数据、活跃实例或迁移要求的对象,直接清理实现、专属测试和说明,将权威文档更新为当前状态;历史由 Git 保存。
|
- 对已明确退役且不存在现役调用方、公开契约、持久化数据、活跃实例或迁移要求的对象,坚持“四不写”:
|
||||||
- 公开 API、持久化数据、SpacetimeDB schema、跨版本重放、活跃实例和正式迁移按实际需求保留最小、白名单化的兼容,并配套契约或迁移测试;迁移完成后同步删除兼容实现与对应测试。
|
1. 不写历史兼容代码。
|
||||||
- UI 面板优先呈现任务内容与操作,说明按当前操作需要提供;移动端优先,同时保证网页端可正常显示和操作。
|
2. 不写用于维持退役行为的防御性兼容测试。
|
||||||
- 点击按钮弹出的独立面板使用弹窗、抽屉、popover 或页面级 portal。
|
3. 不写仅说明其曾存在或已删除的墓碑注释。
|
||||||
|
4. 不写仅记录其已删除的墓碑文档;直接将权威文档更新为当前状态。
|
||||||
|
- 公开 API、持久化数据、SpacetimeDB schema、跨版本重放、活跃实例和正式迁移不适用“四不写”;必要兼容应最小化、白名单化并配套契约或迁移测试,迁移完成后同步删除兼容实现与对应测试。
|
||||||
|
- UI 面板中不要默认写功能说明、规则描述或开发解释文案;移动端优先,同时保证网页端可正常显示和操作。
|
||||||
|
- 点击按钮弹出独立面板的设计,不要实现成在当前面板下面追加内容。
|
||||||
|
|
||||||
## 任务路由
|
## 任务路由
|
||||||
|
|
||||||
- Issue 使用自托管 Gitea;优先用 Gitea UI/API 或 `tea` CLI。默认 triage 标签:`needs-triage`、`needs-info`、`ready-for-agent`、`ready-for-human`、`wontfix`。
|
- Issue 使用自托管 Gitea;优先用 Gitea UI/API 或 `tea` CLI,不使用 GitHub `gh` 或 GitLab `glab`,除非仓库已迁移。默认 triage 标签:`needs-triage`、`needs-info`、`ready-for-agent`、`ready-for-human`、`wontfix`。
|
||||||
- 需要仓库级 Codex skills/plugins 时,再读取 [`.codex/README.md`](.codex/README.md)。
|
- 需要仓库级 Codex skills/plugins 时,再读取 [`.codex/README.md`](.codex/README.md)。
|
||||||
- 涉及 AI 游戏创作独立 App、多智能体 Runtime、本地项目产物或本地 HTTP 预览时,先读取 [`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`](docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.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) 执行。
|
- 新增、补齐、迁移或重构玩法入口、玩法类型、创作工作台、生成页、结果页、发布、运行态、作品架、广场或公开 read model 前,必须读取并按 [`genarrative-play-type-integration`](.codex/skills/genarrative-play-type-integration/SKILL.md) 执行。
|
||||||
@@ -47,10 +50,10 @@
|
|||||||
## 后端红线
|
## 后端红线
|
||||||
|
|
||||||
- 后端最新技术约束以 [`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`](docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md) 为准。
|
- 后端最新技术约束以 [`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`](docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md) 为准。
|
||||||
- 后端路线固定为 `server-rs + Axum + SpacetimeDB`。
|
- 后端路线固定为 `server-rs + Axum + SpacetimeDB`;旧 `server-node`、Express、PostgreSQL、Go 服务端、`maincloud` / `Maincloud` / `MAINCLOUD` 只作为历史残留,不作为兼容目标。
|
||||||
- DDD 分层边界按总纲执行:领域规则沉到 `module-*`,SpacetimeDB 表和事务编排留在 `spacetime-module`,后端访问 SpacetimeDB 统一经 `spacetime-client` facade,HTTP/SSE/BFF 留在 `api-server`,外部副作用留在 `platform-*`,前后端 DTO 留在 `shared-contracts`。
|
- DDD 分层边界按总纲执行:领域规则沉到 `module-*`,SpacetimeDB 表和事务编排留在 `spacetime-module`,后端访问 SpacetimeDB 统一经 `spacetime-client` facade,HTTP/SSE/BFF 留在 `api-server`,外部副作用留在 `platform-*`,前后端 DTO 留在 `shared-contracts`。
|
||||||
- 前端负责表现、交互和临时 UI 状态;正式业务状态与规则以后端投影和后端 API 为准。
|
- 前端只做表现、交互和临时 UI 状态,不承接正式业务真相,不绕过后端投影或后端 API 直接实现业务规则。
|
||||||
- 契约、路由、DTO 去留和 breaking change 以当前后端架构文档、`server-rs/crates/api-server/src/app.rs`、`shared-contracts` 和 `packages/shared` 为准。
|
- 契约、路由、DTO 去留和 breaking change 以当前后端架构文档、`server-rs/crates/api-server/src/app.rs`、`shared-contracts` 和 `packages/shared` 为准;不得在前端、`api-server` 或临时兼容层中重新发明旧接口。
|
||||||
- 凡修改 `/api/external/v1` 的路由、HTTP 方法、请求 / 响应 DTO、请求头、状态码、鉴权或异步语义,必须在同一次变更中同步更新权威契约 [`docs/openapi/genarrative-external-v1.openapi.json`](docs/openapi/genarrative-external-v1.openapi.json) 及对应契约测试;Rust 实现与 OpenAPI 未保持一致时任务不得视为完成。
|
- 凡修改 `/api/external/v1` 的路由、HTTP 方法、请求 / 响应 DTO、请求头、状态码、鉴权或异步语义,必须在同一次变更中同步更新权威契约 [`docs/openapi/genarrative-external-v1.openapi.json`](docs/openapi/genarrative-external-v1.openapi.json) 及对应契约测试;Rust 实现与 OpenAPI 未保持一致时任务不得视为完成。
|
||||||
- SpacetimeDB 已有表新增字段时,字段必须放在 Rust 表结构体最后,并设置明确默认值;需要删除、改名、重排或改类型时,必须先询问用户并确认迁移计划。
|
- SpacetimeDB 已有表新增字段时,字段必须放在 Rust 表结构体最后,并设置明确默认值;需要删除、改名、重排或改类型时,必须先询问用户并确认迁移计划。
|
||||||
- 修改 SpacetimeDB schema 后必须同步 `migration.rs`、表目录和生成绑定,并运行 `npm run check:spacetime-schema`。
|
- 修改 SpacetimeDB schema 后必须同步 `migration.rs`、表目录和生成绑定,并运行 `npm run check:spacetime-schema`。
|
||||||
|
|||||||
-16
@@ -172,20 +172,6 @@ _Avoid_: 多步骤向导、完整规则编辑器、拖拽编辑器
|
|||||||
Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。
|
Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。
|
||||||
_Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
|
_Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
|
||||||
|
|
||||||
## 项目开发对话(DirectProject)
|
|
||||||
|
|
||||||
**项目对话历史**:
|
|
||||||
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
|
|
||||||
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
|
|
||||||
|
|
||||||
**运行态事件**:
|
|
||||||
Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。
|
|
||||||
_Avoid_: 进度通知、快照轮询、第二套历史
|
|
||||||
|
|
||||||
**聊天投影**:
|
|
||||||
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
|
|
||||||
_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
|
|
||||||
|
|
||||||
## Relationships
|
## Relationships
|
||||||
|
|
||||||
- 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**。
|
- 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**。
|
||||||
@@ -220,5 +206,3 @@ _Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
|
|||||||
- “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。
|
- “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。
|
||||||
- “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。
|
- “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。
|
||||||
- “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。
|
- “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。
|
||||||
- “回合进度事件”曾同时指 Direct turn update 与 Thread Manager 运行态事件;已解析为 AGC 项目开发对话只保留 **运行态事件**。
|
|
||||||
- “哪些消息可显示”曾可能由后端历史分页判断;已解析为可见性判断属于 **聊天投影**,后端只按原始条目分页,前端负责跳过不可显示条目并推进分页锚点。
|
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ import type {
|
|||||||
AdminLoginResponse,
|
AdminLoginResponse,
|
||||||
AdminMeResponse,
|
AdminMeResponse,
|
||||||
AdminOverviewResponse,
|
AdminOverviewResponse,
|
||||||
AdminProjectSnapshotListQuery,
|
|
||||||
AdminProjectSnapshotListResponse,
|
|
||||||
AdminRechargeOrderListQuery,
|
AdminRechargeOrderListQuery,
|
||||||
AdminRechargeOrderListResponse,
|
AdminRechargeOrderListResponse,
|
||||||
AdminRechargeRefundActionResponse,
|
AdminRechargeRefundActionResponse,
|
||||||
@@ -200,92 +198,6 @@ export function listAdminAccounts(token: string) {
|
|||||||
return request<AdminAccountListResponse>('/admin/api/accounts', { token });
|
return request<AdminAccountListResponse>('/admin/api/accounts', { token });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listAdminProjectSnapshots(
|
|
||||||
token: string,
|
|
||||||
query: AdminProjectSnapshotListQuery = {},
|
|
||||||
signal?: AbortSignal,
|
|
||||||
) {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (query.cursor) params.set('cursor', query.cursor);
|
|
||||||
params.set('limit', String(query.limit ?? 20));
|
|
||||||
return request<AdminProjectSnapshotListResponse>(
|
|
||||||
`/admin/api/project-snapshots?${params.toString()}`,
|
|
||||||
{ token, signal },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function downloadAdminProjectSnapshot(
|
|
||||||
token: string,
|
|
||||||
userId: string,
|
|
||||||
projectId: string,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
) {
|
|
||||||
const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download`;
|
|
||||||
const response = await fetch(buildRequestUrl(path), {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token.trim()}`,
|
|
||||||
Accept: 'application/zip',
|
|
||||||
[API_RESPONSE_ENVELOPE_HEADER]: 'v1',
|
|
||||||
},
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const responseText = await response.text();
|
|
||||||
throw buildAdminApiError(
|
|
||||||
response,
|
|
||||||
parseJsonResponse(responseText),
|
|
||||||
responseText,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const contentType = response.headers
|
|
||||||
.get('content-type')
|
|
||||||
?.split(';')[0]
|
|
||||||
?.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
if (contentType !== 'application/zip') {
|
|
||||||
await response.body?.cancel();
|
|
||||||
throw new AdminApiError({
|
|
||||||
message: '下载失败:服务端未返回 ZIP 工程文件',
|
|
||||||
status: response.status,
|
|
||||||
code: 'INVALID_PROJECT_ARCHIVE_RESPONSE',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
blob: await response.blob(),
|
|
||||||
filename: projectArchiveFilename(
|
|
||||||
response.headers.get('content-disposition'),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function projectArchiveFilename(contentDisposition: string | null): string {
|
|
||||||
const extended = contentDisposition?.match(
|
|
||||||
/(?:^|;)\s*filename\*=UTF-8'[^']*'([^;]+)/i,
|
|
||||||
);
|
|
||||||
const ordinary = contentDisposition?.match(
|
|
||||||
/(?:^|;)\s*filename=(?:"((?:[^"\\]|\\.)*)"|([^;]+))/i,
|
|
||||||
);
|
|
||||||
let filename =
|
|
||||||
ordinary?.[1]?.replace(/\\(.)/g, '$1') ?? ordinary?.[2]?.trim() ?? '';
|
|
||||||
if (extended?.[1]) {
|
|
||||||
try {
|
|
||||||
filename = decodeURIComponent(extended[1].trim());
|
|
||||||
} catch {
|
|
||||||
// 非法扩展编码继续使用普通文件名。
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const safeName = Array.from(filename, (character) => {
|
|
||||||
const code = character.charCodeAt(0);
|
|
||||||
return code < 32 || code === 127 ? '_' : character;
|
|
||||||
})
|
|
||||||
.join('')
|
|
||||||
.replace(/[<>:"/\\|?*]/g, '_')
|
|
||||||
.trim()
|
|
||||||
.replace(/[. ]+$/, '');
|
|
||||||
if (!safeName || safeName.length > 240) return 'project.zip';
|
|
||||||
return /\.zip$/i.test(safeName) ? safeName : `${safeName}.zip`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createAdminAccount(
|
export function createAdminAccount(
|
||||||
token: string,
|
token: string,
|
||||||
payload: AdminCreateAccountRequest,
|
payload: AdminCreateAccountRequest,
|
||||||
|
|||||||
@@ -96,27 +96,6 @@ export interface AdminMeResponse {
|
|||||||
admin: AdminSessionPayload;
|
admin: AdminSessionPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminProjectSnapshotEntry {
|
|
||||||
userId: string;
|
|
||||||
projectId: string;
|
|
||||||
projectName: string | null;
|
|
||||||
syncRevision: number;
|
|
||||||
syncedAtMs: number;
|
|
||||||
fileCount: number;
|
|
||||||
totalBytes: number;
|
|
||||||
status: 'ready' | 'partial' | 'unverified';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminProjectSnapshotListQuery {
|
|
||||||
cursor?: string | null;
|
|
||||||
limit?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminProjectSnapshotListResponse {
|
|
||||||
items: AdminProjectSnapshotEntry[];
|
|
||||||
nextCursor: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminErrorReportEntry {
|
export interface AdminErrorReportEntry {
|
||||||
batchId: string;
|
batchId: string;
|
||||||
eventCount: number;
|
eventCount: number;
|
||||||
@@ -144,8 +123,6 @@ export interface AdminErrorReportListResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminErrorReportDetail extends AdminErrorReportEntry {
|
export interface AdminErrorReportDetail extends AdminErrorReportEntry {
|
||||||
firstFingerprint?: string;
|
|
||||||
firstSource?: string;
|
|
||||||
note?: string;
|
note?: string;
|
||||||
events: Array<Record<string, unknown>>;
|
events: Array<Record<string, unknown>>;
|
||||||
logNames: string[];
|
logNames: string[];
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
import { afterEach, expect, test, vi } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
|
||||||
downloadAdminProjectSnapshot,
|
|
||||||
listAdminProjectSnapshots,
|
|
||||||
} from './adminApiClient';
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('项目列表携带分页与后台授权,解析标准响应', async () => {
|
|
||||||
const payload = { items: [], nextCursor: 'next' };
|
|
||||||
const fetchMock = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue(
|
|
||||||
new Response(JSON.stringify({ ok: true, data: payload })),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
const controller = new AbortController();
|
|
||||||
expect(
|
|
||||||
await listAdminProjectSnapshots(
|
|
||||||
'admin-token',
|
|
||||||
{ cursor: 'user/a+项目', limit: 20 },
|
|
||||||
controller.signal,
|
|
||||||
),
|
|
||||||
).toEqual(payload);
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
|
||||||
'/admin/api/project-snapshots?cursor=user%2Fa%2B%E9%A1%B9%E7%9B%AE&limit=20',
|
|
||||||
expect.objectContaining({
|
|
||||||
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
|
|
||||||
signal: controller.signal,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ZIP 下载以授权请求读取并优先保留中文附件名', async () => {
|
|
||||||
const fetchMock = vi.fn().mockResolvedValue(
|
|
||||||
new Response('PK\u0003\u0004', {
|
|
||||||
headers: {
|
|
||||||
'content-type': 'application/zip',
|
|
||||||
'content-disposition':
|
|
||||||
"attachment; filename=project.zip; filename*=UTF-8''%E4%B8%89%E6%B6%88-r2.zip",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
|
||||||
const controller = new AbortController();
|
|
||||||
const archive = await downloadAdminProjectSnapshot(
|
|
||||||
'admin-token',
|
|
||||||
'user/a',
|
|
||||||
'project/b',
|
|
||||||
controller.signal,
|
|
||||||
);
|
|
||||||
expect(archive.filename).toBe('三消-r2.zip');
|
|
||||||
expect(archive.blob.type).toBe('application/zip');
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
|
||||||
'/admin/api/project-snapshots/user%2Fa/project%2Fb/download',
|
|
||||||
expect.objectContaining({
|
|
||||||
headers: expect.objectContaining({
|
|
||||||
Authorization: 'Bearer admin-token',
|
|
||||||
Accept: 'application/zip',
|
|
||||||
}),
|
|
||||||
signal: controller.signal,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
['attachment; filename="工程.zip"; filename*=UTF-8\'\'%broken', '工程.zip'],
|
|
||||||
['attachment; filename="../secret.zip"', '.._secret.zip'],
|
|
||||||
["attachment; filename*=UTF-8''unsafe%00%1F%7F.zip", 'unsafe___.zip'],
|
|
||||||
[null, 'project.zip'],
|
|
||||||
])('ZIP 附件名兼容安全回退 %s', async (header, expected) => {
|
|
||||||
const headers: Record<string, string> = { 'content-type': 'application/zip' };
|
|
||||||
// Response 的 Headers 只接受 Latin-1;真实 UTF-8 文件名使用 filename*。
|
|
||||||
if (header)
|
|
||||||
headers['content-disposition'] = header.replace('工程', 'project');
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
vi.fn().mockResolvedValue(new Response('PK', { headers })),
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
(await downloadAdminProjectSnapshot('token', 'user', 'project')).filename,
|
|
||||||
).toBe(expected.replace('工程', 'project'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([401, 403, 409, 500])(
|
|
||||||
'下载 HTTP %s 保留后台错误,不返回 ZIP',
|
|
||||||
async (status) => {
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
vi.fn().mockResolvedValue(
|
|
||||||
new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
ok: false,
|
|
||||||
error: { code: 'SNAPSHOT_FAILURE', message: '工程尚未同步完成' },
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status,
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
downloadAdminProjectSnapshot('token', 'user', 'project'),
|
|
||||||
).rejects.toMatchObject({
|
|
||||||
status,
|
|
||||||
code: 'SNAPSHOT_FAILURE',
|
|
||||||
message: '工程尚未同步完成',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test('200 JSON 或 HTML 不能被保存为成功 ZIP', async () => {
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
vi.fn().mockResolvedValue(
|
|
||||||
new Response('{"ok":false}', {
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
downloadAdminProjectSnapshot('token', 'user', 'project'),
|
|
||||||
).rejects.toMatchObject({ code: 'INVALID_PROJECT_ARCHIVE_RESPONSE' });
|
|
||||||
});
|
|
||||||
@@ -31,7 +31,6 @@ import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
|
|||||||
import { AdminLoginPage } from '../pages/AdminLoginPage';
|
import { AdminLoginPage } from '../pages/AdminLoginPage';
|
||||||
import { AdminOverviewPage } from '../pages/AdminOverviewPage';
|
import { AdminOverviewPage } from '../pages/AdminOverviewPage';
|
||||||
import { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage';
|
import { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage';
|
||||||
import { AdminProjectSnapshotsPage } from '../pages/AdminProjectSnapshotsPage';
|
|
||||||
import { AdminRechargeOrderPage } from '../pages/AdminRechargeOrderPage';
|
import { AdminRechargeOrderPage } from '../pages/AdminRechargeOrderPage';
|
||||||
import { AdminRechargeProductPage } from '../pages/AdminRechargeProductPage';
|
import { AdminRechargeProductPage } from '../pages/AdminRechargeProductPage';
|
||||||
import { AdminRedeemCodePage } from '../pages/AdminRedeemCodePage';
|
import { AdminRedeemCodePage } from '../pages/AdminRedeemCodePage';
|
||||||
@@ -309,12 +308,6 @@ export function AdminApp() {
|
|||||||
{activeRouteId === 'accounts' ? (
|
{activeRouteId === 'accounts' ? (
|
||||||
<AdminAccountsPage token={token} onUnauthorized={handleUnauthorized} />
|
<AdminAccountsPage token={token} onUnauthorized={handleUnauthorized} />
|
||||||
) : null}
|
) : null}
|
||||||
{activeRouteId === 'project-snapshots' ? (
|
|
||||||
<AdminProjectSnapshotsPage
|
|
||||||
token={token}
|
|
||||||
onUnauthorized={handleUnauthorized}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</AdminShell>
|
</AdminShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
Bug,
|
Bug,
|
||||||
Coins,
|
Coins,
|
||||||
Database,
|
Database,
|
||||||
FolderArchive,
|
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Images,
|
Images,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -50,7 +49,6 @@ const routeIcons = {
|
|||||||
'editor-generation-pricing': Coins,
|
'editor-generation-pricing': Coins,
|
||||||
'editor-showcase': Star,
|
'editor-showcase': Star,
|
||||||
'editor-assets': Images,
|
'editor-assets': Images,
|
||||||
'project-snapshots': FolderArchive,
|
|
||||||
accounts: Users,
|
accounts: Users,
|
||||||
'agc-models': ListChecks,
|
'agc-models': ListChecks,
|
||||||
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
|
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
|
||||||
|
|||||||
@@ -122,28 +122,3 @@ test('零权限 member 不回落到 Dashboard', () => {
|
|||||||
expect(routes).toEqual([]);
|
expect(routes).toEqual([]);
|
||||||
expect(resolveAccessibleAdminRoute('#dashboard', routes)).toBeNull();
|
expect(resolveAccessibleAdminRoute('#dashboard', routes)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('项目工程入口对 owner 与已授权 member 开放且可分配权限', () => {
|
|
||||||
const route = {
|
|
||||||
id: 'project-snapshots',
|
|
||||||
label: '项目工程',
|
|
||||||
hash: '#project-snapshots',
|
|
||||||
};
|
|
||||||
expect(adminRoutes.filter((item) => !item.ownerOnly)).toContainEqual(route);
|
|
||||||
expect(resolveAdminRoute('#project-snapshots')).toBe('project-snapshots');
|
|
||||||
expect(
|
|
||||||
getAccessibleAdminRoutes({ accountRole: 'owner', tabPermissions: [] }),
|
|
||||||
).toContainEqual(route);
|
|
||||||
expect(
|
|
||||||
getAccessibleAdminRoutes({
|
|
||||||
accountRole: 'member',
|
|
||||||
tabPermissions: ['project-snapshots'],
|
|
||||||
}),
|
|
||||||
).toEqual([route]);
|
|
||||||
expect(
|
|
||||||
getAccessibleAdminRoutes({
|
|
||||||
accountRole: 'member',
|
|
||||||
tabPermissions: ['tracking'],
|
|
||||||
}),
|
|
||||||
).not.toContainEqual(route);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export type AdminRouteId =
|
|||||||
| 'editor-generation-pricing'
|
| 'editor-generation-pricing'
|
||||||
| 'editor-showcase'
|
| 'editor-showcase'
|
||||||
| 'editor-assets'
|
| 'editor-assets'
|
||||||
| 'project-snapshots'
|
|
||||||
| 'agc-models'
|
| 'agc-models'
|
||||||
| 'accounts';
|
| 'accounts';
|
||||||
|
|
||||||
@@ -55,7 +54,6 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
|||||||
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
|
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
|
||||||
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
|
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
|
||||||
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
|
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
|
||||||
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
|
|
||||||
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
|
||||||
formatAdminTimestamp,
|
|
||||||
parseAdminTimestamp,
|
|
||||||
} from './AdminErrorReportsPage';
|
|
||||||
|
|
||||||
describe('错误报告时间格式化', () => {
|
|
||||||
it('解析 SpacetimeDB seconds.microsZ 时间', () => {
|
|
||||||
expect(parseAdminTimestamp('1778207451.731746Z')).toBe(1778207451731);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('解析微秒字符串并拒绝无效时间', () => {
|
|
||||||
expect(parseAdminTimestamp('1778207451731746')).toBe(1778207451731);
|
|
||||||
expect(formatAdminTimestamp('not-a-date')).toBe('-');
|
|
||||||
expect(formatAdminTimestamp('999999999999999999999999')).toBe('-');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -17,57 +17,6 @@ type Props = { token: string; onUnauthorized: (message?: string) => void };
|
|||||||
|
|
||||||
const ADMIN_ERROR_REPORT_STATUSES = ['new', 'in-progress', 'resolved'] as const;
|
const ADMIN_ERROR_REPORT_STATUSES = ['new', 'in-progress', 'resolved'] as const;
|
||||||
const ERROR_REPORT_PAGE_SIZE = 50;
|
const ERROR_REPORT_PAGE_SIZE = 50;
|
||||||
const ERROR_REPORT_STATUS_LABELS: Record<string, string> = {
|
|
||||||
new: '待处理',
|
|
||||||
'in-progress': '处理中',
|
|
||||||
resolved: '已解决',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function parseAdminTimestamp(value: string | null | undefined) {
|
|
||||||
const normalized = value?.trim() ?? '';
|
|
||||||
if (/^-?\d+\.\d{6}Z$/u.test(normalized)) {
|
|
||||||
const [secondsText, microsText] = normalized.slice(0, -1).split('.');
|
|
||||||
const seconds = Number(secondsText);
|
|
||||||
const micros = Number(microsText);
|
|
||||||
return Number.isFinite(seconds) && Number.isFinite(micros)
|
|
||||||
? seconds * 1000 + Math.floor(micros / 1000)
|
|
||||||
: Number.NaN;
|
|
||||||
}
|
|
||||||
if (/^-?\d+$/u.test(normalized)) {
|
|
||||||
const numeric = Number(normalized);
|
|
||||||
if (!Number.isFinite(numeric)) return Number.NaN;
|
|
||||||
if (Math.abs(numeric) >= 1e14) return Math.floor(numeric / 1000);
|
|
||||||
if (Math.abs(numeric) >= 1e11) return numeric;
|
|
||||||
return numeric * 1000;
|
|
||||||
}
|
|
||||||
return Date.parse(normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatAdminTimestamp(value: string | null | undefined) {
|
|
||||||
const timestamp = parseAdminTimestamp(value);
|
|
||||||
if (!Number.isFinite(timestamp)) return '-';
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
if (!Number.isFinite(date.getTime())) return '-';
|
|
||||||
try {
|
|
||||||
return new Intl.DateTimeFormat('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
}).format(date);
|
|
||||||
} catch {
|
|
||||||
return '-';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function eventText(event: Record<string, unknown>, key: string) {
|
|
||||||
const value = event[key];
|
|
||||||
return typeof value === 'string' || typeof value === 'number'
|
|
||||||
? String(value)
|
|
||||||
: '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
||||||
const [reports, setReports] = useState<AdminErrorReportEntry[]>([]);
|
const [reports, setReports] = useState<AdminErrorReportEntry[]>([]);
|
||||||
@@ -80,24 +29,6 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
|||||||
const [pageInfo, setPageInfo] = useState({ total: 0, hasMore: false });
|
const [pageInfo, setPageInfo] = useState({ total: 0, hasMore: false });
|
||||||
const openReportRequestId = useRef(0);
|
const openReportRequestId = useRef(0);
|
||||||
const loadRequestId = useRef(0);
|
const loadRequestId = useRef(0);
|
||||||
const detailCloseButtonRef = useRef<HTMLButtonElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selected) return;
|
|
||||||
const previousFocus = document.activeElement as HTMLElement | null;
|
|
||||||
const focusFrame = window.requestAnimationFrame(() => {
|
|
||||||
detailCloseButtonRef.current?.focus();
|
|
||||||
});
|
|
||||||
const handleEscape = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Escape') setSelected(null);
|
|
||||||
};
|
|
||||||
window.addEventListener('keydown', handleEscape);
|
|
||||||
return () => {
|
|
||||||
window.cancelAnimationFrame(focusFrame);
|
|
||||||
window.removeEventListener('keydown', handleEscape);
|
|
||||||
previousFocus?.focus?.();
|
|
||||||
};
|
|
||||||
}, [selected]);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const requestId = ++loadRequestId.current;
|
const requestId = ++loadRequestId.current;
|
||||||
@@ -253,15 +184,9 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
|||||||
<td>{report.batchId}</td>
|
<td>{report.batchId}</td>
|
||||||
<td>{report.eventCount}</td>
|
<td>{report.eventCount}</td>
|
||||||
<td>{report.source ?? '-'}</td>
|
<td>{report.source ?? '-'}</td>
|
||||||
<td>
|
<td>{report.status}</td>
|
||||||
<span
|
|
||||||
className={`admin-error-report-status is-${report.status}`}
|
|
||||||
>
|
|
||||||
{ERROR_REPORT_STATUS_LABELS[report.status] ?? report.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>{report.userId}</td>
|
<td>{report.userId}</td>
|
||||||
<td>{formatAdminTimestamp(report.createdAt)}</td>
|
<td>{new Date(report.createdAt).toLocaleString()}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -299,173 +224,66 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
|||||||
className="admin-detail-modal"
|
className="admin-detail-modal"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label="错误报告详情"
|
aria-label="错误报告详情"
|
||||||
aria-modal="true"
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
if (event.target === event.currentTarget) setSelected(null);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="admin-detail-modal__panel admin-error-report-detail">
|
<div className="admin-detail-modal__panel">
|
||||||
<header className="admin-error-report-detail__header">
|
<header>
|
||||||
<div className="admin-error-report-detail__heading">
|
<h2>{selected.batchId}</h2>
|
||||||
<span className="admin-error-report-detail__eyebrow">
|
<button type="button" onClick={() => setSelected(null)}>
|
||||||
错误报告详情
|
关闭
|
||||||
</span>
|
|
||||||
<h2>{selected.batchId}</h2>
|
|
||||||
<div className="admin-error-report-detail__badges">
|
|
||||||
<span
|
|
||||||
className={`admin-error-report-status is-${selected.status}`}
|
|
||||||
>
|
|
||||||
{ERROR_REPORT_STATUS_LABELS[selected.status] ??
|
|
||||||
selected.status}
|
|
||||||
</span>
|
|
||||||
<span className="admin-error-report-detail__badge">
|
|
||||||
{selected.firstSource ?? selected.source ?? '未知来源'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="admin-error-report-detail__close"
|
|
||||||
ref={detailCloseButtonRef}
|
|
||||||
onClick={() => setSelected(null)}
|
|
||||||
aria-label="关闭错误报告详情"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div className="admin-error-report-detail__summary">
|
<p>
|
||||||
<div>
|
用户:{selected.userId} · 事件:{selected.eventCount} · 日志:
|
||||||
<span>用户</span>
|
{selected.logCount}
|
||||||
<strong>{selected.userId}</strong>
|
</p>
|
||||||
</div>
|
<pre>{JSON.stringify(selected.events.slice(0, 20), null, 2)}</pre>
|
||||||
<div>
|
{selected.userDescription ? (
|
||||||
<span>发生时间</span>
|
<p>用户描述:{selected.userDescription}</p>
|
||||||
<strong>{formatAdminTimestamp(selected.createdAt)}</strong>
|
) : null}
|
||||||
</div>
|
<label className="admin-detail-modal__note">
|
||||||
<div>
|
处理备注
|
||||||
<span>错误事件</span>
|
<textarea
|
||||||
<strong>{selected.eventCount}</strong>
|
value={selected.note ?? ''}
|
||||||
</div>
|
onChange={(event) =>
|
||||||
<div>
|
setSelected((current) =>
|
||||||
<span>应用日志</span>
|
current
|
||||||
<strong>{selected.logCount}</strong>
|
? { ...current, note: event.target.value }
|
||||||
</div>
|
: current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
maxLength={2000}
|
||||||
|
rows={4}
|
||||||
|
placeholder="记录处理结论或后续跟进事项"
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="admin-detail-modal__actions">
|
||||||
|
<select
|
||||||
|
value={selected.status}
|
||||||
|
onChange={(event) => void saveStatus(event.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{ADMIN_ERROR_REPORT_STATUSES.map((value) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{value}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void saveStatus(selected.status)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
保存备注
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void download(selected.batchId)}
|
||||||
|
disabled={downloading}
|
||||||
|
>
|
||||||
|
{downloading ? '下载中…' : '下载诊断包'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-error-report-detail__body">
|
|
||||||
<section className="admin-error-report-detail__section">
|
|
||||||
<div className="admin-error-report-detail__section-heading">
|
|
||||||
<div>
|
|
||||||
<h3>错误事件</h3>
|
|
||||||
<span>展示最近 20 条,完整内容请下载诊断包。</span>
|
|
||||||
</div>
|
|
||||||
<span className="admin-error-report-detail__count">
|
|
||||||
{selected.events.length} 条
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="admin-error-report-event-list">
|
|
||||||
{selected.events.length === 0 ? (
|
|
||||||
<p className="admin-error-report-detail__empty">
|
|
||||||
归档中没有可展示的错误事件。
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{selected.events.slice(0, 20).map((event, index) => {
|
|
||||||
const message = eventText(event, 'message') || '未知错误';
|
|
||||||
const stack = eventText(event, 'stack');
|
|
||||||
const occurredAt = eventText(event, 'occurredAt');
|
|
||||||
return (
|
|
||||||
<article
|
|
||||||
className="admin-error-report-event"
|
|
||||||
key={`${index}-${message}`}
|
|
||||||
>
|
|
||||||
<div className="admin-error-report-event__topline">
|
|
||||||
<span className="admin-error-report-event__index">
|
|
||||||
#{index + 1}
|
|
||||||
</span>
|
|
||||||
<span>{eventText(event, 'source') || 'client'}</span>
|
|
||||||
<span>{eventText(event, 'count') || '1'} 次</span>
|
|
||||||
<time>{formatAdminTimestamp(occurredAt)}</time>
|
|
||||||
</div>
|
|
||||||
<p className="admin-error-report-event__message">
|
|
||||||
{message}
|
|
||||||
</p>
|
|
||||||
{eventText(event, 'fingerprint') ? (
|
|
||||||
<code>{eventText(event, 'fingerprint')}</code>
|
|
||||||
) : null}
|
|
||||||
{stack ? (
|
|
||||||
<details>
|
|
||||||
<summary>查看调用栈</summary>
|
|
||||||
<pre>{stack}</pre>
|
|
||||||
</details>
|
|
||||||
) : null}
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{selected.userDescription ? (
|
|
||||||
<section className="admin-error-report-detail__section">
|
|
||||||
<div className="admin-error-report-detail__section-heading">
|
|
||||||
<div>
|
|
||||||
<h3>用户描述</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="admin-error-report-detail__description">
|
|
||||||
{selected.userDescription}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
<section className="admin-error-report-detail__section admin-error-report-detail__section--note">
|
|
||||||
<label className="admin-detail-modal__note">
|
|
||||||
<span>处理备注</span>
|
|
||||||
<textarea
|
|
||||||
value={selected.note ?? ''}
|
|
||||||
onChange={(event) =>
|
|
||||||
setSelected((current) =>
|
|
||||||
current
|
|
||||||
? { ...current, note: event.target.value }
|
|
||||||
: current,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
maxLength={2000}
|
|
||||||
rows={4}
|
|
||||||
placeholder="记录处理结论或后续跟进事项"
|
|
||||||
disabled={busy}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
<footer className="admin-error-report-detail__footer">
|
|
||||||
<label className="admin-error-report-detail__status-field">
|
|
||||||
状态
|
|
||||||
<select
|
|
||||||
value={selected.status}
|
|
||||||
onChange={(event) => void saveStatus(event.target.value)}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
{ADMIN_ERROR_REPORT_STATUSES.map((value) => (
|
|
||||||
<option key={value} value={value}>
|
|
||||||
{ERROR_REPORT_STATUS_LABELS[value]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<div className="admin-error-report-detail__footer-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void saveStatus(selected.status)}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
{busy ? '保存中…' : '保存备注'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void download(selected.batchId)}
|
|
||||||
disabled={downloading}
|
|
||||||
>
|
|
||||||
{downloading ? '下载中…' : '下载诊断包'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -154,27 +154,6 @@ test('灰度发布页可通过功能入口生成画布 Agent Gate Key', async ()
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('灰度发布页可选择模板库并默认启用零比例灰度', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
|
|
||||||
);
|
|
||||||
await screen.findByRole('button', { name: 'editor.new-toolbar' });
|
|
||||||
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), ['agc']);
|
|
||||||
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
|
|
||||||
'agc:template-library',
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
(screen.getByLabelText('Gate Key 目标') as HTMLSelectElement).value,
|
|
||||||
).toBe('template-library');
|
|
||||||
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe(
|
|
||||||
'0',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('灰度发布页保存时转换数组和百分比', async () => {
|
test('灰度发布页保存时转换数组和百分比', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
|
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
|
||||||
|
|||||||
@@ -27,17 +27,9 @@ interface GateTargetOption {
|
|||||||
|
|
||||||
const GATE_PREFIX_LABELS: Record<string, string> = {
|
const GATE_PREFIX_LABELS: Record<string, string> = {
|
||||||
'image-editor': '画布',
|
'image-editor': '画布',
|
||||||
agc: '客户端',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const FIXED_GATE_TARGETS: GateTargetOption[] = [
|
const FIXED_GATE_TARGETS: GateTargetOption[] = [
|
||||||
{
|
|
||||||
prefix: 'agc',
|
|
||||||
suffix: 'template-library',
|
|
||||||
key: 'agc:template-library',
|
|
||||||
label: '模板库',
|
|
||||||
description: '客户端模板库灰度',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
prefix: 'image-editor',
|
prefix: 'image-editor',
|
||||||
suffix: 'agent-sidebar',
|
suffix: 'agent-sidebar',
|
||||||
@@ -188,7 +180,7 @@ export function AdminGrayReleaseConfigPage({
|
|||||||
setSelectedGateKey('');
|
setSelectedGateKey('');
|
||||||
setGatePrefix(option.prefix);
|
setGatePrefix(option.prefix);
|
||||||
setGateKey(option.key);
|
setGateKey(option.key);
|
||||||
setEnabled(option.key === 'agc:template-library');
|
setEnabled(false);
|
||||||
setRolloutPercent('0');
|
setRolloutPercent('0');
|
||||||
setAllowUserIds('');
|
setAllowUserIds('');
|
||||||
setAllowUserTags('');
|
setAllowUserTags('');
|
||||||
|
|||||||
@@ -1,288 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
import {
|
|
||||||
act,
|
|
||||||
cleanup,
|
|
||||||
fireEvent,
|
|
||||||
render,
|
|
||||||
screen,
|
|
||||||
waitFor,
|
|
||||||
within,
|
|
||||||
} from '@testing-library/react';
|
|
||||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
|
||||||
AdminApiError,
|
|
||||||
downloadAdminProjectSnapshot,
|
|
||||||
listAdminProjectSnapshots,
|
|
||||||
} from '../api/adminApiClient';
|
|
||||||
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
|
|
||||||
import { AdminProjectSnapshotsPage } from './AdminProjectSnapshotsPage';
|
|
||||||
|
|
||||||
vi.mock('../api/adminApiClient', async () => ({
|
|
||||||
...(await vi.importActual<typeof import('../api/adminApiClient')>(
|
|
||||||
'../api/adminApiClient',
|
|
||||||
)),
|
|
||||||
downloadAdminProjectSnapshot: vi.fn(),
|
|
||||||
listAdminProjectSnapshots: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const entry: AdminProjectSnapshotEntry = {
|
|
||||||
userId: 'user-1',
|
|
||||||
projectId: 'project-1',
|
|
||||||
projectName: '三消工程',
|
|
||||||
syncRevision: 3,
|
|
||||||
syncedAtMs: 1_700_000_000_000,
|
|
||||||
fileCount: 12,
|
|
||||||
totalBytes: 2048,
|
|
||||||
status: 'ready',
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.mocked(listAdminProjectSnapshots)
|
|
||||||
.mockReset()
|
|
||||||
.mockResolvedValue({ items: [entry], nextCursor: null });
|
|
||||||
vi.mocked(downloadAdminProjectSnapshot).mockReset();
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('按项目展示完整性并限制未完成工程下载', async () => {
|
|
||||||
vi.mocked(listAdminProjectSnapshots).mockResolvedValue({
|
|
||||||
items: [
|
|
||||||
entry,
|
|
||||||
{
|
|
||||||
...entry,
|
|
||||||
projectId: 'partial-project',
|
|
||||||
projectName: '未完成工程',
|
|
||||||
status: 'partial',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...entry,
|
|
||||||
projectId: 'legacy-project',
|
|
||||||
projectName: null,
|
|
||||||
status: 'unverified',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
nextCursor: null,
|
|
||||||
});
|
|
||||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
|
||||||
const completeRow = (await screen.findByText('三消工程')).closest('tr')!;
|
|
||||||
expect(within(completeRow).getByText('2 KiB')).toBeTruthy();
|
|
||||||
expect(
|
|
||||||
within(completeRow)
|
|
||||||
.getByRole('button', { name: '下载完整工程' })
|
|
||||||
.hasAttribute('disabled'),
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
screen.getByRole('button', { name: '同步未完成' }).hasAttribute('disabled'),
|
|
||||||
).toBe(true);
|
|
||||||
expect(screen.getByText('完整性未知')).toBeTruthy();
|
|
||||||
expect(
|
|
||||||
screen
|
|
||||||
.getByRole('button', { name: '下载已存文件' })
|
|
||||||
.hasAttribute('disabled'),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('加载更多合并项目,刷新失败保留列表和错误,重试从首页开始', async () => {
|
|
||||||
vi.mocked(listAdminProjectSnapshots)
|
|
||||||
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
|
|
||||||
nextCursor: null,
|
|
||||||
})
|
|
||||||
.mockRejectedValueOnce(new Error('远端清单读取失败'))
|
|
||||||
.mockResolvedValueOnce({ items: [], nextCursor: null });
|
|
||||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
|
||||||
fireEvent.click(await screen.findByRole('button', { name: '加载更多' }));
|
|
||||||
await screen.findByText('第二工程');
|
|
||||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
'token',
|
|
||||||
{ cursor: 'page-2', limit: 20 },
|
|
||||||
expect.any(AbortSignal),
|
|
||||||
);
|
|
||||||
expect(screen.getByText('三消工程')).toBeTruthy();
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
|
||||||
await screen.findByRole('alert');
|
|
||||||
expect(screen.getByText('第二工程')).toBeTruthy();
|
|
||||||
expect(screen.queryByText('暂无已上传项目')).toBeNull();
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
|
||||||
await screen.findByText('暂无已上传项目');
|
|
||||||
expect(listAdminProjectSnapshots).toHaveBeenLastCalledWith(
|
|
||||||
'token',
|
|
||||||
{ cursor: null, limit: 20 },
|
|
||||||
expect.any(AbortSignal),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('下载使用返回的中文文件名,随后释放对象 URL', async () => {
|
|
||||||
const createObjectURL = vi.fn(() => 'blob:archive');
|
|
||||||
const revokeObjectURL = vi.fn();
|
|
||||||
vi.stubGlobal(
|
|
||||||
'URL',
|
|
||||||
class extends URL {
|
|
||||||
static createObjectURL = createObjectURL;
|
|
||||||
static revokeObjectURL = revokeObjectURL;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let savedFilename = '';
|
|
||||||
let savedHref = '';
|
|
||||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
|
|
||||||
this: HTMLAnchorElement,
|
|
||||||
) {
|
|
||||||
savedFilename = this.download;
|
|
||||||
savedHref = this.href;
|
|
||||||
});
|
|
||||||
const blob = new Blob(['PK'], { type: 'application/zip' });
|
|
||||||
vi.mocked(downloadAdminProjectSnapshot).mockResolvedValue({
|
|
||||||
blob,
|
|
||||||
filename: '三消工程-r3.zip',
|
|
||||||
});
|
|
||||||
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
|
||||||
const button = await screen.findByRole('button', { name: '下载完整工程' });
|
|
||||||
vi.useFakeTimers();
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(button);
|
|
||||||
});
|
|
||||||
expect(savedFilename).toBe('三消工程-r3.zip');
|
|
||||||
expect(savedHref).toBe('blob:archive');
|
|
||||||
expect(createObjectURL).toHaveBeenCalledWith(blob);
|
|
||||||
expect(downloadAdminProjectSnapshot).toHaveBeenCalledWith(
|
|
||||||
'token',
|
|
||||||
'user-1',
|
|
||||||
'project-1',
|
|
||||||
expect.any(AbortSignal),
|
|
||||||
);
|
|
||||||
act(() => vi.advanceTimersByTime(1000));
|
|
||||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:archive');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('取消下载中止请求且不显示错误,卸载中止列表请求', async () => {
|
|
||||||
vi.mocked(downloadAdminProjectSnapshot).mockImplementation(
|
|
||||||
(_token, _user, _project, signal) =>
|
|
||||||
new Promise((_resolve, reject) => {
|
|
||||||
signal?.addEventListener('abort', () =>
|
|
||||||
reject(new DOMException('Aborted', 'AbortError')),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const view = render(
|
|
||||||
<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />,
|
|
||||||
);
|
|
||||||
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
|
|
||||||
fireEvent.click(await screen.findByRole('button', { name: '取消下载' }));
|
|
||||||
expect(
|
|
||||||
vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3]?.aborted,
|
|
||||||
).toBe(true);
|
|
||||||
await waitFor(() => expect(screen.queryByRole('alert')).toBeNull());
|
|
||||||
vi.mocked(listAdminProjectSnapshots).mockReturnValue(new Promise(() => {}));
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
|
||||||
const signal = vi.mocked(listAdminProjectSnapshots).mock.calls.at(-1)?.[2];
|
|
||||||
view.unmount();
|
|
||||||
expect(signal?.aborted).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('下载登录失效走现有会话处理,403 错误保留页面', async () => {
|
|
||||||
const onUnauthorized = vi.fn();
|
|
||||||
vi.mocked(downloadAdminProjectSnapshot)
|
|
||||||
.mockRejectedValueOnce(
|
|
||||||
new AdminApiError({ status: 403, message: '无项目工程权限' }),
|
|
||||||
)
|
|
||||||
.mockRejectedValueOnce(
|
|
||||||
new AdminApiError({ status: 401, message: '已过期' }),
|
|
||||||
);
|
|
||||||
render(
|
|
||||||
<AdminProjectSnapshotsPage token="token" onUnauthorized={onUnauthorized} />,
|
|
||||||
);
|
|
||||||
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
|
|
||||||
expect(await screen.findByText('无项目工程权限')).toBeTruthy();
|
|
||||||
expect(onUnauthorized).not.toHaveBeenCalled();
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '下载完整工程' }));
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('首次列表失败显示错误而非空项目,401 失效回到会话处理', async () => {
|
|
||||||
const onUnauthorized = vi.fn();
|
|
||||||
vi.mocked(listAdminProjectSnapshots)
|
|
||||||
.mockRejectedValueOnce(new Error('清单存储不可用'))
|
|
||||||
.mockRejectedValueOnce(
|
|
||||||
new AdminApiError({ status: 401, message: '已过期' }),
|
|
||||||
);
|
|
||||||
render(
|
|
||||||
<AdminProjectSnapshotsPage token="token" onUnauthorized={onUnauthorized} />,
|
|
||||||
);
|
|
||||||
await screen.findByText('清单存储不可用');
|
|
||||||
expect(screen.queryByText('暂无已上传项目')).toBeNull();
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('更换登录令牌丢弃旧列表和晚返回请求', async () => {
|
|
||||||
let finishOldRequest!: (value: {
|
|
||||||
items: AdminProjectSnapshotEntry[];
|
|
||||||
nextCursor: null;
|
|
||||||
}) => void;
|
|
||||||
vi.mocked(listAdminProjectSnapshots)
|
|
||||||
.mockReturnValueOnce(
|
|
||||||
new Promise((resolve) => {
|
|
||||||
finishOldRequest = resolve;
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
items: [{ ...entry, projectName: '新账号工程' }],
|
|
||||||
nextCursor: null,
|
|
||||||
});
|
|
||||||
const onUnauthorized = vi.fn();
|
|
||||||
const view = render(
|
|
||||||
<AdminProjectSnapshotsPage
|
|
||||||
token="old-token"
|
|
||||||
onUnauthorized={onUnauthorized}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
const oldSignal = vi.mocked(listAdminProjectSnapshots).mock.calls[0]?.[2];
|
|
||||||
view.rerender(
|
|
||||||
<AdminProjectSnapshotsPage
|
|
||||||
token="new-token"
|
|
||||||
onUnauthorized={onUnauthorized}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
await screen.findByText('新账号工程');
|
|
||||||
expect(oldSignal?.aborted).toBe(true);
|
|
||||||
await act(async () => {
|
|
||||||
finishOldRequest({ items: [entry], nextCursor: null });
|
|
||||||
});
|
|
||||||
expect(screen.queryByText('三消工程')).toBeNull();
|
|
||||||
expect(screen.getByText('新账号工程')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('卸载后完成的下载不会创建浏览器文件', async () => {
|
|
||||||
let finishDownload!: (value: { blob: Blob; filename: string }) => void;
|
|
||||||
vi.mocked(downloadAdminProjectSnapshot).mockReturnValue(
|
|
||||||
new Promise((resolve) => {
|
|
||||||
finishDownload = resolve;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const click = vi
|
|
||||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
|
||||||
.mockImplementation(() => {});
|
|
||||||
const view = render(
|
|
||||||
<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />,
|
|
||||||
);
|
|
||||||
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
|
|
||||||
const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3];
|
|
||||||
view.unmount();
|
|
||||||
expect(signal?.aborted).toBe(true);
|
|
||||||
await act(async () => {
|
|
||||||
finishDownload({ blob: new Blob(['PK']), filename: 'old.zip' });
|
|
||||||
});
|
|
||||||
expect(click).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
import { Download, RefreshCcw, X } from 'lucide-react';
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
|
|
||||||
import {
|
|
||||||
downloadAdminProjectSnapshot,
|
|
||||||
listAdminProjectSnapshots,
|
|
||||||
} from '../api/adminApiClient';
|
|
||||||
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
|
|
||||||
import { handlePageError } from './pageUtils';
|
|
||||||
|
|
||||||
interface AdminProjectSnapshotsPageProps {
|
|
||||||
token: string;
|
|
||||||
onUnauthorized: (message?: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const snapshotStatuses = {
|
|
||||||
ready: {
|
|
||||||
label: '已同步',
|
|
||||||
className: 'admin-status-ok',
|
|
||||||
action: '下载完整工程',
|
|
||||||
},
|
|
||||||
partial: {
|
|
||||||
label: '同步未完成',
|
|
||||||
className: 'admin-status-pending',
|
|
||||||
action: '同步未完成',
|
|
||||||
},
|
|
||||||
unverified: {
|
|
||||||
label: '完整性未知',
|
|
||||||
className: 'admin-status-pending',
|
|
||||||
action: '下载已存文件',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AdminProjectSnapshotsPage({
|
|
||||||
token,
|
|
||||||
onUnauthorized,
|
|
||||||
}: AdminProjectSnapshotsPageProps) {
|
|
||||||
const [items, setItems] = useState<AdminProjectSnapshotEntry[]>([]);
|
|
||||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [hasLoaded, setHasLoaded] = useState(false);
|
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
|
||||||
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
|
|
||||||
const listController = useRef<AbortController | null>(null);
|
|
||||||
const downloadController = useRef<AbortController | null>(null);
|
|
||||||
|
|
||||||
const loadPage = useCallback(
|
|
||||||
async (cursor: string | null = null) => {
|
|
||||||
listController.current?.abort();
|
|
||||||
const controller = new AbortController();
|
|
||||||
listController.current = controller;
|
|
||||||
setIsLoading(true);
|
|
||||||
setErrorMessage('');
|
|
||||||
try {
|
|
||||||
const response = await listAdminProjectSnapshots(
|
|
||||||
token,
|
|
||||||
{ cursor, limit: 20 },
|
|
||||||
controller.signal,
|
|
||||||
);
|
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
setItems((current) => {
|
|
||||||
if (!cursor) return response.items;
|
|
||||||
const entries = new Map(
|
|
||||||
current.map((entry) => [snapshotKey(entry), entry]),
|
|
||||||
);
|
|
||||||
response.items.forEach((entry) =>
|
|
||||||
entries.set(snapshotKey(entry), entry),
|
|
||||||
);
|
|
||||||
return [...entries.values()];
|
|
||||||
});
|
|
||||||
setNextCursor(response.nextCursor);
|
|
||||||
setHasLoaded(true);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (!controller.signal.aborted)
|
|
||||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
|
||||||
} finally {
|
|
||||||
if (listController.current === controller) {
|
|
||||||
listController.current = null;
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[token, onUnauthorized],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setItems([]);
|
|
||||||
setNextCursor(null);
|
|
||||||
setHasLoaded(false);
|
|
||||||
setDownloadingKey(null);
|
|
||||||
void loadPage();
|
|
||||||
return () => {
|
|
||||||
listController.current?.abort();
|
|
||||||
listController.current = null;
|
|
||||||
downloadController.current?.abort();
|
|
||||||
downloadController.current = null;
|
|
||||||
};
|
|
||||||
}, [loadPage]);
|
|
||||||
|
|
||||||
async function downloadProject(entry: AdminProjectSnapshotEntry) {
|
|
||||||
if (downloadController.current || entry.status === 'partial') return;
|
|
||||||
const controller = new AbortController();
|
|
||||||
downloadController.current = controller;
|
|
||||||
setDownloadingKey(snapshotKey(entry));
|
|
||||||
setErrorMessage('');
|
|
||||||
try {
|
|
||||||
const archive = await downloadAdminProjectSnapshot(
|
|
||||||
token,
|
|
||||||
entry.userId,
|
|
||||||
entry.projectId,
|
|
||||||
controller.signal,
|
|
||||||
);
|
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
const objectUrl = URL.createObjectURL(archive.blob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = objectUrl;
|
|
||||||
link.download = archive.filename;
|
|
||||||
document.body.append(link);
|
|
||||||
try {
|
|
||||||
link.click();
|
|
||||||
} finally {
|
|
||||||
link.remove();
|
|
||||||
// 给浏览器时间接管下载,随后释放临时 URL。
|
|
||||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (!controller.signal.aborted)
|
|
||||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
|
||||||
} finally {
|
|
||||||
if (downloadController.current === controller) {
|
|
||||||
downloadController.current = null;
|
|
||||||
setDownloadingKey(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelDownload() {
|
|
||||||
downloadController.current?.abort();
|
|
||||||
downloadController.current = null;
|
|
||||||
setDownloadingKey(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="admin-page admin-page-wide">
|
|
||||||
<div className="admin-page-heading">
|
|
||||||
<h2>项目工程</h2>
|
|
||||||
<button
|
|
||||||
className="admin-secondary-button"
|
|
||||||
disabled={isLoading}
|
|
||||||
type="button"
|
|
||||||
onClick={() => void loadPage()}
|
|
||||||
>
|
|
||||||
<RefreshCcw size={17} aria-hidden="true" />
|
|
||||||
<span>{isLoading ? '加载中' : '刷新'}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{errorMessage ? (
|
|
||||||
<div className="admin-alert" role="alert">
|
|
||||||
{errorMessage}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<section
|
|
||||||
className="admin-panel admin-stack"
|
|
||||||
aria-label="项目工程列表"
|
|
||||||
aria-busy={isLoading}
|
|
||||||
>
|
|
||||||
<div className="admin-table-wrap">
|
|
||||||
<table className="admin-table admin-project-snapshot-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>项目</th>
|
|
||||||
<th>用户 ID</th>
|
|
||||||
<th>同步时间</th>
|
|
||||||
<th>文件数</th>
|
|
||||||
<th>体积</th>
|
|
||||||
<th>完整性</th>
|
|
||||||
<th>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{items.map((entry) => {
|
|
||||||
const status = snapshotStatuses[entry.status];
|
|
||||||
const isDownloading = downloadingKey === snapshotKey(entry);
|
|
||||||
return (
|
|
||||||
<tr key={snapshotKey(entry)}>
|
|
||||||
<td data-label="项目">
|
|
||||||
<strong>{entry.projectName || entry.projectId}</strong>
|
|
||||||
<small>{entry.projectId}</small>
|
|
||||||
</td>
|
|
||||||
<td data-label="用户 ID">{entry.userId}</td>
|
|
||||||
<td data-label="同步时间">
|
|
||||||
<span>
|
|
||||||
{new Date(entry.syncedAtMs).toLocaleString('zh-CN', {
|
|
||||||
hour12: false,
|
|
||||||
})}
|
|
||||||
<small>版本 {entry.syncRevision}</small>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td data-label="文件数">
|
|
||||||
{entry.fileCount.toLocaleString('zh-CN')}
|
|
||||||
</td>
|
|
||||||
<td data-label="体积">{formatBytes(entry.totalBytes)}</td>
|
|
||||||
<td data-label="完整性">
|
|
||||||
<span className={`admin-status ${status.className}`}>
|
|
||||||
{status.label}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td data-label="操作">
|
|
||||||
{isDownloading ? (
|
|
||||||
<button
|
|
||||||
className="admin-secondary-button"
|
|
||||||
type="button"
|
|
||||||
onClick={cancelDownload}
|
|
||||||
>
|
|
||||||
<X size={16} aria-hidden="true" />
|
|
||||||
<span>取消下载</span>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="admin-secondary-button"
|
|
||||||
disabled={
|
|
||||||
entry.status === 'partial' ||
|
|
||||||
downloadingKey !== null
|
|
||||||
}
|
|
||||||
type="button"
|
|
||||||
onClick={() => void downloadProject(entry)}
|
|
||||||
>
|
|
||||||
<Download size={16} aria-hidden="true" />
|
|
||||||
<span>{status.action}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{hasLoaded && items.length === 0 && !errorMessage ? (
|
|
||||||
<p className="admin-muted-text">暂无已上传项目</p>
|
|
||||||
) : null}
|
|
||||||
{nextCursor ? (
|
|
||||||
<div className="admin-action-row">
|
|
||||||
<button
|
|
||||||
className="admin-secondary-button"
|
|
||||||
disabled={isLoading}
|
|
||||||
type="button"
|
|
||||||
onClick={() => void loadPage(nextCursor)}
|
|
||||||
>
|
|
||||||
{isLoading ? '加载中' : '加载更多'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshotKey(entry: AdminProjectSnapshotEntry) {
|
|
||||||
return `${entry.userId}/${entry.projectId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(bytes: number) {
|
|
||||||
const units = ['B', 'KiB', 'MiB', 'GiB'];
|
|
||||||
const unit = Math.min(
|
|
||||||
Math.floor(Math.log2(Math.max(1, bytes)) / 10),
|
|
||||||
units.length - 1,
|
|
||||||
);
|
|
||||||
return `${(bytes / 1024 ** unit).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} ${units[unit]}`;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user