Compare commits

..

3 Commits

Author SHA1 Message Date
suzmii 92c05428ec 另一架构切片不得借用宿主 npm
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m55s
Project CI / Backend tests (pull_request) Failing after 16s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m17s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 6m48s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 7m43s
Project CI / AI game creator shell web tests (pull_request) Successful in 5m28s
Project CI / Frontend tests (pull_request) Successful in 8m34s
Project CI / Native shell tests (pull_request) Successful in 12m47s
- runtimeSourceForTarget 对非宿主架构返回 npmCli: null,明令按该发行版自己的安装目录查找 npm
- 覆盖经 npm run 触发(npm_execpath 指向宿主 npm)时两份切片仍各带自己那份 npm 的用例
2026-09-21 12:06:39 +08:00
suzmii 91f1554ac7 macOS universal 管线接入双架构 Node 运行时前置
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
- Jenkinsfile.ai-game-creator-shell-macos-build 增加 AGC_NODE_RUNTIME_DARWIN_X64_HOME,并在 Toolchain 阶段编译前核对 x86_64 发行版存在且与构建 Node 同版本
- Package 阶段导出 AGC_NODE_RUNTIME_DARWIN_X64_PATH / _LICENSE_PATH,供 universal staging 取另一架构来源
- 开发运维文档记录节点前置:官方 darwin-x64 发行版安装与校验命令、同版本要求、按架构并列的资源布局、Rust 侧按运行架构选择、约 260 MB 解包体积代价,以及本机跨架构诊断构建的取值方式
- 实施计划与更新检查方案文档同步 universal 携带两套架构运行时的契约
2026-09-21 11:58:22 +08:00
suzmii 6a0b75779b AGC 随包 Node 支持 universal 双架构运行时
- stage-node-runtime 新增 targetRuntimes:universal 展开为 aarch64/x86_64 两份单架构运行时
- 新增 runtimeSourceForTarget:宿主架构取当前构建 Node,其它架构必须由 AGC_NODE_RUNTIME_<PLATFORM>_<ARCH>_PATH 显式提供,缺失即失败关闭
- 把 stageNodeRuntime 拆成 inspectRuntimeSource(只读校验)+ writeRuntimeBundle(落盘),universal 在写出任何目录前完成全部来源校验与版本一致性核对
- stageNodeRuntimeForTarget 单架构仍写扁平 node-runtime/,universal 按架构写 node-runtime/<platform>-<arch>/
- build-release 默认按发布目标 stage,universal 构建不再只带宿主架构的运行时
- environment_check resolve_at 在扁平目录之后按当前运行架构查找 <platform>-<arch> 子目录,两份候选都要过清单校验,存在但损坏则失败关闭
- 覆盖双架构 staging、缺非宿主运行时、版本不一致、按运行架构解析等用例
2026-09-21 11:57:43 +08:00
677 changed files with 59961 additions and 74123 deletions
-2
View File
@@ -213,8 +213,6 @@ module.exports = {
'!src/services/clipboard.test.ts',
'!src/services/frontendRuntimeConfigService.ts',
'!src/services/frontendRuntimeConfigService.test.ts',
'!src/services/gameDistributionClient.ts',
'!src/services/gameDistributionClient.test.ts',
'!src/services/sseStream.ts',
'!src/services/sseStream.test.ts',
'src/AdventurePanel.tsx',
+1 -92
View File
@@ -82,29 +82,12 @@ jobs:
sleep $((attempt * 2))
done
- name: Prepare isolated Rust compilation cache
shell: bash
run: |
set -euo pipefail
node --test scripts/ci-rust-cache.test.mjs
bash scripts/ci-rust-cache.sh prepare
- name: Run AI game creator shell Rust shard 1/4
run: npm run check:native-shells:agc-rust-shard-1
- name: Run AI game creator shell Rust shard 2/4
run: npm run check:native-shells:agc-rust-shard-2
- name: Report isolated Rust compilation cache
if: always()
run: bash scripts/ci-rust-cache.sh report
- name: Publish master Rust cache artifact
continue-on-error: true
if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }}
env:
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
run: python3 scripts/export-gitea-rust-cache.py
ai-game-creator-shell-rust-lane-2:
name: AI game creator shell Rust lane 2/2
runs-on: genarrative-ci
@@ -135,25 +118,12 @@ jobs:
sleep $((attempt * 2))
done
- name: Prepare isolated Rust compilation cache
run: bash scripts/ci-rust-cache.sh prepare
- name: Run AI game creator shell Rust shard 3/4
run: npm run check:native-shells:agc-rust-shard-3
- name: Run AI game creator shell Rust shard 4/4
run: npm run check:native-shells:agc-rust-shard-4
- name: Report isolated Rust compilation cache
if: always()
run: bash scripts/ci-rust-cache.sh report
- name: Publish master Rust cache artifact
continue-on-error: true
if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }}
env:
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
run: python3 scripts/export-gitea-rust-cache.py
# agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样不装 npm 依赖,
# 单独一个 job,免得把已经压到 4 分钟级的片 job 拖长。
ai-game-creator-shell-rust-smoke:
@@ -186,22 +156,9 @@ jobs:
sleep $((attempt * 2))
done
- name: Prepare isolated Rust compilation cache
run: bash scripts/ci-rust-cache.sh prepare
- name: Run AI game creator shell agent-run smoke
run: npm run check:native-shells:agc-rust-smoke
- name: Report isolated Rust compilation cache
if: always()
run: bash scripts/ci-rust-cache.sh report
- name: Publish master Rust cache artifact
continue-on-error: true
if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }}
env:
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
run: python3 scripts/export-gitea-rust-cache.py
# AGC 壳依赖的共享 / 平台和编辑器插件 crate 各自预热独立 manifest,再运行对应测试。
ai-game-creator-shell-rust-crates:
name: AI game creator shell Rust crates
@@ -300,22 +257,9 @@ jobs:
sleep $((attempt * 2))
done
- name: Prepare isolated Rust compilation cache
run: bash scripts/ci-rust-cache.sh prepare
- name: Run AI game creator shell shared crate gates
run: npm run check:native-shells:agc-rust-crates
- name: Report isolated Rust compilation cache
if: always()
run: bash scripts/ci-rust-cache.sh report
- name: Publish master Rust cache artifact
continue-on-error: true
if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }}
env:
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
run: python3 scripts/export-gitea-rust-cache.py
backend-tests:
name: Backend tests
runs-on: genarrative-ci
@@ -386,9 +330,6 @@ jobs:
- name: Check server-rs boundaries
run: npm run check:server-rs-ddd
- name: Prepare isolated Rust compilation cache
run: bash scripts/ci-rust-cache.sh prepare
- name: Run server-rs workspace tests
run: cargo test --locked --workspace --exclude spacetime-module --no-fail-fast --manifest-path server-rs/Cargo.toml
@@ -401,16 +342,6 @@ jobs:
- name: Check SpacetimeDB module
run: cargo check --locked -p spacetime-module --manifest-path server-rs/Cargo.toml
- name: Report isolated Rust compilation cache
if: always()
run: bash scripts/ci-rust-cache.sh report
- name: Publish master Rust cache artifact
continue-on-error: true
if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }}
env:
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
run: python3 scripts/export-gitea-rust-cache.py
# 客户端的壳级与契约门禁:静态契约断言、H5 / 微信 / 移动 / 桌面壳运行时门禁,
# 以及依赖发布产物的构建 smoke。
native-shell-tests:
@@ -453,32 +384,15 @@ jobs:
- name: Run native shell contract gates
run: npm run check:native-shells:contract
- name: Prepare isolated Rust compilation cache
run: bash scripts/ci-rust-cache.sh prepare
- name: Run native shell gates
run: npm run check:native-shells:shells
- name: Run native shell release build smoke
# 发布构建有独立 profile/features 和资源 staging,不消费测试对象快照。
env:
RUSTC_WRAPPER: ''
CARGO_BUILD_RUSTC_WRAPPER: ''
run: npm run check:native-shells:release
- name: Ensure native lockfiles are unchanged
run: git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock apps/ai-game-creator-shell/src-tauri/Cargo.lock
- name: Report isolated Rust compilation cache
if: always()
run: bash scripts/ci-rust-cache.sh report
- name: Publish master Rust cache artifact
continue-on-error: true
if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }}
env:
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
run: python3 scripts/export-gitea-rust-cache.py
frontend-tests:
name: Frontend tests
runs-on: genarrative-ci
@@ -496,7 +410,7 @@ jobs:
run: bash scripts/ci-npm-ci-with-retry.sh
- name: Run frontend and script tests
run: npm run test:ci:frontend
run: npm run test
- name: Run BgFilter worker smoke harness tests
run: npm run bgfilter-worker:smoke-test
@@ -560,11 +474,6 @@ jobs:
- name: Install npm dependencies
run: bash scripts/ci-npm-ci-with-retry.sh
- name: Validate CI cache maintenance behavior
run: |
python3 -m unittest discover -s scripts -p 'test_gitea_cache_*.py'
node --test scripts/export-ci-npm-download-cache.test.mjs
- name: Run repository checks
run: npm run check:repository-ci
-40
View File
@@ -174,14 +174,6 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
## 项目开发对话(DirectProject
**DirectProject 专属聊天模块**:
AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、发送队列、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。
_Avoid_: 把 DirectProject 作为项目总控聊天的一个布尔分支、把四种 Agent 会话抽象成同一事实源
**项目工作台布局**:
承载本地项目的资源工作区、项目级工具和独立聊天产品路径的外层界面;布局拥有跨面板的账户/钱包入口,聊天模块只负责项目对话,不嵌套账户展示。
_Avoid_: 把钱包入口塞进聊天设置、让聊天组件拥有工作台级账户状态
**项目对话历史**:
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
@@ -194,34 +186,6 @@ _Avoid_: 进度通知、快照轮询、第二套历史
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
**引用候选**:
输入区可以命中的对象集合(`@` 素材、`$` Skill),由宿主按种类注入;输入区不判断候选属于哪一类。
_Avoid_: 输入区自己读项目清单或应用目录、把候选取值写死在组件里
**引用 provider**:
一种引用种类向输入区提供的全部能力:触发符、候选、身份解析与正文文本形态;每种引用各一份,宿主按需选择性注入。
_Avoid_: 一个总装对象决定所有种类、输入区按种类分叉、provider 之间互相知道对方
**静默 provider**:
不提供候选、只负责已有引用身份与文本形态的 provider;附件与运行画面区域属于这一类,只能由外部插入或草稿回填进入正文。
_Avoid_: 给附件或运行画面区域造候选、为它们保留输入区内的专门分支
**引用文本语法**:
引用在正文文本里的形态(`@显示名` / `$名称` / `@附件名`)及其反解析;出站与解析必须同一口径,token 前后各留一个空白。
_Avoid_: 出站与解析各写一套、在空白边界之外再补兼容别名、让解析依赖具体种类的字段
**引用输入区**:
只负责编辑与渲染引用的共享输入组件;候选、身份解析与文本语法都来自注入的 provider,它不持有项目清单、不访问后端。
_Avoid_: 输入区自己拉 Skill 目录、把选择器面板塞在输入区内部
**引用选择器**:
宿主渲染的独立面板,自己拿数据与筛选状态,确认后把选中的引用交给输入区的插入缝。
_Avoid_: 输入区自带面板、每个宿主各画一个、绕开插入缝另开第二条通道
**附件芯片**:
聊天附件在正文里的唯一表示;附件导入成功即以芯片进入正文,正文之外不存在第二份附件状态。
_Avoid_: 待发送附件列表与正文芯片并存、提交时再拼一遍附件
## Relationships
- 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**
@@ -237,10 +201,6 @@ _Avoid_: 待发送附件列表与正文芯片并存、提交时再拼一遍附
- **个人历史成绩** 由最近记录列表和个人最佳摘要组成,只允许本人查看;排行榜只公开入榜胜利成绩。
- **正式作品入口闭环** 必须覆盖创作入口、作品详情 CTA、广场/作品卡片、我的作品/个人作品架、稳定作品 ID runtime 路由和 `work_play_start` 埋点。
- **Phase 2 实施顺序** 固定为:契约与领域规则 → SpacetimeDB 表/reducer 与 api-server BFF → 最小前端纵切 → 投影与列表体验 → 收口验证。
- **引用输入区** 由宿主注入的若干 **引用 provider** 组成;**引用候选** 与 **引用文本语法** 都来自 provider,输入区不判断引用种类。
- **引用选择器** 不属于 **引用输入区**:它自己拿数据,确认后只通过输入区的插入缝交付引用。
- 只有带触发符的 **引用 provider** 会产生候选;**静默 provider** 没有触发符,只能由外部插入或草稿回填进入正文。
- **附件芯片** 是本轮附件的唯一事实源;附件导入失败时不产生芯片。
## Example dialogue
@@ -3,18 +3,12 @@ import { afterEach, expect, test, vi } from 'vitest';
import {
createAdminAccount,
executeAdminRechargeRefund,
getAdminAgcTemplates,
getAdminFeatureGateConfig,
getAdminUserDetail,
importAdminAgcTemplates,
listAdminGameDistributionReviews,
listAdminRechargeOrders,
reconcileAdminUserConsumption,
resolveAdminRechargeRefundManualReview,
reviewAdminGameDistributionVersion,
suspendAdminGameDistributionGame,
updateAdminAccount,
updateAdminAgcTemplate,
uploadAdminEditorShowcaseCampaignImage,
upsertAdminFeatureGateConfig,
upsertProfileWalletConfig,
@@ -24,93 +18,6 @@ afterEach(() => {
vi.unstubAllGlobals();
});
test('模板管理读取和更新复用认证封装,提交 revision 和封面但不提交 ZIP 或版本', async () => {
const library = { revision: 'revision-new', writable: true, templates: [] };
const fetchMock = vi.fn().mockImplementation(
async () =>
new Response(JSON.stringify({ ok: true, data: library }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
const controller = new AbortController();
expect(await getAdminAgcTemplates('admin-token', controller.signal)).toEqual(
library,
);
const update = {
expectedRevision: 'revision-old',
title: '空白模板',
summary: '简介',
tags: ['2D'],
enabled: true,
cover: { contentType: 'image/png', dataBase64: 'aW1hZ2U=' },
};
expect(
await updateAdminAgcTemplate('admin-token', 'template/1', update),
).toEqual(library);
expect(fetchMock.mock.calls[0]).toEqual([
'/admin/api/agc-templates',
expect.objectContaining({
method: 'GET',
signal: controller.signal,
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
}),
]);
expect(fetchMock.mock.calls[1]).toEqual([
'/admin/api/agc-templates/template%2F1',
expect.objectContaining({
method: 'PUT',
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
'Content-Type': 'application/json',
}),
body: JSON.stringify(update),
}),
]);
});
test('模板批量导入走 multipart,不预设 JSON Content-Type', async () => {
const imported = {
revision: 'rev-2',
writable: true,
templates: [],
imported: [
{
id: 'alpha',
templateVersion: '0.1.0',
zipSizeBytes: 4,
zipSha256: 'a'.repeat(64),
reusedObjects: false,
},
],
};
const fetchMock = vi.fn().mockImplementation(
async () =>
new Response(JSON.stringify({ ok: true, data: imported }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
const form = new FormData();
form.append(
'manifest',
JSON.stringify({ expectedRevision: 'rev-1', templates: [] }),
);
form.append(
'zip_0',
new File([new Uint8Array([1])], 'alpha.zip', { type: 'application/zip' }),
);
expect(await importAdminAgcTemplates('admin-token', form)).toEqual(imported);
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe('/admin/api/agc-templates/import');
expect(init.method).toBe('POST');
expect(init.body).toBe(form);
expect(init.headers).not.toHaveProperty('Content-Type');
expect(init.headers.Authorization).toBe('Bearer admin-token');
});
test('后台账号创建和更新同时携带 Tab 与独立操作权限', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
@@ -457,134 +364,3 @@ test('退款人工复核使用独立 resolve 管理员路由', async () => {
}),
);
});
test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ entries: [], nextCursor: null }), {
status: 200,
}),
),
);
vi.stubGlobal('fetch', fetchMock);
await listAdminGameDistributionReviews('admin-token');
await reviewAdminGameDistributionVersion(
'admin-token',
'gamever/1',
'game-review-key-1',
{
decision: 'approve',
expectedPublicationRevision: 3,
entryUrl: 'https://games.example.test/releases/game_1/index.html',
},
);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'/admin/api/game-distribution/reviews?limit=48',
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(
'/admin/api/game-distribution/versions/gamever%2F1/review',
);
expect(fetchMock.mock.calls[1]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
'Idempotency-Key': 'game-review-key-1',
}),
body: JSON.stringify({
decision: 'approve',
expectedPublicationRevision: 3,
entryUrl: 'https://games.example.test/releases/game_1/index.html',
}),
}),
);
});
test('安全下架请求携带公开修订号、原因与幂等键', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ game: {}, replayed: false }), {
status: 200,
}),
),
);
vi.stubGlobal('fetch', fetchMock);
await suspendAdminGameDistributionGame(
'admin-token',
'game/1',
'game-suspend-key-1',
{ expectedPublicationRevision: 7, reason: '版权投诉' },
);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'/admin/api/game-distribution/games/game%2F1/suspend',
);
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
'Idempotency-Key': 'game-suspend-key-1',
}),
body: JSON.stringify({
expectedPublicationRevision: 7,
reason: '版权投诉',
}),
}),
);
expect(() =>
suspendAdminGameDistributionGame('admin-token', ' ', 'key', {
expectedPublicationRevision: 1,
}),
).toThrow('缺少游戏 ID');
expect(() =>
suspendAdminGameDistributionGame('admin-token', 'game-1', ' ', {
expectedPublicationRevision: 1,
}),
).toThrow('下架幂等键必须是 1 到 128 个字符');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test('游戏审核拒绝请求携带理由,空幂等键在本地失败关闭', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ version: {}, replayed: false }), {
status: 200,
}),
),
);
vi.stubGlobal('fetch', fetchMock);
await reviewAdminGameDistributionVersion(
'admin-token',
'version-1',
'game-review-key-2',
{
decision: 'reject',
expectedPublicationRevision: 0,
reviewReason: '运行时报错',
},
);
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
body: JSON.stringify({
decision: 'reject',
expectedPublicationRevision: 0,
reviewReason: '运行时报错',
}),
}),
);
expect(() =>
reviewAdminGameDistributionVersion('admin-token', 'version-1', ' ', {
decision: 'reject',
expectedPublicationRevision: 0,
reviewReason: 'x',
}),
).toThrow('审核幂等键必须是 1 到 128 个字符');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
+1 -126
View File
@@ -1,6 +1,5 @@
import type {
AdminAccountListResponse,
AdminAgcTemplateLibraryResponse,
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
AdminCreateAccountRequest,
AdminCreateAccountResponse,
@@ -30,14 +29,9 @@ import type {
AdminExternalApiKeyListQuery,
AdminExternalApiKeyListResponse,
AdminFeatureGateConfigResponse,
AdminGameDistributionReviewListResponse,
AdminGameDistributionReviewRequest,
AdminGameDistributionReviewResponse,
AdminImportAgcTemplatesResponse,
AdminLoginResponse,
AdminMeResponse,
AdminOverviewResponse,
AdminProjectSnapshotChannelsResponse,
AdminProjectSnapshotListQuery,
AdminProjectSnapshotListResponse,
AdminRechargeOrderListQuery,
@@ -53,7 +47,6 @@ import type {
AdminTrackingEventListResponse,
AdminUpdateAccountRequest,
AdminUpdateAccountResponse,
AdminUpdateAgcTemplateRequest,
AdminUploadedEditorShowcaseCampaignImage,
AdminUpsertEditorShowcaseCampaignRequest,
AdminUpsertFeatureGateConfigRequest,
@@ -92,8 +85,6 @@ interface AdminRequestOptions {
method?: string;
token?: string;
body?: unknown;
/** multipart 表单:交给浏览器自己带 boundary,不能预设 Content-Type。 */
formData?: FormData;
headers?: Record<string, string>;
signal?: AbortSignal;
}
@@ -181,8 +172,6 @@ export async function request<T>(
if (typeof options.body !== 'undefined') {
headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(options.body);
} else if (options.formData) {
init.body = options.formData;
}
const response = await fetch(buildRequestUrl(path), init);
@@ -218,7 +207,6 @@ export function listAdminProjectSnapshots(
) {
const params = new URLSearchParams();
if (query.cursor) params.set('cursor', query.cursor);
if (query.channel) params.set('channel', query.channel);
params.set('limit', String(query.limit ?? 20));
return request<AdminProjectSnapshotListResponse>(
`/admin/api/project-snapshots?${params.toString()}`,
@@ -226,27 +214,13 @@ export function listAdminProjectSnapshots(
);
}
export function getAdminProjectSnapshotChannels(
token: string,
signal?: AbortSignal,
) {
return request<AdminProjectSnapshotChannelsResponse>(
'/admin/api/project-snapshots/channels',
{ token, signal },
);
}
export async function downloadAdminProjectSnapshot(
token: string,
channel: string,
userId: string,
projectId: string,
signal?: AbortSignal,
) {
const params = new URLSearchParams();
if (channel) params.set('channel', channel);
const query = params.toString();
const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download${query ? `?${query}` : ''}`;
const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download`;
const response = await fetch(buildRequestUrl(path), {
headers: {
Authorization: `Bearer ${token.trim()}`,
@@ -1202,102 +1176,3 @@ export function saveAgcModelCatalog(
{ token, method: 'PUT', body },
);
}
export function listAdminGameDistributionReviews(token: string, limit = 48) {
const normalizedLimit = Number.isFinite(limit)
? Math.min(Math.max(Math.trunc(limit), 1), 48)
: 48;
return request<AdminGameDistributionReviewListResponse>(
`/admin/api/game-distribution/reviews?limit=${normalizedLimit}`,
{ token },
);
}
/**
* 审核游戏发行版本。幂等键由调用方生成并在同一次提交内复用,避免重复点击产生
* 两条审核结论。
*/
/**
* 安全下架整个游戏。管理员下架同样要求 CAS 修订号与幂等键,避免并发审核互相覆盖。
*/
export function suspendAdminGameDistributionGame(
token: string,
gameId: string,
idempotencyKey: string,
payload: import('./adminApiTypes').AdminGameDistributionSuspendRequest,
) {
const normalizedGameId = gameId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedGameId) {
throw new Error('缺少游戏 ID');
}
if (!normalizedKey || normalizedKey.length > 128) {
throw new Error('下架幂等键必须是 1 到 128 个字符');
}
return request<
import('./adminApiTypes').AdminGameDistributionSuspendResponse
>(
`/admin/api/game-distribution/games/${encodeURIComponent(normalizedGameId)}/suspend`,
{
method: 'POST',
token,
headers: { 'Idempotency-Key': normalizedKey },
body: payload,
},
);
}
export function reviewAdminGameDistributionVersion(
token: string,
versionId: string,
idempotencyKey: string,
payload: AdminGameDistributionReviewRequest,
) {
const normalizedVersionId = versionId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedVersionId) {
throw new Error('缺少发行版本 ID');
}
if (!normalizedKey || normalizedKey.length > 128) {
throw new Error('审核幂等键必须是 1 到 128 个字符');
}
return request<AdminGameDistributionReviewResponse>(
`/admin/api/game-distribution/versions/${encodeURIComponent(normalizedVersionId)}/review`,
{
method: 'POST',
token,
headers: { 'Idempotency-Key': normalizedKey },
body: payload,
},
);
}
export function getAdminAgcTemplates(token: string, signal?: AbortSignal) {
return request<AdminAgcTemplateLibraryResponse>('/admin/api/agc-templates', {
token,
signal,
});
}
export function updateAdminAgcTemplate(
token: string,
id: string,
body: AdminUpdateAgcTemplateRequest,
) {
return request<AdminAgcTemplateLibraryResponse>(
`/admin/api/agc-templates/${encodeURIComponent(id)}`,
{ token, method: 'PUT', body },
);
}
/** 批量导入模板包:manifest 与 zip_N / cover_N 一起走 multipart,一批一次锁一次提交。 */
export function importAdminAgcTemplates(token: string, formData: FormData) {
return request<AdminImportAgcTemplatesResponse>(
'/admin/api/agc-templates/import',
{
token,
method: 'POST',
formData,
},
);
}
-118
View File
@@ -105,15 +105,11 @@ export interface AdminProjectSnapshotEntry {
fileCount: number;
totalBytes: number;
status: 'ready' | 'partial' | 'unverified';
channel: string;
authorDisplayName?: string | null;
authorPublicUserCode?: string | null;
}
export interface AdminProjectSnapshotListQuery {
cursor?: string | null;
limit?: number;
channel?: string | null;
}
export interface AdminProjectSnapshotListResponse {
@@ -121,11 +117,6 @@ export interface AdminProjectSnapshotListResponse {
nextCursor: string | null;
}
export interface AdminProjectSnapshotChannelsResponse {
defaultChannel: string;
channels: string[];
}
export interface AdminErrorReportEntry {
batchId: string;
eventCount: number;
@@ -1042,112 +1033,3 @@ export interface AdminAgcModelCatalog {
defaultModelId: string;
models: AdminAgcModel[];
}
export interface AdminGameDistributionReviewEntry {
versionId: string;
gameId: string;
versionNumber: number;
packageSha256: string;
packageBytes: number;
status: string;
publicationRevision: number;
reviewReason: string | null;
createdAt: string;
updatedAt: string;
}
export interface AdminGameDistributionReviewListResponse {
entries: AdminGameDistributionReviewEntry[];
nextCursor: string | null;
}
export interface AdminGameDistributionReviewRequest {
decision: 'approve' | 'reject';
expectedPublicationRevision: number;
reviewReason?: string;
entryUrl?: string;
}
export interface AdminGameDistributionReviewResponse {
version: AdminGameDistributionReviewEntry;
replayed: boolean;
}
export interface AdminGameDistributionSuspendRequest {
expectedPublicationRevision: number;
reason?: string;
}
export interface AdminGameDistributionSuspendResponse {
game: {
id: string;
title: string;
status: string;
publicationRevision: number;
};
replayed: boolean;
}
export interface AdminAgcTemplatePayload {
id: string;
title: string;
summary: string;
tags: string[];
runtime: string;
engine: string;
engineVersion: string;
templateVersion: string;
enabled: boolean;
coverUrl: string;
zipSizeBytes: number;
}
export interface AdminAgcTemplateLibraryResponse {
revision: string;
writable: boolean;
templates: AdminAgcTemplatePayload[];
}
export interface AdminUpdateAgcTemplateRequest {
expectedRevision: string;
title: string;
summary: string;
tags: string[];
enabled: boolean;
cover?: {
contentType: string;
dataBase64: string;
};
}
export interface AdminImportAgcTemplateItemPayload {
id: string;
title: string;
summary: string;
tags: string[];
runtime: string;
engine: string;
engineVersion: string;
templateVersion: string;
entry: string;
zipField: string;
coverField: string;
}
export interface AdminImportAgcTemplatesManifest {
expectedRevision: string;
templates: AdminImportAgcTemplateItemPayload[];
}
export interface AdminImportAgcTemplateResult {
id: string;
templateVersion: string;
zipSizeBytes: number;
zipSha256: string;
reusedObjects: boolean;
}
export interface AdminImportAgcTemplatesResponse
extends AdminAgcTemplateLibraryResponse {
imported: AdminImportAgcTemplateResult[];
}
@@ -2,7 +2,6 @@ import { afterEach, expect, test, vi } from 'vitest';
import {
downloadAdminProjectSnapshot,
getAdminProjectSnapshotChannels,
listAdminProjectSnapshots,
} from './adminApiClient';
@@ -22,12 +21,12 @@ test('项目列表携带分页与后台授权,解析标准响应', async () =>
expect(
await listAdminProjectSnapshots(
'admin-token',
{ cursor: 'user/a+项目', limit: 20, channel: 'release' },
{ 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&channel=release&limit=20',
'/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,
@@ -35,23 +34,6 @@ test('项目列表携带分页与后台授权,解析标准响应', async () =>
);
});
test('渠道列表按后台授权读取,解析本部署渠道', async () => {
const payload = { defaultChannel: 'release', channels: ['dev', 'release'] };
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ ok: true, data: payload })),
);
vi.stubGlobal('fetch', fetchMock);
expect(await getAdminProjectSnapshotChannels('admin-token')).toEqual(payload);
expect(fetchMock).toHaveBeenCalledWith(
'/admin/api/project-snapshots/channels',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
}),
);
});
test('ZIP 下载以授权请求读取并优先保留中文附件名', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response('PK\u0003\u0004', {
@@ -66,7 +48,6 @@ test('ZIP 下载以授权请求读取并优先保留中文附件名', async () =
const controller = new AbortController();
const archive = await downloadAdminProjectSnapshot(
'admin-token',
'release',
'user/a',
'project/b',
controller.signal,
@@ -74,7 +55,7 @@ test('ZIP 下载以授权请求读取并优先保留中文附件名', async () =
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?channel=release',
'/admin/api/project-snapshots/user%2Fa/project%2Fb/download',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
@@ -100,8 +81,7 @@ test.each([
vi.fn().mockResolvedValue(new Response('PK', { headers })),
);
expect(
(await downloadAdminProjectSnapshot('token', 'release', 'user', 'project'))
.filename,
(await downloadAdminProjectSnapshot('token', 'user', 'project')).filename,
).toBe(expected.replace('工程', 'project'));
});
@@ -124,7 +104,7 @@ test.each([401, 403, 409, 500])(
),
);
await expect(
downloadAdminProjectSnapshot('token', 'release', 'user', 'project'),
downloadAdminProjectSnapshot('token', 'user', 'project'),
).rejects.toMatchObject({
status,
code: 'SNAPSHOT_FAILURE',
@@ -143,6 +123,6 @@ test('200 JSON 或 HTML 不能被保存为成功 ZIP', async () => {
),
);
await expect(
downloadAdminProjectSnapshot('token', 'release', 'user', 'project'),
downloadAdminProjectSnapshot('token', 'user', 'project'),
).rejects.toMatchObject({ code: 'INVALID_PROJECT_ARCHIVE_RESPONSE' });
});
-14
View File
@@ -19,7 +19,6 @@ import {
} from '../auth/adminAuthStore';
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
import { AdminAgcTemplatesPage } from '../pages/AdminAgcTemplatesPage';
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
@@ -27,7 +26,6 @@ import { AdminEditorAssetQueryPage } from '../pages/AdminEditorAssetQueryPage';
import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGenerationPricingPage';
import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage';
import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage';
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
import { AdminLoginPage } from '../pages/AdminLoginPage';
@@ -296,24 +294,12 @@ export function AdminApp() {
{activeRouteId === 'agc-models' ? (
<AdminAgcModelsPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{activeRouteId === 'agc-templates' ? (
<AdminAgcTemplatesPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'editor-showcase' ? (
<AdminEditorShowcaseReviewPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'game-distribution' ? (
<AdminGameDistributionReviewPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'editor-assets' ? (
<AdminEditorAssetQueryPage
token={token}
-3
View File
@@ -5,7 +5,6 @@ import {
Coins,
Database,
FolderArchive,
Gamepad2,
GitBranch,
Images,
LayoutDashboard,
@@ -50,12 +49,10 @@ const routeIcons = {
'recharge-orders': ReceiptText,
'editor-generation-pricing': Coins,
'editor-showcase': Star,
'game-distribution': Gamepad2,
'editor-assets': Images,
'project-snapshots': FolderArchive,
accounts: Users,
'agc-models': ListChecks,
'agc-templates': Images,
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
export function AdminShell({
@@ -147,51 +147,3 @@ test('项目工程入口对 owner 与已授权 member 开放且可分配权限',
}),
).not.toContainEqual(route);
});
test('后台游戏审核路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'game-distribution',
label: '游戏审核',
hash: '#game-distribution',
});
expect(resolveAdminRoute('#game-distribution')).toBe('game-distribution');
expect(routeHash('game-distribution')).toBe('#game-distribution');
});
test('member 可单独获得游戏审核 Tab 权限', () => {
const routes = getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['game-distribution'],
});
expect(routes.map((route) => route.id)).toEqual(['game-distribution']);
expect(resolveAccessibleAdminRoute('#game-distribution', routes)).toBe(
'game-distribution',
);
});
test('模板管理只对 owner 或具有 agc-templates 权限的 member 可见', () => {
expect(adminRoutes).toContainEqual({
id: 'agc-templates',
label: '模板管理',
hash: '#agc-templates',
});
expect(resolveAdminRoute('#agc-templates')).toBe('agc-templates');
expect(routeHash('agc-templates')).toBe('#agc-templates');
expect(
getAccessibleAdminRoutes({ accountRole: 'owner', tabPermissions: [] }).some(
(route) => route.id === 'agc-templates',
),
).toBe(true);
expect(
getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['agc-templates'],
}).map((route) => route.id),
).toEqual(['agc-templates']);
expect(
getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['editor-assets'],
}).some((route) => route.id === 'agc-templates'),
).toBe(false);
});
-4
View File
@@ -15,11 +15,9 @@ export type AdminRouteId =
| 'recharge-orders'
| 'editor-generation-pricing'
| 'editor-showcase'
| 'game-distribution'
| 'editor-assets'
| 'project-snapshots'
| 'agc-models'
| 'agc-templates'
| 'accounts';
export type AdminTabPermission = Exclude<
@@ -55,9 +53,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
hash: '#editor-generation-pricing',
},
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
{ id: 'agc-templates', label: '模板管理', hash: '#agc-templates' },
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
{ id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' },
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
-1
View File
@@ -1,4 +1,3 @@
import '@genarrative/shared/styles.css';
import './styles/admin.css';
import { StrictMode } from 'react';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,186 +0,0 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, expect, test, vi } from 'vitest';
import {
listAdminGameDistributionReviews,
reviewAdminGameDistributionVersion,
suspendAdminGameDistributionGame,
} from '../api/adminApiClient';
import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes';
import {
AdminGameDistributionReviewPage,
resolveGameReleaseEntryUrlError,
} from './AdminGameDistributionReviewPage';
vi.mock('../api/adminApiClient', () => ({
isAdminApiError: vi.fn(
(error: unknown) =>
typeof error === 'object' &&
error !== null &&
'status' in error &&
typeof error.status === 'number',
),
formatAdminApiError: vi.fn((error: unknown) =>
error instanceof Error ? error.message : '请求失败',
),
listAdminGameDistributionReviews: vi.fn(),
reviewAdminGameDistributionVersion: vi.fn(),
suspendAdminGameDistributionGame: vi.fn(),
}));
const entry: AdminGameDistributionReviewEntry = {
versionId: 'version-1',
gameId: 'game_1',
versionNumber: 2,
packageSha256: 'a'.repeat(64),
packageBytes: 2048,
status: 'pending_review',
publicationRevision: 4,
reviewReason: null,
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T08:00:00Z',
};
beforeEach(() => {
vi.mocked(listAdminGameDistributionReviews).mockReset();
vi.mocked(reviewAdminGameDistributionVersion).mockReset();
vi.mocked(suspendAdminGameDistributionGame).mockReset();
vi.mocked(listAdminGameDistributionReviews).mockResolvedValue({
entries: [entry],
nextCursor: null,
});
});
test('发行入口必须是带完整来源的 HTTPS 地址', () => {
expect(resolveGameReleaseEntryUrlError('')).toBe('请填写发行入口');
expect(
resolveGameReleaseEntryUrlError('http://games.test/a/index.html'),
).toBe('发行入口必须以 https:// 开头');
expect(
resolveGameReleaseEntryUrlError('https://games.test/a/index.html?token=1'),
).toBe('发行入口不能包含 query 或 fragment');
expect(
resolveGameReleaseEntryUrlError('https://u:p@games.test/a/index.html'),
).toBe('发行入口不能包含凭据');
expect(
resolveGameReleaseEntryUrlError('https://games.test/a/index.html'),
).toBe('');
});
test('通过审核时提交当前 publicationRevision 与发行入口并刷新列表', async () => {
vi.mocked(reviewAdminGameDistributionVersion).mockResolvedValue({
version: { ...entry, status: 'published' },
replayed: false,
});
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
fireEvent.change(screen.getByLabelText('发行入口'), {
target: { value: 'https://games.test/releases/game_1/index.html' },
});
fireEvent.click(screen.getByRole('button', { name: '通过' }));
await waitFor(() =>
expect(reviewAdminGameDistributionVersion).toHaveBeenCalledTimes(1),
);
const [token, versionId, idempotencyKey, payload] =
vi.mocked(reviewAdminGameDistributionVersion).mock.calls[0] ?? [];
expect(token).toBe('admin-token');
expect(versionId).toBe('version-1');
expect(String(idempotencyKey)).toContain('version-1');
expect(payload).toEqual({
decision: 'approve',
expectedPublicationRevision: 4,
entryUrl: 'https://games.test/releases/game_1/index.html',
});
await waitFor(() =>
expect(vi.mocked(listAdminGameDistributionReviews)).toHaveBeenCalledTimes(
2,
),
);
});
test('缺少拒绝理由时不调用审核接口', async () => {
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
fireEvent.click(screen.getByRole('button', { name: '拒绝' }));
expect(await screen.findByText('拒绝审核必须填写理由')).toBeTruthy();
expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled();
});
test('安全下架需要二次确认,并携带公开修订号与原因', async () => {
vi.mocked(suspendAdminGameDistributionGame).mockResolvedValue({
game: {
id: 'game_1',
title: '测试游戏',
status: 'suspended',
publicationRevision: 5,
},
replayed: false,
});
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
fireEvent.change(screen.getByLabelText('下架原因'), {
target: { value: '盗用素材' },
});
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
// 第一次点击只弹出确认面板,不直接调用后端。
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
expect(await screen.findByRole('dialog')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1),
);
const [token, gameId, idempotencyKey, payload] =
vi.mocked(suspendAdminGameDistributionGame).mock.calls[0] ?? [];
expect(token).toBe('admin-token');
expect(gameId).toBe('game_1');
expect(String(idempotencyKey)).toContain('game_1');
expect(payload).toEqual({
expectedPublicationRevision: 4,
reason: '盗用素材',
});
expect(await screen.findByText(//u)).toBeTruthy();
});
test('取消确认时不下架', async () => {
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
await screen.findByRole('dialog');
fireEvent.click(screen.getByRole('button', { name: '取消' }));
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
});
@@ -1,365 +0,0 @@
import { RefreshCcw } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import {
listAdminGameDistributionReviews,
reviewAdminGameDistributionVersion,
suspendAdminGameDistributionGame,
} from '../api/adminApiClient';
import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError } from './pageUtils';
interface AdminGameDistributionReviewPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
function formatBytes(value: number) {
if (value >= 1024 * 1024) {
return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
}
if (value >= 1024) {
return `${(value / 1024).toFixed(1)} KiB`;
}
return `${value} B`;
}
function formatTime(value: string) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleString('zh-CN', { hour12: false });
}
function createSuspendIdempotencyKey(gameId: string) {
const random =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `game-suspend-${gameId}-${random}`.slice(0, 128);
}
function createReviewIdempotencyKey(versionId: string) {
const random =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `game-review-${versionId}-${random}`.slice(0, 128);
}
export function resolveGameReleaseEntryUrlError(value: string) {
const normalized = value.trim();
if (!normalized) return '请填写发行入口';
if (!normalized.startsWith('https://')) {
return '发行入口必须以 https:// 开头';
}
if (normalized.includes('?') || normalized.includes('#')) {
return '发行入口不能包含 query 或 fragment';
}
try {
const parsed = new URL(normalized);
if (parsed.username || parsed.password) {
return '发行入口不能包含凭据';
}
} catch {
return '发行入口不是合法 URL';
}
return '';
}
export function AdminGameDistributionReviewPage({
token,
onUnauthorized,
}: AdminGameDistributionReviewPageProps) {
const [entries, setEntries] = useState<AdminGameDistributionReviewEntry[]>(
[],
);
const [isLoading, setIsLoading] = useState(false);
const [busyVersionId, setBusyVersionId] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [statusMessage, setStatusMessage] = useState('');
const [entryUrlByVersion, setEntryUrlByVersion] = useState<
Record<string, string>
>({});
const [reasonByVersion, setReasonByVersion] = useState<
Record<string, string>
>({});
const [suspendReasonByGame, setSuspendReasonByGame] = useState<
Record<string, string>
>({});
const [busyGameId, setBusyGameId] = useState('');
const writeConfirm = useAdminWriteConfirm();
const loadReviews = useCallback(async () => {
setIsLoading(true);
setErrorMessage('');
try {
const response = await listAdminGameDistributionReviews(token);
setEntries(response.entries);
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
}, [token, onUnauthorized]);
useEffect(() => {
void loadReviews();
}, [loadReviews]);
async function submitReview(
entry: AdminGameDistributionReviewEntry,
decision: 'approve' | 'reject',
) {
const entryUrl = (entryUrlByVersion[entry.versionId] ?? '').trim();
const reason = (reasonByVersion[entry.versionId] ?? '').trim();
if (decision === 'approve') {
const invalid = resolveGameReleaseEntryUrlError(entryUrl);
if (invalid) {
setErrorMessage(invalid);
return;
}
} else if (!reason) {
setErrorMessage('拒绝审核必须填写理由');
return;
}
setBusyVersionId(entry.versionId);
setErrorMessage('');
setStatusMessage('');
try {
await reviewAdminGameDistributionVersion(
token,
entry.versionId,
createReviewIdempotencyKey(entry.versionId),
decision === 'approve'
? {
decision,
expectedPublicationRevision: entry.publicationRevision,
entryUrl,
}
: {
decision,
expectedPublicationRevision: entry.publicationRevision,
reviewReason: reason,
},
);
setStatusMessage(
decision === 'approve'
? `版本 v${entry.versionNumber} 已通过审核`
: `版本 v${entry.versionNumber} 已拒绝`,
);
await loadReviews();
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setBusyVersionId('');
}
}
/**
* 管理员安全下架:先二次确认,再带当前公开修订号调用后端;并发审核导致修订号变化时
* 由服务端返回冲突,前端只提示刷新,不静默重试。
*/
async function suspendGame(entry: AdminGameDistributionReviewEntry) {
const reason = (suspendReasonByGame[entry.gameId] ?? '').trim();
const confirmed = await writeConfirm.confirmWrite({
action: '安全下架游戏',
target: `${entry.gameId}(版本 v${entry.versionNumber}`,
});
if (!confirmed) return;
setBusyGameId(entry.gameId);
setErrorMessage('');
setStatusMessage('');
try {
await suspendAdminGameDistributionGame(
token,
entry.gameId,
createSuspendIdempotencyKey(entry.gameId),
{
expectedPublicationRevision: entry.publicationRevision,
...(reason ? { reason } : {}),
},
);
setStatusMessage(`游戏 ${entry.gameId} 已安全下架,发行入口已关闭`);
setSuspendReasonByGame((current) => ({ ...current, [entry.gameId]: '' }));
await loadReviews();
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setBusyGameId('');
}
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<h1></h1>
<button
type="button"
className="admin-secondary-button"
onClick={() => void loadReviews()}
disabled={isLoading}
>
<RefreshCcw aria-hidden="true" />
</button>
</div>
{errorMessage ? (
<div className="admin-alert admin-alert-warning" role="alert">
{errorMessage}
</div>
) : null}
{statusMessage ? (
<div className="admin-alert admin-alert-success" role="status">
{statusMessage}
</div>
) : null}
<div className="admin-panel">
<div className="admin-panel-heading">
<h2></h2>
<span className="admin-muted-text"> {entries.length} </span>
</div>
{isLoading ? (
<p className="admin-muted-text"></p>
) : null}
{!isLoading && entries.length === 0 ? (
<p className="admin-muted-text"></p>
) : null}
{!isLoading && entries.length > 0 ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-wide">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{entries.map((entry) => {
const busy = busyVersionId === entry.versionId;
return (
<tr key={entry.versionId}>
<td>
<code>{entry.gameId}</code>
</td>
<td>
v{entry.versionNumber}
<div className="admin-muted-text">{entry.status}</div>
{entry.reviewReason ? (
<div className="admin-muted-text">
{entry.reviewReason}
</div>
) : null}
</td>
<td>
{formatBytes(entry.packageBytes)}
<div className="admin-muted-text">
<code>{entry.packageSha256.slice(0, 12)}</code>
</div>
</td>
<td>{formatTime(entry.createdAt)}</td>
<td>
<div className="admin-action-row">
<div className="admin-field">
<label
htmlFor={`game-release-url-${entry.versionId}`}
>
</label>
<input
id={`game-release-url-${entry.versionId}`}
value={entryUrlByVersion[entry.versionId] ?? ''}
placeholder="https://"
onChange={(event) =>
setEntryUrlByVersion((current) => ({
...current,
[entry.versionId]: event.target.value,
}))
}
disabled={busy}
/>
</div>
<button
type="button"
className="admin-primary-button"
disabled={busy}
onClick={() => void submitReview(entry, 'approve')}
>
</button>
<div className="admin-field">
<label
htmlFor={`game-reject-reason-${entry.versionId}`}
>
</label>
<input
id={`game-reject-reason-${entry.versionId}`}
value={reasonByVersion[entry.versionId] ?? ''}
onChange={(event) =>
setReasonByVersion((current) => ({
...current,
[entry.versionId]: event.target.value,
}))
}
disabled={busy}
/>
</div>
<button
type="button"
className="admin-ghost-button"
disabled={busy}
onClick={() => void submitReview(entry, 'reject')}
>
</button>
<div className="admin-field">
<label
htmlFor={`game-suspend-reason-${entry.versionId}`}
>
</label>
<input
id={`game-suspend-reason-${entry.versionId}`}
value={suspendReasonByGame[entry.gameId] ?? ''}
onChange={(event) =>
setSuspendReasonByGame((current) => ({
...current,
[entry.gameId]: event.target.value,
}))
}
disabled={busy}
/>
</div>
<button
type="button"
className="admin-danger-button"
disabled={busy || busyGameId === entry.gameId}
onClick={() => void suspendGame(entry)}
>
{busyGameId === entry.gameId
? '正在下架…'
: '安全下架'}
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : null}
</div>
{writeConfirm.confirmDialog}
</section>
);
}
@@ -175,86 +175,6 @@ test('灰度发布页可选择模板库并默认启用零比例灰度', 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 前缀'), [
'game-distribution',
]);
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
'game-distribution:publish',
);
expect(
(screen.getByLabelText('Gate Key 目标') as HTMLSelectElement).value,
).toBe('publish');
// 该开关的语义是「未配置/关闭 = 默认开放」,所以选中后不能默认打开收紧。
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
false,
);
expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe(
'0',
);
expect(
(screen.getByLabelText('描述') as HTMLTextAreaElement).value,
).toContain('游戏发布入口灰度');
});
test('灰度发布页保存游戏发布开关时写入白名单与比例', async () => {
const user = userEvent.setup();
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
gates: [
...configResponse.gates,
{
gateKey: 'game-distribution:publish',
enabled: true,
rolloutPercent: 20,
allowUserIds: ['user-internal'],
allowUserTags: [],
denyUserIds: [],
description: '游戏发布入口灰度',
updatedAt: '2026-09-22T10:00:00Z',
},
],
});
render(
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByRole('button', { name: 'editor.new-toolbar' });
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
'game-distribution',
]);
fireEvent.click(screen.getByLabelText('启用'));
fireEvent.change(screen.getByLabelText('灰度比例'), {
target: { value: '20' },
});
fireEvent.change(screen.getByLabelText('允许用户 ID'), {
target: { value: 'user-internal' },
});
fireEvent.change(screen.getByLabelText('描述'), {
target: { value: '游戏发布入口灰度' },
});
await user.click(screen.getByRole('button', { name: '保存配置' }));
await user.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(upsertAdminFeatureGateConfig).toHaveBeenCalledWith('admin-token', {
gateKey: 'game-distribution:publish',
enabled: true,
rolloutPercent: 20,
allowUserIds: ['user-internal'],
allowUserTags: [],
denyUserIds: [],
description: '游戏发布入口灰度',
}),
);
});
test('灰度发布页保存时转换数组和百分比', async () => {
const user = userEvent.setup();
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
@@ -28,7 +28,6 @@ interface GateTargetOption {
const GATE_PREFIX_LABELS: Record<string, string> = {
'image-editor': '画布',
agc: '客户端',
'game-distribution': '游戏分发',
};
const FIXED_GATE_TARGETS: GateTargetOption[] = [
@@ -46,14 +45,6 @@ const FIXED_GATE_TARGETS: GateTargetOption[] = [
label: 'Agent 侧边栏',
description: '画布 Agent 入口灰度',
},
{
prefix: 'game-distribution',
suffix: 'publish',
key: 'game-distribution:publish',
label: '游戏发布',
description:
'游戏发布入口灰度:未配置或关闭时对已登录作者默认开放,开启后只放行白名单 / 灰度命中',
},
];
export function AdminGrayReleaseConfigPage({
@@ -13,7 +13,6 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import {
AdminApiError,
downloadAdminProjectSnapshot,
getAdminProjectSnapshotChannels,
listAdminProjectSnapshots,
} from '../api/adminApiClient';
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
@@ -24,7 +23,6 @@ vi.mock('../api/adminApiClient', async () => ({
'../api/adminApiClient',
)),
downloadAdminProjectSnapshot: vi.fn(),
getAdminProjectSnapshotChannels: vi.fn(),
listAdminProjectSnapshots: vi.fn(),
}));
@@ -37,18 +35,12 @@ const entry: AdminProjectSnapshotEntry = {
fileCount: 12,
totalBytes: 2048,
status: 'ready',
channel: 'dev',
authorDisplayName: '陶泥作者',
authorPublicUserCode: 'SY-00000007',
};
beforeEach(() => {
vi.mocked(listAdminProjectSnapshots)
.mockReset()
.mockResolvedValue({ items: [entry], nextCursor: null });
vi.mocked(getAdminProjectSnapshotChannels)
.mockReset()
.mockResolvedValue({ defaultChannel: 'dev', channels: ['dev'] });
vi.mocked(downloadAdminProjectSnapshot).mockReset();
});
afterEach(() => {
@@ -96,166 +88,36 @@ test('按项目展示完整性并限制未完成工程下载', async () => {
).toBe(false);
});
test('按游标翻页回到上一页时复用已取得的游标', async () => {
test('加载更多合并项目,刷新失败保留列表和错误,重试从首页开始', async () => {
vi.mocked(listAdminProjectSnapshots)
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
.mockResolvedValueOnce({
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
nextCursor: 'page-3',
nextCursor: null,
})
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' });
.mockRejectedValueOnce(new Error('远端清单读取失败'))
.mockResolvedValueOnce({ items: [], nextCursor: null });
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
await screen.findByText('三消工程');
const pagination = await screen.findByRole('navigation', {
name: '项目工程分页',
});
expect(pagination.textContent).toContain('第 1 页');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
1,
'token',
{ cursor: null, limit: 20, channel: 'dev' },
expect.any(AbortSignal),
);
expect(
screen.getByRole('button', { name: '上一页' }).hasAttribute('disabled'),
).toBe(true);
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
fireEvent.click(await screen.findByRole('button', { name: '加载更多' }));
await screen.findByText('第二工程');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
2,
'token',
{ cursor: 'page-2', limit: 20, channel: 'dev' },
{ cursor: 'page-2', limit: 20 },
expect.any(AbortSignal),
);
expect(screen.queryByText('三消工程')).toBeNull();
expect(pagination.textContent).toContain('第 2 页');
expect(
screen.getByRole('button', { name: '下一页' }).hasAttribute('disabled'),
).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '上一页' }));
await screen.findByText('三消工程');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
3,
'token',
{ cursor: null, limit: 20, channel: 'dev' },
expect.any(AbortSignal),
);
expect(pagination.textContent).toContain('第 1 页');
});
test('切换每页条数从第一页按新条数重新加载', async () => {
vi.mocked(listAdminProjectSnapshots)
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
.mockResolvedValueOnce({
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
nextCursor: 'page-3',
})
.mockResolvedValueOnce({ items: [entry], nextCursor: null });
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
await screen.findByText('三消工程');
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await screen.findByText('第二工程');
const pagination = screen.getByRole('navigation', { name: '项目工程分页' });
expect(pagination.textContent).toContain('第 2 页');
fireEvent.change(screen.getByLabelText('每页条数'), {
target: { value: '50' },
});
await screen.findByText('三消工程');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
3,
'token',
{ cursor: null, limit: 50, channel: 'dev' },
expect.any(AbortSignal),
);
// 重新加载时页脚会重建,必须重新取节点再断言。
expect(
screen.getByRole('navigation', { name: '项目工程分页' }).textContent,
).toContain('第 1 页');
});
test('刷新重载当前页,翻页失败保留当前页并提示错误', async () => {
vi.mocked(listAdminProjectSnapshots)
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
.mockResolvedValueOnce({
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
nextCursor: null,
})
.mockRejectedValueOnce(new Error('翻页读取失败'));
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
await screen.findByText('三消工程');
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await screen.findByText('第二工程');
expect(screen.getByText('三消工程')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
await screen.findByRole('alert');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
3,
'token',
{ cursor: 'page-2', limit: 20, channel: 'dev' },
expect.any(AbortSignal),
);
const pagination = screen.getByRole('navigation', { name: '项目工程分页' });
expect(pagination.textContent).toContain('第 2 页');
expect(screen.getByText('第二工程')).toBeTruthy();
expect(screen.queryByText('暂无已上传项目')).toBeNull();
vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({
items: [],
nextCursor: null,
});
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
await screen.findByText('暂无已上传项目');
expect(screen.queryByRole('alert')).toBeNull();
});
test('用户列与素材查询同口径展示昵称、陶泥号和用户详情入口', async () => {
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
const row = (await screen.findByText('三消工程')).closest('tr')!;
expect(within(row).getByText('陶泥作者')).toBeTruthy();
expect(within(row).getByText('SY-00000007')).toBeTruthy();
expect(
within(row).getByRole('button', { name: '查看用户信息' }),
).toBeTruthy();
expect(within(row).queryByText('user-1')).toBeNull();
});
test('默认查询本部署渠道,切换渠道后从第一页按该渠道重新查询', async () => {
vi.mocked(getAdminProjectSnapshotChannels).mockResolvedValue({
defaultChannel: 'release',
channels: ['dev', 'release'],
});
vi.mocked(listAdminProjectSnapshots)
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
.mockResolvedValueOnce({ items: [entry], nextCursor: null })
.mockResolvedValueOnce({
items: [{ ...entry, channel: 'dev', projectName: 'dev 工程' }],
nextCursor: null,
});
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
await screen.findByText('三消工程');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
1,
expect(listAdminProjectSnapshots).toHaveBeenLastCalledWith(
'token',
{ cursor: null, limit: 20, channel: 'release' },
{ cursor: null, limit: 20 },
expect.any(AbortSignal),
);
const channelSelect = screen.getByLabelText('项目工程渠道');
expect((channelSelect as HTMLSelectElement).value).toBe('release');
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await screen.findByText('第 2 页,本页 1 个项目');
fireEvent.change(channelSelect, { target: { value: 'dev' } });
await screen.findByText('dev 工程');
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
3,
'token',
{ cursor: null, limit: 20, channel: 'dev' },
expect.any(AbortSignal),
);
expect(screen.getByText('第 1 页,本页 1 个项目')).toBeTruthy();
});
test('下载使用返回的中文文件名,随后释放对象 URL', async () => {
@@ -292,7 +154,6 @@ test('下载使用返回的中文文件名,随后释放对象 URL', async () =
expect(createObjectURL).toHaveBeenCalledWith(blob);
expect(downloadAdminProjectSnapshot).toHaveBeenCalledWith(
'token',
'dev',
'user-1',
'project-1',
expect.any(AbortSignal),
@@ -303,7 +164,7 @@ test('下载使用返回的中文文件名,随后释放对象 URL', async () =
test('取消下载中止请求且不显示错误,卸载中止列表请求', async () => {
vi.mocked(downloadAdminProjectSnapshot).mockImplementation(
(_token, _channel, _user, _project, signal) =>
(_token, _user, _project, signal) =>
new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () =>
reject(new DOMException('Aborted', 'AbortError')),
@@ -316,7 +177,7 @@ test('取消下载中止请求且不显示错误,卸载中止列表请求', as
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
fireEvent.click(await screen.findByRole('button', { name: '取消下载' }));
expect(
vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[4]?.aborted,
vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3]?.aborted,
).toBe(true);
await waitFor(() => expect(screen.queryByRole('alert')).toBeNull());
vi.mocked(listAdminProjectSnapshots).mockReturnValue(new Promise(() => {}));
@@ -387,10 +248,6 @@ test('更换登录令牌丢弃旧列表和晚返回请求', async () => {
onUnauthorized={onUnauthorized}
/>,
);
// 渠道确定之后才会发出列表请求,这里等到旧令牌的请求真的在途再换令牌。
await waitFor(() =>
expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(1),
);
const oldSignal = vi.mocked(listAdminProjectSnapshots).mock.calls[0]?.[2];
view.rerender(
<AdminProjectSnapshotsPage
@@ -421,7 +278,7 @@ test('卸载后完成的下载不会创建浏览器文件', async () => {
<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />,
);
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[4];
const signal = vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3];
view.unmount();
expect(signal?.aborted).toBe(true);
await act(async () => {
@@ -1,19 +1,11 @@
import {
ChevronLeft,
ChevronRight,
Download,
RefreshCcw,
X,
} from 'lucide-react';
import { Download, RefreshCcw, X } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
downloadAdminProjectSnapshot,
getAdminProjectSnapshotChannels,
listAdminProjectSnapshots,
} from '../api/adminApiClient';
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
import { handlePageError } from './pageUtils';
interface AdminProjectSnapshotsPageProps {
@@ -39,32 +31,21 @@ const snapshotStatuses = {
},
};
const DEFAULT_PAGE_SIZE = 20;
const PAGE_SIZE_OPTIONS = [20, 50, 100];
export function AdminProjectSnapshotsPage({
token,
onUnauthorized,
}: AdminProjectSnapshotsPageProps) {
const [items, setItems] = useState<AdminProjectSnapshotEntry[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [pageIndex, setPageIndex] = useState(1);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
// null 表示渠道还没确定:先读完可选渠道再发列表请求,避免用错渠道白跑一次。
const [channel, setChannel] = useState<string | null>(null);
const [channelOptions, setChannelOptions] = useState<string[]>([]);
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);
// 远端按游标分页且不给总数:第 N 页的起始游标只能由前 N-1 页依次返回,
// 因此按页记录已取得的游标,翻页只在这些游标之间移动。
const pageCursors = useRef<(string | null)[]>([null]);
const loadPage = useCallback(
async (cursor: string | null, limit: number, page: number) => {
async (cursor: string | null = null) => {
listController.current?.abort();
const controller = new AbortController();
listController.current = controller;
@@ -73,16 +54,23 @@ export function AdminProjectSnapshotsPage({
try {
const response = await listAdminProjectSnapshots(
token,
{ cursor, limit, channel },
{ cursor, limit: 20 },
controller.signal,
);
if (controller.signal.aborted) return;
setItems(response.items);
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);
setPageIndex(page);
setHasLoaded(true);
} catch (error: unknown) {
// 翻页或刷新失败时保留当前页,不把已看到的列表换成空表。
if (!controller.signal.aborted)
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
@@ -92,70 +80,22 @@ export function AdminProjectSnapshotsPage({
}
}
},
[token, onUnauthorized, channel],
[token, onUnauthorized],
);
useEffect(() => {
// 换令牌或首次进入时取一次可选渠道;已选渠道保持不变,只在还没选时落到本部署渠道。
const controller = new AbortController();
void (async () => {
try {
const response = await getAdminProjectSnapshotChannels(
token,
controller.signal,
);
if (controller.signal.aborted) return;
setChannelOptions(response.channels);
setChannel((current) => current ?? response.defaultChannel);
} catch (error: unknown) {
if (controller.signal.aborted) return;
// 渠道列表失败不阻塞查询:不带渠道按本部署渠道查询,并提示失败原因。
handlePageError(error, onUnauthorized, setErrorMessage);
setChannel((current) => current ?? '');
}
})();
return () => controller.abort();
}, [token, onUnauthorized]);
useEffect(() => {
if (channel === null) return undefined;
pageCursors.current = [null];
setItems([]);
setNextCursor(null);
setPageIndex(1);
setHasLoaded(false);
setDownloadingKey(null);
void loadPage(null, pageSize, 1);
void loadPage();
return () => {
listController.current?.abort();
listController.current = null;
downloadController.current?.abort();
downloadController.current = null;
};
}, [loadPage, pageSize, channel]);
function goToNextPage() {
if (!nextCursor) return;
pageCursors.current[pageIndex] = nextCursor;
void loadPage(nextCursor, pageSize, pageIndex + 1);
}
function goToPreviousPage() {
if (pageIndex <= 1) return;
void loadPage(
pageCursors.current[pageIndex - 2] ?? null,
pageSize,
pageIndex - 1,
);
}
function refreshCurrentPage() {
void loadPage(
pageCursors.current[pageIndex - 1] ?? null,
pageSize,
pageIndex,
);
}
}, [loadPage]);
async function downloadProject(entry: AdminProjectSnapshotEntry) {
if (downloadController.current || entry.status === 'partial') return;
@@ -166,7 +106,6 @@ export function AdminProjectSnapshotsPage({
try {
const archive = await downloadAdminProjectSnapshot(
token,
channel ?? '',
entry.userId,
entry.projectId,
controller.signal,
@@ -201,42 +140,19 @@ export function AdminProjectSnapshotsPage({
setDownloadingKey(null);
}
// 渠道列表读取失败时至少保留当前渠道,避免选择框空掉后看不出在查哪个渠道。
const visibleChannelOptions = channelOptions.length
? channelOptions
: channel
? [channel]
: [];
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<h2></h2>
<div className="admin-action-row">
<label className="admin-field admin-field-compact">
<span></span>
<select
aria-label="项目工程渠道"
value={channel ?? ''}
onChange={(event) => setChannel(event.target.value)}
>
{visibleChannelOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshCurrentPage}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '加载中' : '刷新'}</span>
</button>
</div>
<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">
@@ -253,7 +169,7 @@ export function AdminProjectSnapshotsPage({
<thead>
<tr>
<th></th>
<th></th>
<th> ID</th>
<th></th>
<th></th>
<th></th>
@@ -271,22 +187,7 @@ export function AdminProjectSnapshotsPage({
<strong>{entry.projectName || entry.projectId}</strong>
<small>{entry.projectId}</small>
</td>
<td data-label="用户">
<div className="admin-inline-identity">
<div>
{projectOwnerDisplayName(entry)}
<small>
{entry.authorPublicUserCode?.trim() || '-'}
</small>
</div>
<AdminUserReferenceButton
token={token}
userId={entry.userId}
publicUserCode={entry.authorPublicUserCode}
onUnauthorized={onUnauthorized}
/>
</div>
</td>
<td data-label="用户 ID">{entry.userId}</td>
<td data-label="同步时间">
<span>
{new Date(entry.syncedAtMs).toLocaleString('zh-CN', {
@@ -338,66 +239,23 @@ export function AdminProjectSnapshotsPage({
{hasLoaded && items.length === 0 && !errorMessage ? (
<p className="admin-muted-text"></p>
) : null}
{hasLoaded ? (
<nav
className="admin-action-row admin-project-snapshot-pagination"
aria-label="项目工程分页"
>
<span className="admin-project-snapshot-pagination-info">
{pageIndex}
{items.length ? `,本页 ${items.length} 个项目` : ''}
</span>
<div className="admin-action-row">
<label className="admin-field admin-field-compact">
<span></span>
<select
aria-label="每页条数"
value={pageSize}
onChange={(event) => setPageSize(Number(event.target.value))}
>
{PAGE_SIZE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<button
aria-label="上一页"
className="admin-secondary-button"
disabled={isLoading || pageIndex <= 1}
title="上一页"
type="button"
onClick={goToPreviousPage}
>
<ChevronLeft size={17} aria-hidden="true" />
<span></span>
</button>
<button
aria-label="下一页"
className="admin-secondary-button"
disabled={isLoading || !nextCursor}
title="下一页"
type="button"
onClick={goToNextPage}
>
<span></span>
<ChevronRight size={17} aria-hidden="true" />
</button>
</div>
</nav>
{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 projectOwnerDisplayName(entry: AdminProjectSnapshotEntry) {
return (
entry.authorDisplayName?.trim() || entry.authorPublicUserCode?.trim() || '-'
);
}
function snapshotKey(entry: AdminProjectSnapshotEntry) {
return `${entry.userId}/${entry.projectId}`;
}
@@ -1,155 +0,0 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest';
import {
attachCoverFiles,
buildTemplateImportFormData,
deriveTemplateUploadRow,
parseTemplateTags,
sanitizeTemplateId,
TEMPLATE_IMPORT_MAX_BATCH,
type TemplateUploadRow,
validateTemplateUploadRows,
} from './adminAgcTemplateUploadModel';
function zipFile(name: string) {
return new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], name, {
type: 'application/zip',
});
}
function coverFile(name: string) {
return new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], name, {
type: 'image/png',
});
}
function row(zipName: string, patch: Partial<TemplateUploadRow> = {}) {
return { ...deriveTemplateUploadRow(zipFile(zipName)), ...patch };
}
describe('sanitizeTemplateId', () => {
it('lowercases, keeps whitelisted characters and drops traversal', () => {
expect(sanitizeTemplateId('Cocos Empty 2D.zip')).toBe('cocos-empty-2d');
expect(sanitizeTemplateId('../../evil.zip')).toBe('evil');
expect(sanitizeTemplateId('a..b.zip')).toBe('a.b');
expect(sanitizeTemplateId('__hidden__')).toBe('hidden__');
expect(sanitizeTemplateId(`${'x'.repeat(90)}.zip`)).toHaveLength(64);
});
});
describe('deriveTemplateUploadRow', () => {
it('starts from safe defaults so a batch only needs covers attached', () => {
const derived = row('my-template.zip');
expect(derived.id).toBe('my-template');
expect(derived.title).toBe('my-template');
expect(derived.runtime).toBe('html');
expect(derived.templateVersion).toBe('0.1.0');
expect(derived.entry).toBe('index.html');
expect(derived.coverFile).toBeNull();
});
});
describe('attachCoverFiles', () => {
it('matches covers by template id and ignores non-image files', () => {
const rows = [row('alpha.zip'), row('beta.zip')];
const covers = [
coverFile('alpha.png'),
coverFile('beta.webp'),
coverFile('notes.txt'),
];
const next = attachCoverFiles(rows, covers);
expect(next[0]?.coverFile?.name).toBe('alpha.png');
expect(next[1]?.coverFile?.name).toBe('beta.webp');
});
it('keeps an already matched cover when the new selection has no partner', () => {
const rows = [row('alpha.zip', { coverFile: coverFile('alpha.png') })];
const next = attachCoverFiles(rows, [coverFile('other.png')]);
expect(next[0]?.coverFile?.name).toBe('alpha.png');
});
});
describe('validateTemplateUploadRows', () => {
it('accepts a complete batch', () => {
const rows = [
row('alpha.zip', { coverFile: coverFile('alpha.png') }),
row('beta.zip', { coverFile: coverFile('beta.png'), runtime: 'cocos' }),
];
expect(validateTemplateUploadRows(rows)).toEqual({});
});
it('reports missing cover, duplicate id and invalid fields per row', () => {
const rows = [
row('alpha.zip'),
row('alpha.zip', { coverFile: coverFile('alpha.png'), key: 'second' }),
row('gamma.zip', {
coverFile: coverFile('gamma.png'),
runtime: 'docker',
templateVersion: 'Bad Version',
entry: '../escape.js',
title: ' ',
}),
];
const errors = validateTemplateUploadRows(rows);
expect(errors['alpha.zip']).toContain('缺少封面');
expect(errors.second).toContain('ID 在本批次内重复');
expect(errors['gamma.zip']).toContain('名称必须是 1-80 个字符');
});
it('rejects a batch larger than the server limit', () => {
const rows = Array.from(
{ length: TEMPLATE_IMPORT_MAX_BATCH + 1 },
(_, index) =>
row(`template-${index}.zip`, {
coverFile: coverFile(`template-${index}.png`),
}),
);
const errors = validateTemplateUploadRows(rows);
expect(Object.keys(errors)).toHaveLength(TEMPLATE_IMPORT_MAX_BATCH + 1);
expect(errors['template-0.zip']).toContain('单批最多上传');
});
});
describe('buildTemplateImportFormData', () => {
it('writes manifest field names and attaches zip / cover per row', () => {
const rows = [
row('alpha.zip', {
coverFile: coverFile('alpha.png'),
tags: '起步, 起步 2d',
title: ' Alpha ',
}),
row('beta.zip', { coverFile: coverFile('beta.png') }),
];
const form = buildTemplateImportFormData('a'.repeat(64), rows);
const manifest = JSON.parse(String(form.get('manifest')));
expect(manifest.expectedRevision).toBe('a'.repeat(64));
expect(manifest.templates).toHaveLength(2);
expect(manifest.templates[0]).toMatchObject({
id: 'alpha',
title: 'Alpha',
tags: ['起步', '2d'],
zipField: 'zip_0',
coverField: 'cover_0',
});
expect(manifest.templates[1]).toMatchObject({
id: 'beta',
zipField: 'zip_1',
coverField: 'cover_1',
});
expect((form.get('zip_0') as File).name).toBe('alpha.zip');
expect((form.get('cover_1') as File).name).toBe('beta.png');
});
it('parses tags with dedupe and caps them at the server limit', () => {
expect(parseTemplateTags(' a, ab c ')).toEqual(['a', 'b', 'c']);
const many = Array.from({ length: 20 }, (_, index) => `t${index}`).join(
',',
);
expect(parseTemplateTags(many)).toHaveLength(16);
});
});
@@ -1,191 +0,0 @@
import type {
AdminImportAgcTemplateItemPayload,
AdminImportAgcTemplatesManifest,
} from '../api/adminApiTypes';
/** 与服务端 module-assets 的导入上限保持一致;超出请分批或改走 CLI。 */
export const TEMPLATE_IMPORT_MAX_BATCH = 20;
export const TEMPLATE_UPLOAD_RUNTIMES = [
'html',
'unity',
'godot',
'cocos',
] as const;
const TEMPLATE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
const TEMPLATE_VERSION_PATTERN = /^[a-z0-9][a-z0-9._-]{0,31}$/u;
const ENTRY_PATTERN = /^(?![\\/:])(?!.*\.\.)[^\s\\:]+$/u;
const COVER_EXTENSION_PATTERN = /\.(png|jpe?g|webp)$/iu;
export interface TemplateUploadRow {
/** 稳定行标识:用 ZIP 文件名,重选文件后不会串行。 */
key: string;
zipFile: File;
coverFile: File | null;
id: string;
title: string;
summary: string;
tags: string;
runtime: string;
engine: string;
engineVersion: string;
templateVersion: string;
entry: string;
}
/** 文件名 → 模板 ID:小写、只留白名单字符,并去掉 `..` 与开头的非字母数字。 */
export function sanitizeTemplateId(value: string) {
return value
.replace(/\.zip$/iu, '')
.trim()
.toLowerCase()
.replace(/\.{2,}/gu, '.')
.replace(/[^a-z0-9._-]+/gu, '-')
.replace(/^[^a-z0-9]+/u, '')
.slice(0, 64);
}
export function deriveTemplateUploadRow(zipFile: File): TemplateUploadRow {
const id = sanitizeTemplateId(zipFile.name);
return {
key: zipFile.name,
zipFile,
coverFile: null,
id,
title: id,
summary: '',
tags: '',
runtime: 'html',
engine: '',
engineVersion: '',
templateVersion: '0.1.0',
entry: 'index.html',
};
}
/** 封面按「与模板 ID 同名的图片」匹配,一次多选即可覆盖整批。 */
export function attachCoverFiles(
rows: TemplateUploadRow[],
coverFiles: File[],
): TemplateUploadRow[] {
const covers = new Map<string, File>();
for (const file of coverFiles) {
if (!COVER_EXTENSION_PATTERN.test(file.name)) continue;
const stem = sanitizeTemplateId(
file.name.replace(COVER_EXTENSION_PATTERN, ''),
);
if (!covers.has(stem)) covers.set(stem, file);
}
return rows.map((row) => {
const matched = covers.get(row.id.trim().toLowerCase()) ?? null;
return matched ? { ...row, coverFile: matched } : row;
});
}
export function parseTemplateTags(value: string) {
const seen = new Set<string>();
const tags: string[] = [];
for (const raw of value.split(/[,\s]+/u)) {
const tag = raw.trim();
if (!tag || seen.has(tag)) continue;
seen.add(tag);
tags.push(tag);
}
return tags.slice(0, 16);
}
/** 逐行校验:返回 row.key → 错误文案;空对象表示整批可以提交。 */
export function validateTemplateUploadRows(
rows: TemplateUploadRow[],
): Record<string, string> {
const errors: Record<string, string> = {};
const seen = new Set<string>();
const fail = (row: TemplateUploadRow, message: string) => {
if (!errors[row.key]) errors[row.key] = message;
};
if (rows.length === 0) return errors;
if (rows.length > TEMPLATE_IMPORT_MAX_BATCH) {
for (const row of rows) {
fail(row, `单批最多上传 ${TEMPLATE_IMPORT_MAX_BATCH} 个模板`);
}
return errors;
}
for (const row of rows) {
const id = row.id.trim();
if (!TEMPLATE_ID_PATTERN.test(id)) {
fail(
row,
'ID 必须是 1-64 位小写字母、数字、点、下划线或连字符,且以字母数字开头',
);
} else if (seen.has(id)) {
fail(row, 'ID 在本批次内重复');
} else {
seen.add(id);
}
if (!row.title.trim() || row.title.trim().length > 80) {
fail(row, '名称必须是 1-80 个字符');
}
if (row.summary.trim().length > 1000) {
fail(row, '简介最多 1000 个字符');
}
if (!TEMPLATE_VERSION_PATTERN.test(row.templateVersion.trim())) {
fail(row, '版本号必须是 1-32 位小写字母、数字、点、下划线或连字符');
}
if (
!TEMPLATE_UPLOAD_RUNTIMES.includes(
row.runtime as (typeof TEMPLATE_UPLOAD_RUNTIMES)[number],
)
) {
fail(row, `运行时只能是 ${TEMPLATE_UPLOAD_RUNTIMES.join(' / ')}`);
}
if (!ENTRY_PATTERN.test(row.entry.trim())) {
fail(row, 'entry 必须是模板包内的相对路径');
}
if (!row.coverFile) {
fail(row, '缺少封面:请上传与模板 ID 同名的 PNG / JPEG / WebP');
}
}
return errors;
}
export function buildTemplateImportManifest(
expectedRevision: string,
rows: TemplateUploadRow[],
): AdminImportAgcTemplatesManifest {
return {
expectedRevision,
templates: rows.map((row, index) => {
const item: AdminImportAgcTemplateItemPayload = {
id: row.id.trim(),
title: row.title.trim(),
summary: row.summary.trim(),
tags: parseTemplateTags(row.tags),
runtime: row.runtime,
engine: row.engine.trim(),
engineVersion: row.engineVersion.trim(),
templateVersion: row.templateVersion.trim(),
entry: row.entry.trim(),
zipField: `zip_${index}`,
coverField: `cover_${index}`,
};
return item;
}),
};
}
export function buildTemplateImportFormData(
expectedRevision: string,
rows: TemplateUploadRow[],
): FormData {
const form = new FormData();
form.append(
'manifest',
JSON.stringify(buildTemplateImportManifest(expectedRevision, rows)),
);
rows.forEach((row, index) => {
form.append(`zip_${index}`, row.zipFile, row.zipFile.name);
if (row.coverFile) {
form.append(`cover_${index}`, row.coverFile, row.coverFile.name);
}
});
return form;
}
+3 -149
View File
@@ -13,137 +13,6 @@
text-rendering: optimizeLegibility;
}
.admin-agc-templates {
min-width: 0;
}
.admin-agc-template-filters {
display: grid;
grid-template-columns: minmax(180px, 1fr) repeat(2, minmax(130px, 190px));
gap: 14px;
}
.admin-agc-template-table {
min-width: 850px;
}
.admin-agc-template-table td:nth-child(2) {
min-width: 230px;
max-width: 380px;
}
.admin-agc-template-cover {
display: block;
width: 88px;
height: 62px;
border-radius: 8px;
object-fit: cover;
background: #f8efe7;
}
.admin-agc-template-summary {
display: -webkit-box;
overflow: hidden;
margin: 8px 0;
color: #866954;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.admin-agc-template-tags,
.admin-agc-template-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.admin-agc-template-tags span {
padding: 3px 7px;
border-radius: 5px;
background: #f8efe7;
font-size: 12px;
}
.admin-agc-template-dialog {
border-radius: 10px;
}
.admin-agc-template-editor,
.admin-agc-template-conflict {
display: grid;
gap: 14px;
}
.admin-agc-template-cover-preview {
display: block;
width: min(100%, 300px);
max-height: 180px;
border-radius: 8px;
object-fit: contain;
background: #f8efe7;
}
.admin-agc-template-dialog .admin-confirm-backdrop {
z-index: 1100;
}
.admin-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.admin-agc-template-upload-dialog {
border-radius: 10px;
max-width: min(1180px, calc(100vw - 24px));
}
.admin-agc-template-upload-pickers {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.admin-agc-template-upload-picker {
display: grid;
gap: 6px;
font-size: 13px;
}
.admin-agc-template-upload-picker input[type='file'] {
width: 100%;
font-size: 12px;
}
/* 上传行数多时表格自己滚动,窄屏不撑坏整页布局。 */
.admin-agc-template-upload-scroll {
max-height: min(52vh, 520px);
overflow: auto;
}
.admin-agc-template-upload-file {
font-size: 12px;
word-break: break-all;
}
.admin-agc-template-upload-row-error {
margin-top: 4px;
color: #b3261e;
font-size: 12px;
}
@media (max-width: 680px) {
.admin-agc-template-filters {
grid-template-columns: minmax(0, 1fr);
}
.admin-agc-template-upload-dialog {
max-width: calc(100vw - 12px);
}
}
* {
box-sizing: border-box;
}
@@ -1594,15 +1463,15 @@ button:disabled {
}
.admin-project-snapshot-table th:first-child {
width: 18%;
width: 20%;
}
.admin-project-snapshot-table th:nth-child(2) {
width: 18%;
width: 14%;
}
.admin-project-snapshot-table th:nth-child(3) {
width: 16%;
width: 18%;
}
.admin-project-snapshot-table th:nth-child(4) {
@@ -1689,21 +1558,6 @@ button:disabled {
}
}
.admin-project-snapshot-pagination {
justify-content: space-between;
gap: 12px;
}
.admin-project-snapshot-pagination-info {
color: #755a49;
font-size: 13px;
font-weight: 700;
}
.admin-project-snapshot-pagination .admin-field {
min-width: 92px;
}
.admin-recharge-table {
min-width: 1080px;
table-layout: fixed;
-2
View File
@@ -9,7 +9,6 @@
"dev-stack": "node scripts/start-dev-stack.mjs",
"build": "node scripts/build-release.mjs",
"release:upload": "node scripts/release-upload.mjs",
"nsis:prepare": "node scripts/ensure-nsis-toolset.mjs",
"skill-pack:check": "node scripts/check-skill-pack.mjs",
"skill-pack:sync": "node scripts/check-skill-pack.mjs --write",
"skill-pack:test": "node --test scripts/check-skill-pack.test.mjs",
@@ -76,7 +75,6 @@
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"@types/three": "^0.184.1",
"jszip": "^3.10.1",
"tailwindcss": "^4.1.14",
"typescript": "~5.8.2",
"vitest": "^0.34.6"
@@ -185,12 +185,7 @@ test('nextVersion 只在 patch 位递增', () => {
test('ossutil 参数默认使用 v1 签名,并可按需带 region 与 v4', () => {
const base = {
args: [
'cp',
'--force',
'/tmp/a.json',
'oss://agc-dev/agc/global-version.json',
],
args: ['cp', '--force', '/tmp/a.json', 'oss://agc-dev/agc/global-version.json'],
endpoint: 'oss-rg-china-mainland.aliyuncs.com',
accessKeyId: 'id',
accessKeySecret: 'secret',

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