Compare commits

..

1 Commits

Author SHA1 Message Date
kdletters b79ee96f7e 补齐容器预览的 AGC Router 密钥说明
- deploy/container/api-server.env.example 增加 AGC 官方 LLM Router 段,列明容器预览必须由 .env.secrets.local 提供的三个密钥
- 【开发运维】Jenkins容器预览部署控制面技术方案 补充预览 secrets 必含密钥与缺失时的失败表现
- 【开发运维】本地开发验证与生产运维 补充容器与 Jenkins 预览路径同样受 Router 启动校验约束
- project-memory pitfalls 补充容器预览缺少 Router 密钥时的排障特征
2026-09-14 19:52:27 +08:00
813 changed files with 12217 additions and 112016 deletions
@@ -32,7 +32,6 @@ 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.
- Preserve both warning channels after completion. A general `warning` can coexist with `sliceWarning`; do not discard either.
- Do not invent missing derivatives. A source-preserved warning means the main source remains usable but requested post-processing failed. A slice warning means the complete transparent sheet is usable but individual slices are absent.
- Icon spritesheet generation requires an explicit `sliceMode` and has no default. Use `sliceMode="grid"` with the `gridX` and `gridY` the requirement actually names (1-32 each) only for equal grid cells or fixed slots; use `sliceMode="connected-components"` for free-form sheets or an open number of subjects, and constrain the count with `sliceCount` instead of inventing grid dimensions. `connected-components` must not carry `gridX`/`gridY`; an omitted, contradictory, or misapplied declaration returns 400 before billing.
- For successful `style="pixelArt"`, treat completed-result and nested resource/asset dimensions as the final logical-grid PNG dimensions. They may differ from `size`, `imageSize`, the provider image, and `canvasCompletion.placeholder`; do not rescale or reject the artifact to match those inputs.
- Keep generated artifacts in the canvas and asset library together. Character animation accepts `assetFolderId` and `assetLabel`; its completed result directly returns the final `assetKind="character-animation"` resource and asset with formal sequence fields. Do not create a duplicate first-frame record.
@@ -52,7 +52,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
| Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion` |
| Background removal | `/api/external/v1/editor/images/background-removals` | `sourceImageSrc` | `projectId`, `sourceResourceId`, `targetLayerId`, static-image `assetKind`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions` | `sliceMode`, `gridX`, `gridY`, `sliceCount`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions` | `sliceLayout`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| UI asset extraction | `/api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` |
| Character animation | `/api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| Video generation | `/api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
@@ -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.
`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.
`sliceLayout: "grid-2x2"` is an opt-in contract for four fixed game-runtime assets. The provider prompt and server persistence both preserve the ordered slots left-top, right-top, left-bottom, right-bottom. Omit it to retain the default connected-component slicing behaviour for ordinary free-form icon sheets.
## 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.
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 the four-category game contract it must also send `sliceLayout: "grid-2x2"`; this is an explicit fixed-slot contract, not a client-side guessed crop.
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. For the four-category game-chat contract, require response `sliceLayout: "grid-2x2"` 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.
@@ -617,7 +617,6 @@ class GenarrativeExternalClient:
self,
reference_id: str,
icon_descriptions: list[str],
slice_mode: str,
**fields: Any,
) -> Any:
reference_id = normalize_optional_text(reference_id)
@@ -626,20 +625,6 @@ class GenarrativeExternalClient:
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")
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", "图标图集")
self._apply_canvas_session_fields(fields, label, 1024, 1024)
fields.setdefault("screenColor", "auto")
@@ -650,7 +635,6 @@ class GenarrativeExternalClient:
**fields,
"referenceId": reference_id,
"iconDescriptions": descriptions,
"sliceMode": slice_mode,
},
idempotency_key=idempotency_key,
)
@@ -895,7 +879,6 @@ def _self_test() -> None:
client.generate_icon_spritesheet(
"editor-resource-spec",
["蛇头向上", "蛇身直线", "转角", "尾部", "四类食物"],
"connected-components",
canvasSession=session,
assetLabel="贪吃蛇透明图集",
referenceId="must-not-override-explicit-reference",
@@ -904,7 +887,6 @@ def _self_test() -> None:
assert calls[0]["path"] == "/api/external/v1/editor/icon-spritesheets/generations"
assert calls[0]["body"]["referenceId"] == "editor-resource-spec"
assert calls[0]["body"]["screenColor"] == "auto"
assert calls[0]["body"]["sliceMode"] == "connected-components"
assert calls[0]["body"]["iconDescriptions"][0] == "蛇头向上"
assert calls[1]["path"] == "/api/external/v1/generations/task-operation-demo"
print("self-test ok")
+5 -19
View File
@@ -1,8 +1,8 @@
# Server-side OpenAI-compatible LLM endpoint base URL.
LLM_BASE_URL="https://api.tiantoken.com/v1"
LLM_BASE_URL="https://api.vectorengine.cn/v1"
# Server-side API key used by the local Vite proxy.
# Recommended: set `LLM_API_KEY` locally, or use `TIANTOKEN_API_KEY`
# Recommended: set `LLM_API_KEY` locally, or use `VECTOR_ENGINE_API_KEY`
# through the Rust api-server proxy.
# Legacy compatibility: `VITE_LLM_API_KEY` is still supported by the proxy,
# but it should not be relied on by browser code.
@@ -122,7 +122,7 @@ WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=""
# Model name for chat completions.
VITE_LLM_MODEL="gpt-5.4-mini"
GENARRATIVE_LLM_PROVIDER="openai-compatible"
GENARRATIVE_LLM_BASE_URL="https://api.tiantoken.com/v1"
GENARRATIVE_LLM_BASE_URL="https://api.vectorengine.cn/v1"
GENARRATIVE_LLM_API_KEY=""
GENARRATIVE_LLM_MODEL="gpt-5.4-mini"
@@ -130,15 +130,10 @@ GENARRATIVE_LLM_MODEL="gpt-5.4-mini"
DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1"
DASHSCOPE_API_KEY="YOUR_DASHSCOPE_API_KEY"
# Tiantoken LLM and GPT-image-2 / Gemini image generation config.
TIANTOKEN_BASE_URL="https://api.tiantoken.com"
TIANTOKEN_API_KEY=""
TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS="1000000"
# VectorEngine is retained for Suno audio generation only.
# VectorEngine LLM and GPT-image-2 / Gemini image generation config.
VECTOR_ENGINE_BASE_URL="https://api.vectorengine.cn"
VECTOR_ENGINE_API_KEY=""
VECTOR_ENGINE_AUDIO_REQUEST_TIMEOUT_MS="180000"
VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS="1000000"
# ElevenLabs editor sound-effect generation is server-side only.
ELEVENLABS_BASE_URL="https://api.elevenlabs.io"
@@ -158,15 +153,6 @@ ALIYUN_OSS_POST_EXPIRE_SECONDS="600"
ALIYUN_OSS_POST_MAX_SIZE_BYTES="20971520"
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 与唯一的
# `GENARRATIVE_PROCESS_ROLE=bgfilter-worker` 进程必须使用同一个内部 Token。
# `npm run dev` 与 `npm run dev:api-server` 都会自动带起并验活唯一 worker,不要再开第二个终端重复启动。
-1
View File
@@ -164,7 +164,6 @@ module.exports = {
'server-rs/target-*',
'apps/desktop-shell/src-tauri/target',
'apps/ai-game-creator-shell/src/features/ui-editor/types/**',
'apps/ai-game-creator-shell/src/features/project-workspace/generated/**',
'target',
'src/main.tsx',
'src/App.tsx',
-3
View File
@@ -23,6 +23,3 @@
*.meta text
*.anim text
*.controller text
# Rust ts-rs 生成的共享契约:保留在仓库中供 TS 消费,但不作为手写源文件统计。
packages/shared/src/contracts/generated/** linguist-generated=true
+4 -43
View File
@@ -7,10 +7,6 @@ on:
pull_request:
workflow_dispatch:
concurrency:
group: project-ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
@@ -221,7 +217,8 @@ jobs:
- name: Run AI game creator shell agent-run 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:
name: AI game creator shell Rust crates
runs-on: genarrative-ci
@@ -262,15 +259,13 @@ jobs:
# `cargo test --manifest-path` 单独跑这两个 crate。不在这里预热的话,这两条测试
# 会在测试阶段自己 `Updating crates.io index`crates.io 一抖动整条 job 就红
# (见 #327 / PR #316 run 1950)。
# Cocos 插件也使用独立且未提交的锁文件,一并预热。
# 这些 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch:
# 两个 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch:
# 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内,
# 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本
# 解析,不再触碰 registry index。
for manifest_path in \
server-rs/crates/agent-runtime-core/Cargo.toml \
server-rs/crates/agent-runtime-orchestration/Cargo.toml \
plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml; do
server-rs/crates/agent-runtime-orchestration/Cargo.toml; do
for attempt in $(seq 1 5); do
if cargo fetch \
--target x86_64-unknown-linux-gnu \
@@ -285,40 +280,6 @@ jobs:
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
run: npm run check:native-shells:agc-rust-crates
-10
View File
@@ -41,17 +41,7 @@ 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-package.json
/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
/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/.llm-drafts/
/apps/ai-game-creator-shell/game-creator.config.local.json
-3
View File
@@ -1,4 +1 @@
# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd;
# 子进程(npm、lint-staged、测试夹具)会继承它们并写到真实仓库,故在入口统一清除。
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES
npm run format:staged
-3
View File
@@ -1,4 +1 @@
# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd;
# 钩子链(npm → check:repository-ci → 测试夹具)会继承它们并写到真实仓库,故在入口统一清除。
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES
npm run check:pre-push-master -- "$@"
+1 -10
View File
@@ -1,14 +1,5 @@
{
"singleQuote": true,
"semi": true,
"trailingComma": "all",
"overrides": [
{
"files": "packages/shared/src/contracts/generated/**/*.ts",
"options": {
"printWidth": 1000,
"singleQuote": false
}
}
]
"trailingComma": "all"
}
-16
View File
@@ -172,20 +172,6 @@ _Avoid_: 多步骤向导、完整规则编辑器、拖拽编辑器
Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。
_Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
## 项目开发对话(DirectProject
**项目对话历史**:
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
**运行态事件**:
Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。
_Avoid_: 进度通知、快照轮询、第二套历史
**聊天投影**:
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
## Relationships
- 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**
@@ -220,5 +206,3 @@ _Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
- “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。
- “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。
- “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。
- “回合进度事件”曾同时指 Direct turn update 与 Thread Manager 运行态事件;已解析为 AGC 项目开发对话只保留 **运行态事件**
- “哪些消息可显示”曾可能由后端历史分页判断;已解析为可见性判断属于 **聊天投影**,后端只按原始条目分页,前端负责跳过不可显示条目并推进分页锚点。
-88
View File
@@ -32,8 +32,6 @@ import type {
AdminLoginResponse,
AdminMeResponse,
AdminOverviewResponse,
AdminProjectSnapshotListQuery,
AdminProjectSnapshotListResponse,
AdminRechargeOrderListQuery,
AdminRechargeOrderListResponse,
AdminRechargeRefundActionResponse,
@@ -200,92 +198,6 @@ export function listAdminAccounts(token: string) {
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(
token: string,
payload: AdminCreateAccountRequest,
-21
View File
@@ -96,27 +96,6 @@ export interface AdminMeResponse {
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 {
batchId: string;
eventCount: number;
@@ -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' });
});
-7
View File
@@ -31,7 +31,6 @@ import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
import { AdminLoginPage } from '../pages/AdminLoginPage';
import { AdminOverviewPage } from '../pages/AdminOverviewPage';
import { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage';
import { AdminProjectSnapshotsPage } from '../pages/AdminProjectSnapshotsPage';
import { AdminRechargeOrderPage } from '../pages/AdminRechargeOrderPage';
import { AdminRechargeProductPage } from '../pages/AdminRechargeProductPage';
import { AdminRedeemCodePage } from '../pages/AdminRedeemCodePage';
@@ -309,12 +308,6 @@ export function AdminApp() {
{activeRouteId === 'accounts' ? (
<AdminAccountsPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{activeRouteId === 'project-snapshots' ? (
<AdminProjectSnapshotsPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
</AdminShell>
);
}
-2
View File
@@ -4,7 +4,6 @@ import {
Bug,
Coins,
Database,
FolderArchive,
GitBranch,
Images,
LayoutDashboard,
@@ -50,7 +49,6 @@ const routeIcons = {
'editor-generation-pricing': Coins,
'editor-showcase': Star,
'editor-assets': Images,
'project-snapshots': FolderArchive,
accounts: Users,
'agc-models': ListChecks,
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
@@ -122,28 +122,3 @@ test('零权限 member 不回落到 Dashboard', () => {
expect(routes).toEqual([]);
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);
});
-2
View File
@@ -16,7 +16,6 @@ export type AdminRouteId =
| 'editor-generation-pricing'
| 'editor-showcase'
| 'editor-assets'
| 'project-snapshots'
| 'agc-models'
| 'accounts';
@@ -55,7 +54,6 @@ export const adminRoutes: AdminRouteDefinition[] = [
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
];
@@ -38,7 +38,6 @@ vi.mock('../api/adminApiClient', () => ({
interface MockIntersectionObserverController {
enter: (target: Element) => void;
enterAll: (targets: Element[]) => void;
isObserved: (target: Element) => boolean;
}
@@ -107,25 +106,6 @@ function installIntersectionObserverMock(): MockIntersectionObserverController {
);
});
},
enterAll(targets) {
act(() => {
for (const target of targets) {
const record = observed.get(target);
if (!record) {
throw new Error('目标缩略图尚未进入 IntersectionObserver');
}
record.callback(
[
{
isIntersecting: true,
target,
} as IntersectionObserverEntry,
],
record.observer,
);
}
});
},
isObserved(target) {
return observed.has(target);
},
@@ -773,10 +753,10 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as
const thumbnails = entries.map((entry) =>
thumbnailElementForLabel(entry.label),
);
for (const thumbnail of thumbnails) {
thumbnails.forEach((thumbnail) => {
expect(observer.isObserved(thumbnail)).toBe(true);
}
observer.enterAll(thumbnails);
observer.enter(thumbnail);
});
await act(async () => {
await Promise.resolve();
});
@@ -796,7 +776,7 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as
await vi.advanceTimersByTimeAsync(200);
});
expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(105);
}, 10_000);
});
test('后台素材查询读取更多后为新进入可视区域的素材换签', async () => {
const observer = installIntersectionObserverMock();
@@ -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]}`;
}
-106
View File
@@ -1452,112 +1452,6 @@ button:disabled {
min-width: 1180px;
}
.admin-project-snapshot-table {
min-width: 0;
table-layout: fixed;
}
.admin-project-snapshot-table th,
.admin-project-snapshot-table td {
overflow-wrap: anywhere;
}
.admin-project-snapshot-table th:first-child {
width: 20%;
}
.admin-project-snapshot-table th:nth-child(2) {
width: 14%;
}
.admin-project-snapshot-table th:nth-child(3) {
width: 18%;
}
.admin-project-snapshot-table th:nth-child(4) {
width: 7%;
}
.admin-project-snapshot-table th:nth-child(5) {
width: 9%;
}
.admin-project-snapshot-table th:nth-child(6) {
width: 12%;
}
.admin-project-snapshot-table th:last-child {
width: 20%;
}
@media (max-width: 1200px) {
.admin-project-snapshot-table,
.admin-project-snapshot-table tbody {
display: block;
}
.admin-project-snapshot-table thead {
display: none;
}
.admin-project-snapshot-table tr {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 10px 16px;
border-bottom: 1px solid #eaded2;
padding: 18px 0;
}
.admin-project-snapshot-table tr:first-child {
padding-top: 0;
}
.admin-project-snapshot-table tr:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.admin-project-snapshot-table td {
display: flex;
min-width: 0;
align-items: baseline;
gap: 8px;
border: 0;
padding: 0;
font-size: 14px;
}
.admin-project-snapshot-table td::before {
flex-shrink: 0;
color: #8f7868;
font-size: 12px;
content: attr(data-label);
}
.admin-project-snapshot-table td:first-child,
.admin-project-snapshot-table td:nth-child(2),
.admin-project-snapshot-table td:nth-child(3),
.admin-project-snapshot-table td:nth-child(6),
.admin-project-snapshot-table td:last-child {
grid-column: 1 / -1;
}
.admin-project-snapshot-table td:first-child {
display: block;
font-size: 16px;
}
.admin-project-snapshot-table td:first-child::before,
.admin-project-snapshot-table td:last-child::before {
display: none;
}
.admin-project-snapshot-table td:last-child button {
width: 100%;
justify-content: center;
}
}
.admin-recharge-table {
min-width: 1080px;
table-layout: fixed;
@@ -2,8 +2,6 @@
"schemaVersion": "game-creator-config.v2",
"agentMode": "codex_app_server",
"llm": {
"customEnabled": false,
"visibleModels": [],
"apiKey": "",
"baseUrl": "https://dev.genarrative.world/gpt/v1",
"model": "gpt-6-astra",
@@ -15,7 +13,7 @@
"autoCompactTokenLimit": 64000,
"toolOutputTokenLimit": 12000,
"requestTimeoutMs": 180000,
"maxRetries": 10,
"maxRetries": 2,
"retryBackoffMs": 500
},
"agentLlm": {}

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