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/
|
||||||
|
|||||||
@@ -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,95 +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(
|
||||||
@@ -456,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
@@ -9,7 +9,6 @@
|
|||||||
"dev-stack": "node scripts/start-dev-stack.mjs",
|
"dev-stack": "node scripts/start-dev-stack.mjs",
|
||||||
"build": "node scripts/build-release.mjs",
|
"build": "node scripts/build-release.mjs",
|
||||||
"release:upload": "node scripts/release-upload.mjs",
|
"release:upload": "node scripts/release-upload.mjs",
|
||||||
"nsis:prepare": "node scripts/ensure-nsis-toolset.mjs",
|
|
||||||
"skill-pack:check": "node scripts/check-skill-pack.mjs",
|
"skill-pack:check": "node scripts/check-skill-pack.mjs",
|
||||||
"skill-pack:sync": "node scripts/check-skill-pack.mjs --write",
|
"skill-pack:sync": "node scripts/check-skill-pack.mjs --write",
|
||||||
"skill-pack:test": "node --test scripts/check-skill-pack.test.mjs",
|
"skill-pack:test": "node --test scripts/check-skill-pack.test.mjs",
|
||||||
@@ -67,16 +66,15 @@
|
|||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@openai/codex": "0.155.1",
|
"@openai/codex": "0.147.0",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@tauri-apps/cli": "^2.11.2",
|
"@tauri-apps/cli": "^2.11.2",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-window": "^1.8.8",
|
|
||||||
"@types/three": "^0.184.1",
|
"@types/three": "^0.184.1",
|
||||||
"jszip": "^3.10.1",
|
"@types/react-window": "^1.8.8",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
"typescript": "~5.8.2",
|
"typescript": "~5.8.2",
|
||||||
"vitest": "^0.34.6"
|
"vitest": "^0.34.6"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user