Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ac7f65c92 | |||
| 2f0e2a48a3 | |||
| 00b96f244d | |||
| 0dde6ee501 | |||
| 4a91e5865a | |||
| 4a2b270714 | |||
| 789aef4ce5 | |||
| 7ff6e46774 | |||
| 2254831db5 | |||
| 64425e8d1a | |||
| 328ac31844 |
@@ -4,5 +4,3 @@
|
|||||||
src/components/AdventurePanel.tsx
|
src/components/AdventurePanel.tsx
|
||||||
src/data/customWorldCharacterLoadout.ts
|
src/data/customWorldCharacterLoadout.ts
|
||||||
dist_check_monster_position/**
|
dist_check_monster_position/**
|
||||||
# 固定上游UTF-8测试刻意包含U+FFFD;upstream-integrity.test.mjs逐字节验证来源hash。
|
|
||||||
apps/ai-game-creator-shell/src-tauri/vendor/codex-utils-path-uri/src/api_path_string_tests.rs
|
|
||||||
|
|||||||
@@ -213,6 +213,8 @@ module.exports = {
|
|||||||
'!src/services/clipboard.test.ts',
|
'!src/services/clipboard.test.ts',
|
||||||
'!src/services/frontendRuntimeConfigService.ts',
|
'!src/services/frontendRuntimeConfigService.ts',
|
||||||
'!src/services/frontendRuntimeConfigService.test.ts',
|
'!src/services/frontendRuntimeConfigService.test.ts',
|
||||||
|
'!src/services/gameDistributionClient.ts',
|
||||||
|
'!src/services/gameDistributionClient.test.ts',
|
||||||
'!src/services/sseStream.ts',
|
'!src/services/sseStream.ts',
|
||||||
'!src/services/sseStream.test.ts',
|
'!src/services/sseStream.test.ts',
|
||||||
'src/AdventurePanel.tsx',
|
'src/AdventurePanel.tsx',
|
||||||
|
|||||||
@@ -31,26 +31,28 @@ env:
|
|||||||
RUSTC_WRAPPER: ''
|
RUSTC_WRAPPER: ''
|
||||||
CARGO_BUILD_RUSTC_WRAPPER: ''
|
CARGO_BUILD_RUSTC_WRAPPER: ''
|
||||||
|
|
||||||
# job 声明顺序就是 runner 领取顺序,因此把两条最长尾的客户端 Rust lane 排在前面,
|
# job 声明顺序就是 runner 领取顺序,因此把最长尾的客户端 Rust 门禁排在前面,
|
||||||
# 让它在最少的等待下占用并发槽位;其余 job 按时长递减排列。
|
# 让它在最少的等待下占用并发槽位;其余 job 按时长递减排列。
|
||||||
#
|
#
|
||||||
# 客户端(微信壳 / Expo 移动壳 / Tauri 桌面壳 / AI 游戏创作壳)门禁原先全部串在
|
# 客户端(微信壳 / Expo 移动壳 / Tauri 桌面壳 / AI 游戏创作壳)门禁原先全部串在
|
||||||
# `Native shell tests` 一个 job 里,实测 18 分 37 秒。现在按门禁组拆成一个分组一个 job:
|
# `Native shell tests` 一个 job 里,实测 18 分 37 秒。现在按门禁组拆成一个分组一个 job:
|
||||||
# `Native shell tests`(契约 + H5 / 微信 / 移动 / 桌面壳门禁 + 发布构建 smoke)、
|
# `Native shell tests`(契约 + H5 / 微信 / 移动 / 桌面壳门禁 + 发布构建 smoke)、
|
||||||
# `AI game creator shell web tests`(typecheck + 壳内测试)、`AI game creator shell
|
# `AI game creator shell web tests`(typecheck + 壳内测试)、`AI game creator shell
|
||||||
# Rust lane 1/2`、`lane 2/2`(每条 lane 顺序运行两片 AGC 壳 bin 单测)、`AI game creator
|
# Rust shard 1/4` 到 `4/4`(AGC 壳 bin 单测按名单分 4 片)、`AI game creator shell
|
||||||
# shell Rust smoke`(agent-run smoke)与 `AI game creator shell Rust crates`(AGC 壳依赖的
|
# Rust smoke`(agent-run smoke)与 `AI game creator shell Rust crates`(AGC 壳依赖的
|
||||||
# 共享 / 平台 crate 测试)。各自的命令与拆分前逐一对应,本地
|
# 共享 / 平台 crate 测试)。各自的命令与拆分前逐一对应,本地
|
||||||
# `npm run check:native-shells` 仍是同一条串行序列。
|
# `npm run check:native-shells` 仍是同一条串行序列。
|
||||||
#
|
#
|
||||||
# AGC 壳的 bin 单测(2466 条)仍按名单分 4 片、片内保持 `--test-threads=1`;两条 lane
|
# AGC 壳的 bin 单测(2466 条)按名单分 4 片、一片一个 job:片内保持 `--test-threads=1`
|
||||||
# 各自顺序运行两片,让一次 job 只做一次依赖预热,同时避免同一容器内多进程并行争抢
|
# (当年线程并行会互相干扰的是进程内后台锁与异步终态),片与片之间靠 job 级并发摊开。
|
||||||
# 共享 HOME、target 与固定临时路径。每个分片调用仍会自校验「片并集等于全集且互斥」。
|
# 不要改回「一个 job 里多进程并行这几片」:同一容器内它们会争抢共享 HOME、target 与固定
|
||||||
|
# 临时路径,实测比整套串行还慢。每个分片 job 都会自校验「片并集等于全集且互斥」。
|
||||||
jobs:
|
jobs:
|
||||||
# AGC 壳自身的 Rust bin 单测由两条 lane 承载,每条 lane 顺序运行两片;这里不装 npm
|
# AGC 壳自身的 Rust bin 单测分片,4 片各自独立 job 并发执行、片内仍保持
|
||||||
# 依赖:壳 Rust 门禁只用 cargo 与 node 内建模块,也只需要 AGC 壳自己那份锁定依赖。
|
# `--test-threads=1`。这里不装 npm 依赖:壳 Rust 门禁只用 cargo 与 node 内建模块,
|
||||||
ai-game-creator-shell-rust-lane-1:
|
# 也只需要 AGC 壳自己那份锁定依赖。
|
||||||
name: AI game creator shell Rust lane 1/2
|
ai-game-creator-shell-rust-shard-1:
|
||||||
|
name: AI game creator shell Rust shard 1/4
|
||||||
runs-on: genarrative-ci
|
runs-on: genarrative-ci
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout full history from Gitea
|
- name: Checkout full history from Gitea
|
||||||
@@ -85,11 +87,41 @@ jobs:
|
|||||||
- name: Run AI game creator shell Rust shard 1/4
|
- name: Run AI game creator shell Rust shard 1/4
|
||||||
run: npm run check:native-shells:agc-rust-shard-1
|
run: npm run check:native-shells:agc-rust-shard-1
|
||||||
|
|
||||||
|
ai-game-creator-shell-rust-shard-2:
|
||||||
|
name: AI game creator shell Rust shard 2/4
|
||||||
|
runs-on: genarrative-ci
|
||||||
|
steps:
|
||||||
|
- name: Checkout full history from Gitea
|
||||||
|
env:
|
||||||
|
GENARRATIVE_GITEA_FETCH_DEPTH: '0'
|
||||||
|
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
|
||||||
|
run: genarrative-gitea-checkout
|
||||||
|
|
||||||
|
- name: Validate preinstalled CI job image and sandbox
|
||||||
|
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
||||||
|
|
||||||
|
- name: Prepare AI game creator shell Rust dependencies
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for attempt in $(seq 1 5); do
|
||||||
|
if cargo fetch --locked \
|
||||||
|
--target x86_64-unknown-linux-gnu \
|
||||||
|
--manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [[ "${attempt}" -eq 5 ]]; then
|
||||||
|
echo 'AI game creator shell Cargo dependency fetch failed after 5 attempts.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep $((attempt * 2))
|
||||||
|
done
|
||||||
|
|
||||||
- name: Run AI game creator shell Rust shard 2/4
|
- name: Run AI game creator shell Rust shard 2/4
|
||||||
run: npm run check:native-shells:agc-rust-shard-2
|
run: npm run check:native-shells:agc-rust-shard-2
|
||||||
|
|
||||||
ai-game-creator-shell-rust-lane-2:
|
ai-game-creator-shell-rust-shard-3:
|
||||||
name: AI game creator shell Rust lane 2/2
|
name: AI game creator shell Rust shard 3/4
|
||||||
runs-on: genarrative-ci
|
runs-on: genarrative-ci
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout full history from Gitea
|
- name: Checkout full history from Gitea
|
||||||
@@ -121,6 +153,36 @@ jobs:
|
|||||||
- name: Run AI game creator shell Rust shard 3/4
|
- name: Run AI game creator shell Rust shard 3/4
|
||||||
run: npm run check:native-shells:agc-rust-shard-3
|
run: npm run check:native-shells:agc-rust-shard-3
|
||||||
|
|
||||||
|
ai-game-creator-shell-rust-shard-4:
|
||||||
|
name: AI game creator shell Rust shard 4/4
|
||||||
|
runs-on: genarrative-ci
|
||||||
|
steps:
|
||||||
|
- name: Checkout full history from Gitea
|
||||||
|
env:
|
||||||
|
GENARRATIVE_GITEA_FETCH_DEPTH: '0'
|
||||||
|
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
|
||||||
|
run: genarrative-gitea-checkout
|
||||||
|
|
||||||
|
- name: Validate preinstalled CI job image and sandbox
|
||||||
|
run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh
|
||||||
|
|
||||||
|
- name: Prepare AI game creator shell Rust dependencies
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for attempt in $(seq 1 5); do
|
||||||
|
if cargo fetch --locked \
|
||||||
|
--target x86_64-unknown-linux-gnu \
|
||||||
|
--manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [[ "${attempt}" -eq 5 ]]; then
|
||||||
|
echo 'AI game creator shell Cargo dependency fetch failed after 5 attempts.' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep $((attempt * 2))
|
||||||
|
done
|
||||||
|
|
||||||
- name: Run AI game creator shell Rust shard 4/4
|
- name: Run AI game creator shell Rust shard 4/4
|
||||||
run: npm run check:native-shells:agc-rust-shard-4
|
run: npm run check:native-shells:agc-rust-shard-4
|
||||||
|
|
||||||
|
|||||||
@@ -47,16 +47,12 @@ temp*build*/
|
|||||||
/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/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/manifest.json
|
||||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/NOTICE.md
|
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/NOTICE.md
|
||||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/darwin-arm64/
|
|
||||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/darwin-x64/
|
|
||||||
/plugins/agc-cocos-editor/native/payload/
|
/plugins/agc-cocos-editor/native/payload/
|
||||||
/plugins/agc-unity-editor/dotnet/**/bin/
|
/plugins/agc-unity-editor/dotnet/**/bin/
|
||||||
/plugins/agc-unity-editor/dotnet/**/obj/
|
/plugins/agc-unity-editor/dotnet/**/obj/
|
||||||
/plugins/agc-unity-editor/dotnet/publish/
|
/plugins/agc-unity-editor/dotnet/publish/
|
||||||
/plugins/agc-unity-editor/dotnet/native-build/
|
/plugins/agc-unity-editor/dotnet/native-build/
|
||||||
/apps/ai-game-creator-shell/logs/
|
/apps/ai-game-creator-shell/logs/
|
||||||
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime/
|
|
||||||
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime-staging-*/
|
|
||||||
/apps/ai-game-creator-shell/.llm-drafts/
|
/apps/ai-game-creator-shell/.llm-drafts/
|
||||||
/apps/ai-game-creator-shell/game-creator.config.local.json
|
/apps/ai-game-creator-shell/game-creator.config.local.json
|
||||||
/apps/mobile-shell/.expo/
|
/apps/mobile-shell/.expo/
|
||||||
|
|||||||
@@ -174,14 +174,6 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
|
|||||||
|
|
||||||
## 项目开发对话(DirectProject)
|
## 项目开发对话(DirectProject)
|
||||||
|
|
||||||
**DirectProject 专属聊天模块**:
|
|
||||||
AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、发送队列、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。
|
|
||||||
_Avoid_: 把 DirectProject 作为项目总控聊天的一个布尔分支、把四种 Agent 会话抽象成同一事实源
|
|
||||||
|
|
||||||
**项目工作台布局**:
|
|
||||||
承载本地项目的资源工作区、项目级工具和独立聊天产品路径的外层界面;布局拥有跨面板的账户/钱包入口,聊天模块只负责项目对话,不嵌套账户展示。
|
|
||||||
_Avoid_: 把钱包入口塞进聊天设置、让聊天组件拥有工作台级账户状态
|
|
||||||
|
|
||||||
**项目对话历史**:
|
**项目对话历史**:
|
||||||
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
|
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
|
||||||
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
|
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
|
||||||
|
|||||||
@@ -3,15 +3,15 @@ import { afterEach, expect, test, vi } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
createAdminAccount,
|
createAdminAccount,
|
||||||
executeAdminRechargeRefund,
|
executeAdminRechargeRefund,
|
||||||
getAdminAgcTemplates,
|
|
||||||
getAdminFeatureGateConfig,
|
getAdminFeatureGateConfig,
|
||||||
getAdminUserDetail,
|
getAdminUserDetail,
|
||||||
importAdminAgcTemplates,
|
listAdminGameDistributionReviews,
|
||||||
listAdminRechargeOrders,
|
listAdminRechargeOrders,
|
||||||
reconcileAdminUserConsumption,
|
reconcileAdminUserConsumption,
|
||||||
resolveAdminRechargeRefundManualReview,
|
resolveAdminRechargeRefundManualReview,
|
||||||
|
reviewAdminGameDistributionVersion,
|
||||||
|
suspendAdminGameDistributionGame,
|
||||||
updateAdminAccount,
|
updateAdminAccount,
|
||||||
updateAdminAgcTemplate,
|
|
||||||
uploadAdminEditorShowcaseCampaignImage,
|
uploadAdminEditorShowcaseCampaignImage,
|
||||||
upsertAdminFeatureGateConfig,
|
upsertAdminFeatureGateConfig,
|
||||||
upsertProfileWalletConfig,
|
upsertProfileWalletConfig,
|
||||||
@@ -21,93 +21,6 @@ afterEach(() => {
|
|||||||
vi.unstubAllGlobals();
|
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 () => {
|
test('后台账号创建和更新同时携带 Tab 与独立操作权限', async () => {
|
||||||
const fetchMock = vi.fn().mockImplementation(() =>
|
const fetchMock = vi.fn().mockImplementation(() =>
|
||||||
Promise.resolve(
|
Promise.resolve(
|
||||||
@@ -454,3 +367,134 @@ 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,6 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
AdminAccountListResponse,
|
AdminAccountListResponse,
|
||||||
AdminAgcTemplateLibraryResponse,
|
|
||||||
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
|
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
|
||||||
AdminCreateAccountRequest,
|
AdminCreateAccountRequest,
|
||||||
AdminCreateAccountResponse,
|
AdminCreateAccountResponse,
|
||||||
@@ -30,11 +29,12 @@ import type {
|
|||||||
AdminExternalApiKeyListQuery,
|
AdminExternalApiKeyListQuery,
|
||||||
AdminExternalApiKeyListResponse,
|
AdminExternalApiKeyListResponse,
|
||||||
AdminFeatureGateConfigResponse,
|
AdminFeatureGateConfigResponse,
|
||||||
AdminImportAgcTemplatesResponse,
|
AdminGameDistributionReviewListResponse,
|
||||||
|
AdminGameDistributionReviewRequest,
|
||||||
|
AdminGameDistributionReviewResponse,
|
||||||
AdminLoginResponse,
|
AdminLoginResponse,
|
||||||
AdminMeResponse,
|
AdminMeResponse,
|
||||||
AdminOverviewResponse,
|
AdminOverviewResponse,
|
||||||
AdminProjectSnapshotChannelsResponse,
|
|
||||||
AdminProjectSnapshotListQuery,
|
AdminProjectSnapshotListQuery,
|
||||||
AdminProjectSnapshotListResponse,
|
AdminProjectSnapshotListResponse,
|
||||||
AdminRechargeOrderListQuery,
|
AdminRechargeOrderListQuery,
|
||||||
@@ -50,7 +50,6 @@ import type {
|
|||||||
AdminTrackingEventListResponse,
|
AdminTrackingEventListResponse,
|
||||||
AdminUpdateAccountRequest,
|
AdminUpdateAccountRequest,
|
||||||
AdminUpdateAccountResponse,
|
AdminUpdateAccountResponse,
|
||||||
AdminUpdateAgcTemplateRequest,
|
|
||||||
AdminUploadedEditorShowcaseCampaignImage,
|
AdminUploadedEditorShowcaseCampaignImage,
|
||||||
AdminUpsertEditorShowcaseCampaignRequest,
|
AdminUpsertEditorShowcaseCampaignRequest,
|
||||||
AdminUpsertFeatureGateConfigRequest,
|
AdminUpsertFeatureGateConfigRequest,
|
||||||
@@ -89,8 +88,6 @@ interface AdminRequestOptions {
|
|||||||
method?: string;
|
method?: string;
|
||||||
token?: string;
|
token?: string;
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
/** multipart 表单:交给浏览器自己带 boundary,不能预设 Content-Type。 */
|
|
||||||
formData?: FormData;
|
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
@@ -178,8 +175,6 @@ export async function request<T>(
|
|||||||
if (typeof options.body !== 'undefined') {
|
if (typeof options.body !== 'undefined') {
|
||||||
headers['Content-Type'] = 'application/json';
|
headers['Content-Type'] = 'application/json';
|
||||||
init.body = JSON.stringify(options.body);
|
init.body = JSON.stringify(options.body);
|
||||||
} else if (options.formData) {
|
|
||||||
init.body = options.formData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(buildRequestUrl(path), init);
|
const response = await fetch(buildRequestUrl(path), init);
|
||||||
@@ -215,7 +210,6 @@ export function listAdminProjectSnapshots(
|
|||||||
) {
|
) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (query.cursor) params.set('cursor', query.cursor);
|
if (query.cursor) params.set('cursor', query.cursor);
|
||||||
if (query.channel) params.set('channel', query.channel);
|
|
||||||
params.set('limit', String(query.limit ?? 20));
|
params.set('limit', String(query.limit ?? 20));
|
||||||
return request<AdminProjectSnapshotListResponse>(
|
return request<AdminProjectSnapshotListResponse>(
|
||||||
`/admin/api/project-snapshots?${params.toString()}`,
|
`/admin/api/project-snapshots?${params.toString()}`,
|
||||||
@@ -223,27 +217,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(
|
export async function downloadAdminProjectSnapshot(
|
||||||
token: string,
|
token: string,
|
||||||
channel: string,
|
|
||||||
userId: string,
|
userId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
) {
|
) {
|
||||||
const params = new URLSearchParams();
|
const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download`;
|
||||||
if (channel) params.set('channel', channel);
|
|
||||||
const query = params.toString();
|
|
||||||
const path = `/admin/api/project-snapshots/${encodeURIComponent(userId)}/${encodeURIComponent(projectId)}/download${query ? `?${query}` : ''}`;
|
|
||||||
const response = await fetch(buildRequestUrl(path), {
|
const response = await fetch(buildRequestUrl(path), {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token.trim()}`,
|
Authorization: `Bearer ${token.trim()}`,
|
||||||
@@ -1200,32 +1180,71 @@ export function saveAgcModelCatalog(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAdminAgcTemplates(token: string, signal?: AbortSignal) {
|
export function listAdminGameDistributionReviews(token: string, limit = 48) {
|
||||||
return request<AdminAgcTemplateLibraryResponse>('/admin/api/agc-templates', {
|
const normalizedLimit = Number.isFinite(limit)
|
||||||
token,
|
? Math.min(Math.max(Math.trunc(limit), 1), 48)
|
||||||
signal,
|
: 48;
|
||||||
});
|
return request<AdminGameDistributionReviewListResponse>(
|
||||||
}
|
`/admin/api/game-distribution/reviews?limit=${normalizedLimit}`,
|
||||||
|
{ token },
|
||||||
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',
|
*/
|
||||||
|
/**
|
||||||
|
* 安全下架整个游戏。管理员下架同样要求 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`,
|
||||||
{
|
{
|
||||||
token,
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
formData,
|
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,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,15 +105,11 @@ export interface AdminProjectSnapshotEntry {
|
|||||||
fileCount: number;
|
fileCount: number;
|
||||||
totalBytes: number;
|
totalBytes: number;
|
||||||
status: 'ready' | 'partial' | 'unverified';
|
status: 'ready' | 'partial' | 'unverified';
|
||||||
channel: string;
|
|
||||||
authorDisplayName?: string | null;
|
|
||||||
authorPublicUserCode?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminProjectSnapshotListQuery {
|
export interface AdminProjectSnapshotListQuery {
|
||||||
cursor?: string | null;
|
cursor?: string | null;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
channel?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminProjectSnapshotListResponse {
|
export interface AdminProjectSnapshotListResponse {
|
||||||
@@ -121,11 +117,6 @@ export interface AdminProjectSnapshotListResponse {
|
|||||||
nextCursor: string | null;
|
nextCursor: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminProjectSnapshotChannelsResponse {
|
|
||||||
defaultChannel: string;
|
|
||||||
channels: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminErrorReportEntry {
|
export interface AdminErrorReportEntry {
|
||||||
batchId: string;
|
batchId: string;
|
||||||
eventCount: number;
|
eventCount: number;
|
||||||
@@ -153,8 +144,6 @@ export interface AdminErrorReportListResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminErrorReportDetail extends AdminErrorReportEntry {
|
export interface AdminErrorReportDetail extends AdminErrorReportEntry {
|
||||||
firstFingerprint?: string;
|
|
||||||
firstSource?: string;
|
|
||||||
note?: string;
|
note?: string;
|
||||||
events: Array<Record<string, unknown>>;
|
events: Array<Record<string, unknown>>;
|
||||||
logNames: string[];
|
logNames: string[];
|
||||||
@@ -1043,66 +1032,47 @@ export interface AdminAgcModelCatalog {
|
|||||||
models: AdminAgcModel[];
|
models: AdminAgcModel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminAgcTemplatePayload {
|
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;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
summary: string;
|
status: string;
|
||||||
tags: string[];
|
publicationRevision: number;
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
}
|
replayed: boolean;
|
||||||
|
|
||||||
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 {
|
import {
|
||||||
downloadAdminProjectSnapshot,
|
downloadAdminProjectSnapshot,
|
||||||
getAdminProjectSnapshotChannels,
|
|
||||||
listAdminProjectSnapshots,
|
listAdminProjectSnapshots,
|
||||||
} from './adminApiClient';
|
} from './adminApiClient';
|
||||||
|
|
||||||
@@ -22,12 +21,12 @@ test('项目列表携带分页与后台授权,解析标准响应', async () =>
|
|||||||
expect(
|
expect(
|
||||||
await listAdminProjectSnapshots(
|
await listAdminProjectSnapshots(
|
||||||
'admin-token',
|
'admin-token',
|
||||||
{ cursor: 'user/a+项目', limit: 20, channel: 'release' },
|
{ cursor: 'user/a+项目', limit: 20 },
|
||||||
controller.signal,
|
controller.signal,
|
||||||
),
|
),
|
||||||
).toEqual(payload);
|
).toEqual(payload);
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
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({
|
expect.objectContaining({
|
||||||
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
|
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
|
||||||
signal: controller.signal,
|
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 () => {
|
test('ZIP 下载以授权请求读取并优先保留中文附件名', async () => {
|
||||||
const fetchMock = vi.fn().mockResolvedValue(
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
new Response('PK\u0003\u0004', {
|
new Response('PK\u0003\u0004', {
|
||||||
@@ -66,7 +48,6 @@ test('ZIP 下载以授权请求读取并优先保留中文附件名', async () =
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const archive = await downloadAdminProjectSnapshot(
|
const archive = await downloadAdminProjectSnapshot(
|
||||||
'admin-token',
|
'admin-token',
|
||||||
'release',
|
|
||||||
'user/a',
|
'user/a',
|
||||||
'project/b',
|
'project/b',
|
||||||
controller.signal,
|
controller.signal,
|
||||||
@@ -74,7 +55,7 @@ test('ZIP 下载以授权请求读取并优先保留中文附件名', async () =
|
|||||||
expect(archive.filename).toBe('三消-r2.zip');
|
expect(archive.filename).toBe('三消-r2.zip');
|
||||||
expect(archive.blob.type).toBe('application/zip');
|
expect(archive.blob.type).toBe('application/zip');
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
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({
|
expect.objectContaining({
|
||||||
headers: expect.objectContaining({
|
headers: expect.objectContaining({
|
||||||
Authorization: 'Bearer admin-token',
|
Authorization: 'Bearer admin-token',
|
||||||
@@ -100,8 +81,7 @@ test.each([
|
|||||||
vi.fn().mockResolvedValue(new Response('PK', { headers })),
|
vi.fn().mockResolvedValue(new Response('PK', { headers })),
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
(await downloadAdminProjectSnapshot('token', 'release', 'user', 'project'))
|
(await downloadAdminProjectSnapshot('token', 'user', 'project')).filename,
|
||||||
.filename,
|
|
||||||
).toBe(expected.replace('工程', 'project'));
|
).toBe(expected.replace('工程', 'project'));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,7 +104,7 @@ test.each([401, 403, 409, 500])(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
await expect(
|
await expect(
|
||||||
downloadAdminProjectSnapshot('token', 'release', 'user', 'project'),
|
downloadAdminProjectSnapshot('token', 'user', 'project'),
|
||||||
).rejects.toMatchObject({
|
).rejects.toMatchObject({
|
||||||
status,
|
status,
|
||||||
code: 'SNAPSHOT_FAILURE',
|
code: 'SNAPSHOT_FAILURE',
|
||||||
@@ -143,6 +123,6 @@ test('200 JSON 或 HTML 不能被保存为成功 ZIP', async () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
await expect(
|
await expect(
|
||||||
downloadAdminProjectSnapshot('token', 'release', 'user', 'project'),
|
downloadAdminProjectSnapshot('token', 'user', 'project'),
|
||||||
).rejects.toMatchObject({ code: 'INVALID_PROJECT_ARCHIVE_RESPONSE' });
|
).rejects.toMatchObject({ code: 'INVALID_PROJECT_ARCHIVE_RESPONSE' });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
} from '../auth/adminAuthStore';
|
} from '../auth/adminAuthStore';
|
||||||
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
|
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
|
||||||
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
|
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
|
||||||
import { AdminAgcTemplatesPage } from '../pages/AdminAgcTemplatesPage';
|
|
||||||
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
|
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
|
||||||
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
|
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
|
||||||
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
|
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
|
||||||
@@ -27,6 +26,7 @@ import { AdminEditorAssetQueryPage } from '../pages/AdminEditorAssetQueryPage';
|
|||||||
import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGenerationPricingPage';
|
import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGenerationPricingPage';
|
||||||
import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage';
|
import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage';
|
||||||
import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
|
import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
|
||||||
|
import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage';
|
||||||
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
|
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
|
||||||
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
|
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
|
||||||
import { AdminLoginPage } from '../pages/AdminLoginPage';
|
import { AdminLoginPage } from '../pages/AdminLoginPage';
|
||||||
@@ -295,14 +295,14 @@ export function AdminApp() {
|
|||||||
{activeRouteId === 'agc-models' ? (
|
{activeRouteId === 'agc-models' ? (
|
||||||
<AdminAgcModelsPage token={token} onUnauthorized={handleUnauthorized} />
|
<AdminAgcModelsPage token={token} onUnauthorized={handleUnauthorized} />
|
||||||
) : null}
|
) : null}
|
||||||
{activeRouteId === 'agc-templates' ? (
|
{activeRouteId === 'editor-showcase' ? (
|
||||||
<AdminAgcTemplatesPage
|
<AdminEditorShowcaseReviewPage
|
||||||
token={token}
|
token={token}
|
||||||
onUnauthorized={handleUnauthorized}
|
onUnauthorized={handleUnauthorized}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{activeRouteId === 'editor-showcase' ? (
|
{activeRouteId === 'game-distribution' ? (
|
||||||
<AdminEditorShowcaseReviewPage
|
<AdminGameDistributionReviewPage
|
||||||
token={token}
|
token={token}
|
||||||
onUnauthorized={handleUnauthorized}
|
onUnauthorized={handleUnauthorized}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Coins,
|
Coins,
|
||||||
Database,
|
Database,
|
||||||
FolderArchive,
|
FolderArchive,
|
||||||
|
Gamepad2,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Images,
|
Images,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -49,11 +50,11 @@ const routeIcons = {
|
|||||||
'recharge-orders': ReceiptText,
|
'recharge-orders': ReceiptText,
|
||||||
'editor-generation-pricing': Coins,
|
'editor-generation-pricing': Coins,
|
||||||
'editor-showcase': Star,
|
'editor-showcase': Star,
|
||||||
|
'game-distribution': Gamepad2,
|
||||||
'editor-assets': Images,
|
'editor-assets': Images,
|
||||||
'project-snapshots': FolderArchive,
|
'project-snapshots': FolderArchive,
|
||||||
accounts: Users,
|
accounts: Users,
|
||||||
'agc-models': ListChecks,
|
'agc-models': ListChecks,
|
||||||
'agc-templates': Images,
|
|
||||||
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
|
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
|
||||||
|
|
||||||
export function AdminShell({
|
export function AdminShell({
|
||||||
|
|||||||
@@ -148,29 +148,23 @@ test('项目工程入口对 owner 与已授权 member 开放且可分配权限',
|
|||||||
).not.toContainEqual(route);
|
).not.toContainEqual(route);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('模板管理只对 owner 或具有 agc-templates 权限的 member 可见', () => {
|
test('后台游戏审核路由可通过导航和 hash 访问', () => {
|
||||||
expect(adminRoutes).toContainEqual({
|
expect(adminRoutes).toContainEqual({
|
||||||
id: 'agc-templates',
|
id: 'game-distribution',
|
||||||
label: '模板管理',
|
label: '游戏审核',
|
||||||
hash: '#agc-templates',
|
hash: '#game-distribution',
|
||||||
});
|
});
|
||||||
expect(resolveAdminRoute('#agc-templates')).toBe('agc-templates');
|
expect(resolveAdminRoute('#game-distribution')).toBe('game-distribution');
|
||||||
expect(routeHash('agc-templates')).toBe('#agc-templates');
|
expect(routeHash('game-distribution')).toBe('#game-distribution');
|
||||||
expect(
|
});
|
||||||
getAccessibleAdminRoutes({ accountRole: 'owner', tabPermissions: [] }).some(
|
|
||||||
(route) => route.id === 'agc-templates',
|
test('member 可单独获得游戏审核 Tab 权限', () => {
|
||||||
),
|
const routes = getAccessibleAdminRoutes({
|
||||||
).toBe(true);
|
accountRole: 'member',
|
||||||
expect(
|
tabPermissions: ['game-distribution'],
|
||||||
getAccessibleAdminRoutes({
|
});
|
||||||
accountRole: 'member',
|
expect(routes.map((route) => route.id)).toEqual(['game-distribution']);
|
||||||
tabPermissions: ['agc-templates'],
|
expect(resolveAccessibleAdminRoute('#game-distribution', routes)).toBe(
|
||||||
}).map((route) => route.id),
|
'game-distribution',
|
||||||
).toEqual(['agc-templates']);
|
);
|
||||||
expect(
|
|
||||||
getAccessibleAdminRoutes({
|
|
||||||
accountRole: 'member',
|
|
||||||
tabPermissions: ['editor-assets'],
|
|
||||||
}).some((route) => route.id === 'agc-templates'),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ export type AdminRouteId =
|
|||||||
| 'recharge-orders'
|
| 'recharge-orders'
|
||||||
| 'editor-generation-pricing'
|
| 'editor-generation-pricing'
|
||||||
| 'editor-showcase'
|
| 'editor-showcase'
|
||||||
|
| 'game-distribution'
|
||||||
| 'editor-assets'
|
| 'editor-assets'
|
||||||
| 'project-snapshots'
|
| 'project-snapshots'
|
||||||
| 'agc-models'
|
| 'agc-models'
|
||||||
| 'agc-templates'
|
|
||||||
| 'accounts';
|
| 'accounts';
|
||||||
|
|
||||||
export type AdminTabPermission = Exclude<
|
export type AdminTabPermission = Exclude<
|
||||||
@@ -54,8 +54,8 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
|||||||
hash: '#editor-generation-pricing',
|
hash: '#editor-generation-pricing',
|
||||||
},
|
},
|
||||||
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
|
{ 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: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
|
||||||
|
{ id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' },
|
||||||
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
|
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
|
||||||
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
|
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
|
||||||
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import '@genarrative/shared/styles.css';
|
|
||||||
import './styles/admin.css';
|
import './styles/admin.css';
|
||||||
|
|
||||||
import { StrictMode } from 'react';
|
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,18 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
|
||||||
formatAdminTimestamp,
|
|
||||||
parseAdminTimestamp,
|
|
||||||
} from './AdminErrorReportsPage';
|
|
||||||
|
|
||||||
describe('错误报告时间格式化', () => {
|
|
||||||
it('解析 SpacetimeDB seconds.microsZ 时间', () => {
|
|
||||||
expect(parseAdminTimestamp('1778207451.731746Z')).toBe(1778207451731);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('解析微秒字符串并拒绝无效时间', () => {
|
|
||||||
expect(parseAdminTimestamp('1778207451731746')).toBe(1778207451731);
|
|
||||||
expect(formatAdminTimestamp('not-a-date')).toBe('-');
|
|
||||||
expect(formatAdminTimestamp('999999999999999999999999')).toBe('-');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -17,57 +17,6 @@ type Props = { token: string; onUnauthorized: (message?: string) => void };
|
|||||||
|
|
||||||
const ADMIN_ERROR_REPORT_STATUSES = ['new', 'in-progress', 'resolved'] as const;
|
const ADMIN_ERROR_REPORT_STATUSES = ['new', 'in-progress', 'resolved'] as const;
|
||||||
const ERROR_REPORT_PAGE_SIZE = 50;
|
const ERROR_REPORT_PAGE_SIZE = 50;
|
||||||
const ERROR_REPORT_STATUS_LABELS: Record<string, string> = {
|
|
||||||
new: '待处理',
|
|
||||||
'in-progress': '处理中',
|
|
||||||
resolved: '已解决',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function parseAdminTimestamp(value: string | null | undefined) {
|
|
||||||
const normalized = value?.trim() ?? '';
|
|
||||||
if (/^-?\d+\.\d{6}Z$/u.test(normalized)) {
|
|
||||||
const [secondsText, microsText] = normalized.slice(0, -1).split('.');
|
|
||||||
const seconds = Number(secondsText);
|
|
||||||
const micros = Number(microsText);
|
|
||||||
return Number.isFinite(seconds) && Number.isFinite(micros)
|
|
||||||
? seconds * 1000 + Math.floor(micros / 1000)
|
|
||||||
: Number.NaN;
|
|
||||||
}
|
|
||||||
if (/^-?\d+$/u.test(normalized)) {
|
|
||||||
const numeric = Number(normalized);
|
|
||||||
if (!Number.isFinite(numeric)) return Number.NaN;
|
|
||||||
if (Math.abs(numeric) >= 1e14) return Math.floor(numeric / 1000);
|
|
||||||
if (Math.abs(numeric) >= 1e11) return numeric;
|
|
||||||
return numeric * 1000;
|
|
||||||
}
|
|
||||||
return Date.parse(normalized);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatAdminTimestamp(value: string | null | undefined) {
|
|
||||||
const timestamp = parseAdminTimestamp(value);
|
|
||||||
if (!Number.isFinite(timestamp)) return '-';
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
if (!Number.isFinite(date.getTime())) return '-';
|
|
||||||
try {
|
|
||||||
return new Intl.DateTimeFormat('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
}).format(date);
|
|
||||||
} catch {
|
|
||||||
return '-';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function eventText(event: Record<string, unknown>, key: string) {
|
|
||||||
const value = event[key];
|
|
||||||
return typeof value === 'string' || typeof value === 'number'
|
|
||||||
? String(value)
|
|
||||||
: '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
||||||
const [reports, setReports] = useState<AdminErrorReportEntry[]>([]);
|
const [reports, setReports] = useState<AdminErrorReportEntry[]>([]);
|
||||||
@@ -80,24 +29,6 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
|||||||
const [pageInfo, setPageInfo] = useState({ total: 0, hasMore: false });
|
const [pageInfo, setPageInfo] = useState({ total: 0, hasMore: false });
|
||||||
const openReportRequestId = useRef(0);
|
const openReportRequestId = useRef(0);
|
||||||
const loadRequestId = useRef(0);
|
const loadRequestId = useRef(0);
|
||||||
const detailCloseButtonRef = useRef<HTMLButtonElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selected) return;
|
|
||||||
const previousFocus = document.activeElement as HTMLElement | null;
|
|
||||||
const focusFrame = window.requestAnimationFrame(() => {
|
|
||||||
detailCloseButtonRef.current?.focus();
|
|
||||||
});
|
|
||||||
const handleEscape = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Escape') setSelected(null);
|
|
||||||
};
|
|
||||||
window.addEventListener('keydown', handleEscape);
|
|
||||||
return () => {
|
|
||||||
window.cancelAnimationFrame(focusFrame);
|
|
||||||
window.removeEventListener('keydown', handleEscape);
|
|
||||||
previousFocus?.focus?.();
|
|
||||||
};
|
|
||||||
}, [selected]);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const requestId = ++loadRequestId.current;
|
const requestId = ++loadRequestId.current;
|
||||||
@@ -253,15 +184,9 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
|||||||
<td>{report.batchId}</td>
|
<td>{report.batchId}</td>
|
||||||
<td>{report.eventCount}</td>
|
<td>{report.eventCount}</td>
|
||||||
<td>{report.source ?? '-'}</td>
|
<td>{report.source ?? '-'}</td>
|
||||||
<td>
|
<td>{report.status}</td>
|
||||||
<span
|
|
||||||
className={`admin-error-report-status is-${report.status}`}
|
|
||||||
>
|
|
||||||
{ERROR_REPORT_STATUS_LABELS[report.status] ?? report.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>{report.userId}</td>
|
<td>{report.userId}</td>
|
||||||
<td>{formatAdminTimestamp(report.createdAt)}</td>
|
<td>{new Date(report.createdAt).toLocaleString()}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -299,124 +224,24 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
|||||||
className="admin-detail-modal"
|
className="admin-detail-modal"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label="错误报告详情"
|
aria-label="错误报告详情"
|
||||||
aria-modal="true"
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
if (event.target === event.currentTarget) setSelected(null);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="admin-detail-modal__panel admin-error-report-detail">
|
<div className="admin-detail-modal__panel">
|
||||||
<header className="admin-error-report-detail__header">
|
<header>
|
||||||
<div className="admin-error-report-detail__heading">
|
|
||||||
<span className="admin-error-report-detail__eyebrow">
|
|
||||||
错误报告详情
|
|
||||||
</span>
|
|
||||||
<h2>{selected.batchId}</h2>
|
<h2>{selected.batchId}</h2>
|
||||||
<div className="admin-error-report-detail__badges">
|
<button type="button" onClick={() => setSelected(null)}>
|
||||||
<span
|
关闭
|
||||||
className={`admin-error-report-status is-${selected.status}`}
|
|
||||||
>
|
|
||||||
{ERROR_REPORT_STATUS_LABELS[selected.status] ??
|
|
||||||
selected.status}
|
|
||||||
</span>
|
|
||||||
<span className="admin-error-report-detail__badge">
|
|
||||||
{selected.firstSource ?? selected.source ?? '未知来源'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="admin-error-report-detail__close"
|
|
||||||
ref={detailCloseButtonRef}
|
|
||||||
onClick={() => setSelected(null)}
|
|
||||||
aria-label="关闭错误报告详情"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div className="admin-error-report-detail__summary">
|
<p>
|
||||||
<div>
|
用户:{selected.userId} · 事件:{selected.eventCount} · 日志:
|
||||||
<span>用户</span>
|
{selected.logCount}
|
||||||
<strong>{selected.userId}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>发生时间</span>
|
|
||||||
<strong>{formatAdminTimestamp(selected.createdAt)}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>错误事件</span>
|
|
||||||
<strong>{selected.eventCount}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>应用日志</span>
|
|
||||||
<strong>{selected.logCount}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="admin-error-report-detail__body">
|
|
||||||
<section className="admin-error-report-detail__section">
|
|
||||||
<div className="admin-error-report-detail__section-heading">
|
|
||||||
<div>
|
|
||||||
<h3>错误事件</h3>
|
|
||||||
<span>展示最近 20 条,完整内容请下载诊断包。</span>
|
|
||||||
</div>
|
|
||||||
<span className="admin-error-report-detail__count">
|
|
||||||
{selected.events.length} 条
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="admin-error-report-event-list">
|
|
||||||
{selected.events.length === 0 ? (
|
|
||||||
<p className="admin-error-report-detail__empty">
|
|
||||||
归档中没有可展示的错误事件。
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
<pre>{JSON.stringify(selected.events.slice(0, 20), null, 2)}</pre>
|
||||||
{selected.events.slice(0, 20).map((event, index) => {
|
|
||||||
const message = eventText(event, 'message') || '未知错误';
|
|
||||||
const stack = eventText(event, 'stack');
|
|
||||||
const occurredAt = eventText(event, 'occurredAt');
|
|
||||||
return (
|
|
||||||
<article
|
|
||||||
className="admin-error-report-event"
|
|
||||||
key={`${index}-${message}`}
|
|
||||||
>
|
|
||||||
<div className="admin-error-report-event__topline">
|
|
||||||
<span className="admin-error-report-event__index">
|
|
||||||
#{index + 1}
|
|
||||||
</span>
|
|
||||||
<span>{eventText(event, 'source') || 'client'}</span>
|
|
||||||
<span>{eventText(event, 'count') || '1'} 次</span>
|
|
||||||
<time>{formatAdminTimestamp(occurredAt)}</time>
|
|
||||||
</div>
|
|
||||||
<p className="admin-error-report-event__message">
|
|
||||||
{message}
|
|
||||||
</p>
|
|
||||||
{eventText(event, 'fingerprint') ? (
|
|
||||||
<code>{eventText(event, 'fingerprint')}</code>
|
|
||||||
) : null}
|
|
||||||
{stack ? (
|
|
||||||
<details>
|
|
||||||
<summary>查看调用栈</summary>
|
|
||||||
<pre>{stack}</pre>
|
|
||||||
</details>
|
|
||||||
) : null}
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{selected.userDescription ? (
|
{selected.userDescription ? (
|
||||||
<section className="admin-error-report-detail__section">
|
<p>用户描述:{selected.userDescription}</p>
|
||||||
<div className="admin-error-report-detail__section-heading">
|
|
||||||
<div>
|
|
||||||
<h3>用户描述</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="admin-error-report-detail__description">
|
|
||||||
{selected.userDescription}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
) : null}
|
) : null}
|
||||||
<section className="admin-error-report-detail__section admin-error-report-detail__section--note">
|
|
||||||
<label className="admin-detail-modal__note">
|
<label className="admin-detail-modal__note">
|
||||||
<span>处理备注</span>
|
处理备注
|
||||||
<textarea
|
<textarea
|
||||||
value={selected.note ?? ''}
|
value={selected.note ?? ''}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -432,11 +257,7 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</section>
|
<div className="admin-detail-modal__actions">
|
||||||
</div>
|
|
||||||
<footer className="admin-error-report-detail__footer">
|
|
||||||
<label className="admin-error-report-detail__status-field">
|
|
||||||
状态
|
|
||||||
<select
|
<select
|
||||||
value={selected.status}
|
value={selected.status}
|
||||||
onChange={(event) => void saveStatus(event.target.value)}
|
onChange={(event) => void saveStatus(event.target.value)}
|
||||||
@@ -444,18 +265,16 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
|||||||
>
|
>
|
||||||
{ADMIN_ERROR_REPORT_STATUSES.map((value) => (
|
{ADMIN_ERROR_REPORT_STATUSES.map((value) => (
|
||||||
<option key={value} value={value}>
|
<option key={value} value={value}>
|
||||||
{ERROR_REPORT_STATUS_LABELS[value]}
|
{value}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
|
||||||
<div className="admin-error-report-detail__footer-actions">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void saveStatus(selected.status)}
|
onClick={() => void saveStatus(selected.status)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
>
|
>
|
||||||
{busy ? '保存中…' : '保存备注'}
|
保存备注
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -465,7 +284,6 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
|||||||
{downloading ? '下载中…' : '下载诊断包'}
|
{downloading ? '下载中…' : '下载诊断包'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
/* @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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,6 @@ import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
AdminApiError,
|
AdminApiError,
|
||||||
downloadAdminProjectSnapshot,
|
downloadAdminProjectSnapshot,
|
||||||
getAdminProjectSnapshotChannels,
|
|
||||||
listAdminProjectSnapshots,
|
listAdminProjectSnapshots,
|
||||||
} from '../api/adminApiClient';
|
} from '../api/adminApiClient';
|
||||||
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
|
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
|
||||||
@@ -24,7 +23,6 @@ vi.mock('../api/adminApiClient', async () => ({
|
|||||||
'../api/adminApiClient',
|
'../api/adminApiClient',
|
||||||
)),
|
)),
|
||||||
downloadAdminProjectSnapshot: vi.fn(),
|
downloadAdminProjectSnapshot: vi.fn(),
|
||||||
getAdminProjectSnapshotChannels: vi.fn(),
|
|
||||||
listAdminProjectSnapshots: vi.fn(),
|
listAdminProjectSnapshots: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -37,18 +35,12 @@ const entry: AdminProjectSnapshotEntry = {
|
|||||||
fileCount: 12,
|
fileCount: 12,
|
||||||
totalBytes: 2048,
|
totalBytes: 2048,
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
channel: 'dev',
|
|
||||||
authorDisplayName: '陶泥作者',
|
|
||||||
authorPublicUserCode: 'SY-00000007',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(listAdminProjectSnapshots)
|
vi.mocked(listAdminProjectSnapshots)
|
||||||
.mockReset()
|
.mockReset()
|
||||||
.mockResolvedValue({ items: [entry], nextCursor: null });
|
.mockResolvedValue({ items: [entry], nextCursor: null });
|
||||||
vi.mocked(getAdminProjectSnapshotChannels)
|
|
||||||
.mockReset()
|
|
||||||
.mockResolvedValue({ defaultChannel: 'dev', channels: ['dev'] });
|
|
||||||
vi.mocked(downloadAdminProjectSnapshot).mockReset();
|
vi.mocked(downloadAdminProjectSnapshot).mockReset();
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -96,166 +88,36 @@ test('按项目展示完整性并限制未完成工程下载', async () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('按游标翻页回到上一页时复用已取得的游标', async () => {
|
test('加载更多合并项目,刷新失败保留列表和错误,重试从首页开始', async () => {
|
||||||
vi.mocked(listAdminProjectSnapshots)
|
vi.mocked(listAdminProjectSnapshots)
|
||||||
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
|
.mockResolvedValueOnce({ items: [entry], nextCursor: 'page-2' })
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
items: [{ ...entry, projectId: 'project-2', projectName: '第二工程' }],
|
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()} />);
|
render(<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />);
|
||||||
await screen.findByText('三消工程');
|
fireEvent.click(await screen.findByRole('button', { name: '加载更多' }));
|
||||||
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: '下一页' }));
|
|
||||||
await screen.findByText('第二工程');
|
await screen.findByText('第二工程');
|
||||||
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
expect(listAdminProjectSnapshots).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
'token',
|
'token',
|
||||||
{ cursor: 'page-2', limit: 20, channel: 'dev' },
|
{ cursor: 'page-2', limit: 20 },
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
);
|
);
|
||||||
expect(screen.queryByText('三消工程')).toBeNull();
|
expect(screen.getByText('三消工程')).toBeTruthy();
|
||||||
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('第二工程');
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||||
await screen.findByRole('alert');
|
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.getByText('第二工程')).toBeTruthy();
|
||||||
expect(screen.queryByText('暂无已上传项目')).toBeNull();
|
expect(screen.queryByText('暂无已上传项目')).toBeNull();
|
||||||
|
|
||||||
vi.mocked(listAdminProjectSnapshots).mockResolvedValueOnce({
|
|
||||||
items: [],
|
|
||||||
nextCursor: null,
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
fireEvent.click(screen.getByRole('button', { name: '刷新' }));
|
||||||
await screen.findByText('暂无已上传项目');
|
await screen.findByText('暂无已上传项目');
|
||||||
expect(screen.queryByRole('alert')).toBeNull();
|
expect(listAdminProjectSnapshots).toHaveBeenLastCalledWith(
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
|
||||||
'token',
|
'token',
|
||||||
{ cursor: null, limit: 20, channel: 'release' },
|
{ cursor: null, limit: 20 },
|
||||||
expect.any(AbortSignal),
|
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 () => {
|
test('下载使用返回的中文文件名,随后释放对象 URL', async () => {
|
||||||
@@ -292,7 +154,6 @@ test('下载使用返回的中文文件名,随后释放对象 URL', async () =
|
|||||||
expect(createObjectURL).toHaveBeenCalledWith(blob);
|
expect(createObjectURL).toHaveBeenCalledWith(blob);
|
||||||
expect(downloadAdminProjectSnapshot).toHaveBeenCalledWith(
|
expect(downloadAdminProjectSnapshot).toHaveBeenCalledWith(
|
||||||
'token',
|
'token',
|
||||||
'dev',
|
|
||||||
'user-1',
|
'user-1',
|
||||||
'project-1',
|
'project-1',
|
||||||
expect.any(AbortSignal),
|
expect.any(AbortSignal),
|
||||||
@@ -303,7 +164,7 @@ test('下载使用返回的中文文件名,随后释放对象 URL', async () =
|
|||||||
|
|
||||||
test('取消下载中止请求且不显示错误,卸载中止列表请求', async () => {
|
test('取消下载中止请求且不显示错误,卸载中止列表请求', async () => {
|
||||||
vi.mocked(downloadAdminProjectSnapshot).mockImplementation(
|
vi.mocked(downloadAdminProjectSnapshot).mockImplementation(
|
||||||
(_token, _channel, _user, _project, signal) =>
|
(_token, _user, _project, signal) =>
|
||||||
new Promise((_resolve, reject) => {
|
new Promise((_resolve, reject) => {
|
||||||
signal?.addEventListener('abort', () =>
|
signal?.addEventListener('abort', () =>
|
||||||
reject(new DOMException('Aborted', 'AbortError')),
|
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: '下载完整工程' }));
|
||||||
fireEvent.click(await screen.findByRole('button', { name: '取消下载' }));
|
fireEvent.click(await screen.findByRole('button', { name: '取消下载' }));
|
||||||
expect(
|
expect(
|
||||||
vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[4]?.aborted,
|
vi.mocked(downloadAdminProjectSnapshot).mock.calls[0]?.[3]?.aborted,
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
await waitFor(() => expect(screen.queryByRole('alert')).toBeNull());
|
await waitFor(() => expect(screen.queryByRole('alert')).toBeNull());
|
||||||
vi.mocked(listAdminProjectSnapshots).mockReturnValue(new Promise(() => {}));
|
vi.mocked(listAdminProjectSnapshots).mockReturnValue(new Promise(() => {}));
|
||||||
@@ -387,10 +248,6 @@ test('更换登录令牌丢弃旧列表和晚返回请求', async () => {
|
|||||||
onUnauthorized={onUnauthorized}
|
onUnauthorized={onUnauthorized}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
// 渠道确定之后才会发出列表请求,这里等到旧令牌的请求真的在途再换令牌。
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(listAdminProjectSnapshots).toHaveBeenCalledTimes(1),
|
|
||||||
);
|
|
||||||
const oldSignal = vi.mocked(listAdminProjectSnapshots).mock.calls[0]?.[2];
|
const oldSignal = vi.mocked(listAdminProjectSnapshots).mock.calls[0]?.[2];
|
||||||
view.rerender(
|
view.rerender(
|
||||||
<AdminProjectSnapshotsPage
|
<AdminProjectSnapshotsPage
|
||||||
@@ -421,7 +278,7 @@ test('卸载后完成的下载不会创建浏览器文件', async () => {
|
|||||||
<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />,
|
<AdminProjectSnapshotsPage token="token" onUnauthorized={vi.fn()} />,
|
||||||
);
|
);
|
||||||
fireEvent.click(await screen.findByRole('button', { name: '下载完整工程' }));
|
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();
|
view.unmount();
|
||||||
expect(signal?.aborted).toBe(true);
|
expect(signal?.aborted).toBe(true);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
import {
|
import { Download, RefreshCcw, X } from 'lucide-react';
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Download,
|
|
||||||
RefreshCcw,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
downloadAdminProjectSnapshot,
|
downloadAdminProjectSnapshot,
|
||||||
getAdminProjectSnapshotChannels,
|
|
||||||
listAdminProjectSnapshots,
|
listAdminProjectSnapshots,
|
||||||
} from '../api/adminApiClient';
|
} from '../api/adminApiClient';
|
||||||
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
|
import type { AdminProjectSnapshotEntry } from '../api/adminApiTypes';
|
||||||
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
|
|
||||||
import { handlePageError } from './pageUtils';
|
import { handlePageError } from './pageUtils';
|
||||||
|
|
||||||
interface AdminProjectSnapshotsPageProps {
|
interface AdminProjectSnapshotsPageProps {
|
||||||
@@ -39,32 +31,21 @@ const snapshotStatuses = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 20;
|
|
||||||
const PAGE_SIZE_OPTIONS = [20, 50, 100];
|
|
||||||
|
|
||||||
export function AdminProjectSnapshotsPage({
|
export function AdminProjectSnapshotsPage({
|
||||||
token,
|
token,
|
||||||
onUnauthorized,
|
onUnauthorized,
|
||||||
}: AdminProjectSnapshotsPageProps) {
|
}: AdminProjectSnapshotsPageProps) {
|
||||||
const [items, setItems] = useState<AdminProjectSnapshotEntry[]>([]);
|
const [items, setItems] = useState<AdminProjectSnapshotEntry[]>([]);
|
||||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
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 [isLoading, setIsLoading] = useState(false);
|
||||||
const [hasLoaded, setHasLoaded] = useState(false);
|
const [hasLoaded, setHasLoaded] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
|
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
|
||||||
const listController = useRef<AbortController | null>(null);
|
const listController = useRef<AbortController | null>(null);
|
||||||
const downloadController = useRef<AbortController | null>(null);
|
const downloadController = useRef<AbortController | null>(null);
|
||||||
// 远端按游标分页且不给总数:第 N 页的起始游标只能由前 N-1 页依次返回,
|
|
||||||
// 因此按页记录已取得的游标,翻页只在这些游标之间移动。
|
|
||||||
const pageCursors = useRef<(string | null)[]>([null]);
|
|
||||||
|
|
||||||
const loadPage = useCallback(
|
const loadPage = useCallback(
|
||||||
async (cursor: string | null, limit: number, page: number) => {
|
async (cursor: string | null = null) => {
|
||||||
listController.current?.abort();
|
listController.current?.abort();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
listController.current = controller;
|
listController.current = controller;
|
||||||
@@ -73,16 +54,23 @@ export function AdminProjectSnapshotsPage({
|
|||||||
try {
|
try {
|
||||||
const response = await listAdminProjectSnapshots(
|
const response = await listAdminProjectSnapshots(
|
||||||
token,
|
token,
|
||||||
{ cursor, limit, channel },
|
{ cursor, limit: 20 },
|
||||||
controller.signal,
|
controller.signal,
|
||||||
);
|
);
|
||||||
if (controller.signal.aborted) return;
|
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);
|
setNextCursor(response.nextCursor);
|
||||||
setPageIndex(page);
|
|
||||||
setHasLoaded(true);
|
setHasLoaded(true);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// 翻页或刷新失败时保留当前页,不把已看到的列表换成空表。
|
|
||||||
if (!controller.signal.aborted)
|
if (!controller.signal.aborted)
|
||||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -92,70 +80,22 @@ export function AdminProjectSnapshotsPage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[token, onUnauthorized, channel],
|
[token, onUnauthorized],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
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([]);
|
setItems([]);
|
||||||
setNextCursor(null);
|
setNextCursor(null);
|
||||||
setPageIndex(1);
|
|
||||||
setHasLoaded(false);
|
setHasLoaded(false);
|
||||||
setDownloadingKey(null);
|
setDownloadingKey(null);
|
||||||
void loadPage(null, pageSize, 1);
|
void loadPage();
|
||||||
return () => {
|
return () => {
|
||||||
listController.current?.abort();
|
listController.current?.abort();
|
||||||
listController.current = null;
|
listController.current = null;
|
||||||
downloadController.current?.abort();
|
downloadController.current?.abort();
|
||||||
downloadController.current = null;
|
downloadController.current = null;
|
||||||
};
|
};
|
||||||
}, [loadPage, pageSize, channel]);
|
}, [loadPage]);
|
||||||
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadProject(entry: AdminProjectSnapshotEntry) {
|
async function downloadProject(entry: AdminProjectSnapshotEntry) {
|
||||||
if (downloadController.current || entry.status === 'partial') return;
|
if (downloadController.current || entry.status === 'partial') return;
|
||||||
@@ -166,7 +106,6 @@ export function AdminProjectSnapshotsPage({
|
|||||||
try {
|
try {
|
||||||
const archive = await downloadAdminProjectSnapshot(
|
const archive = await downloadAdminProjectSnapshot(
|
||||||
token,
|
token,
|
||||||
channel ?? '',
|
|
||||||
entry.userId,
|
entry.userId,
|
||||||
entry.projectId,
|
entry.projectId,
|
||||||
controller.signal,
|
controller.signal,
|
||||||
@@ -201,43 +140,20 @@ export function AdminProjectSnapshotsPage({
|
|||||||
setDownloadingKey(null);
|
setDownloadingKey(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 渠道列表读取失败时至少保留当前渠道,避免选择框空掉后看不出在查哪个渠道。
|
|
||||||
const visibleChannelOptions = channelOptions.length
|
|
||||||
? channelOptions
|
|
||||||
: channel
|
|
||||||
? [channel]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="admin-page admin-page-wide">
|
<section className="admin-page admin-page-wide">
|
||||||
<div className="admin-page-heading">
|
<div className="admin-page-heading">
|
||||||
<h2>项目工程</h2>
|
<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
|
<button
|
||||||
className="admin-secondary-button"
|
className="admin-secondary-button"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={refreshCurrentPage}
|
onClick={() => void loadPage()}
|
||||||
>
|
>
|
||||||
<RefreshCcw size={17} aria-hidden="true" />
|
<RefreshCcw size={17} aria-hidden="true" />
|
||||||
<span>{isLoading ? '加载中' : '刷新'}</span>
|
<span>{isLoading ? '加载中' : '刷新'}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<div className="admin-alert" role="alert">
|
<div className="admin-alert" role="alert">
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
@@ -253,7 +169,7 @@ export function AdminProjectSnapshotsPage({
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>项目</th>
|
<th>项目</th>
|
||||||
<th>用户</th>
|
<th>用户 ID</th>
|
||||||
<th>同步时间</th>
|
<th>同步时间</th>
|
||||||
<th>文件数</th>
|
<th>文件数</th>
|
||||||
<th>体积</th>
|
<th>体积</th>
|
||||||
@@ -271,22 +187,7 @@ export function AdminProjectSnapshotsPage({
|
|||||||
<strong>{entry.projectName || entry.projectId}</strong>
|
<strong>{entry.projectName || entry.projectId}</strong>
|
||||||
<small>{entry.projectId}</small>
|
<small>{entry.projectId}</small>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="用户">
|
<td data-label="用户 ID">{entry.userId}</td>
|
||||||
<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="同步时间">
|
<td data-label="同步时间">
|
||||||
<span>
|
<span>
|
||||||
{new Date(entry.syncedAtMs).toLocaleString('zh-CN', {
|
{new Date(entry.syncedAtMs).toLocaleString('zh-CN', {
|
||||||
@@ -338,66 +239,23 @@ export function AdminProjectSnapshotsPage({
|
|||||||
{hasLoaded && items.length === 0 && !errorMessage ? (
|
{hasLoaded && items.length === 0 && !errorMessage ? (
|
||||||
<p className="admin-muted-text">暂无已上传项目</p>
|
<p className="admin-muted-text">暂无已上传项目</p>
|
||||||
) : null}
|
) : null}
|
||||||
{hasLoaded ? (
|
{nextCursor ? (
|
||||||
<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">
|
<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
|
<button
|
||||||
aria-label="上一页"
|
|
||||||
className="admin-secondary-button"
|
className="admin-secondary-button"
|
||||||
disabled={isLoading || pageIndex <= 1}
|
disabled={isLoading}
|
||||||
title="上一页"
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={goToPreviousPage}
|
onClick={() => void loadPage(nextCursor)}
|
||||||
>
|
>
|
||||||
<ChevronLeft size={17} aria-hidden="true" />
|
{isLoading ? '加载中' : '加载更多'}
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectOwnerDisplayName(entry: AdminProjectSnapshotEntry) {
|
|
||||||
return (
|
|
||||||
entry.authorDisplayName?.trim() || entry.authorPublicUserCode?.trim() || '-'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshotKey(entry: AdminProjectSnapshotEntry) {
|
function snapshotKey(entry: AdminProjectSnapshotEntry) {
|
||||||
return `${entry.userId}/${entry.projectId}`;
|
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, a,b 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;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user