Merge branch 'master' into feat/agc-ui-motion-ux
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m59s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m44s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m53s
Project CI / Frontend tests (pull_request) Successful in 3m39s
Project CI / Backend tests (pull_request) Successful in 8m27s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m37s
Project CI / Native shell tests (pull_request) Successful in 8m56s
Project CI / Repository checks (pull_request) Successful in 7m21s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m59s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m44s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m53s
Project CI / Frontend tests (pull_request) Successful in 3m39s
Project CI / Backend tests (pull_request) Successful in 8m27s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m37s
Project CI / Native shell tests (pull_request) Successful in 8m56s
Project CI / Repository checks (pull_request) Successful in 7m21s
This commit is contained in:
@@ -164,6 +164,10 @@ module.exports = {
|
||||
'server-rs/target-*',
|
||||
'apps/desktop-shell/src-tauri/target',
|
||||
'apps/ai-game-creator-shell/src/features/ui-editor/types/**',
|
||||
// ts-rs 生成绑定:不接受 eslint --fix 二次改写,必须与原始输出逐字节一致
|
||||
'packages/shared/src/contracts/generated/**',
|
||||
'apps/ai-game-creator-shell/src/view/project-development/chat/generated/**',
|
||||
'apps/ai-game-creator-shell/src/services/generated/**',
|
||||
'apps/ai-game-creator-shell/src/features/project-workspace/generated/**',
|
||||
'target',
|
||||
'src/main.tsx',
|
||||
@@ -215,6 +219,7 @@ module.exports = {
|
||||
'!src/services/frontendRuntimeConfigService.test.ts',
|
||||
'!src/services/gameDistributionClient.ts',
|
||||
'!src/services/gameDistributionClient.test.ts',
|
||||
'!src/services/gamePlayClientId.ts',
|
||||
'!src/services/sseStream.ts',
|
||||
'!src/services/sseStream.test.ts',
|
||||
'src/AdventurePanel.tsx',
|
||||
|
||||
+7
-2
@@ -24,5 +24,10 @@
|
||||
*.anim text
|
||||
*.controller text
|
||||
|
||||
# Rust ts-rs 生成的共享契约:保留在仓库中供 TS 消费,但不作为手写源文件统计。
|
||||
packages/shared/src/contracts/generated/** linguist-generated=true
|
||||
# ts-rs 生成绑定:保留在仓库中供 TS 消费,但不作为手写源文件统计。
|
||||
# ts-rs 原始输出在多行对象 / 枚举变体后带行尾空格,生成目录统一豁免行尾空白检查;
|
||||
# 一致性由 `npm run check:generated-bindings` 逐字节比对原始输出来保证。
|
||||
packages/shared/src/contracts/generated/** linguist-generated=true whitespace=-trailing-space
|
||||
apps/ai-game-creator-shell/src/features/ui-editor/types/** linguist-generated=true whitespace=-trailing-space
|
||||
apps/ai-game-creator-shell/src/view/project-development/chat/generated/** linguist-generated=true whitespace=-trailing-space
|
||||
apps/ai-game-creator-shell/src/services/generated/** linguist-generated=true whitespace=-trailing-space
|
||||
|
||||
@@ -20,6 +20,9 @@ env:
|
||||
CARGO_HTTP_MULTIPLEXING: 'false'
|
||||
CARGO_NET_RETRY: '10'
|
||||
CARGO_TERM_COLOR: always
|
||||
# 等价于给每条 `cargo test` 加 `-q`:通过的用例只打点号,失败的才按名字列出。
|
||||
# 对 npm 脚本、shard runner 里嵌套的 cargo 同样生效,避免逐条 `test ... ok` 刷屏。
|
||||
CARGO_TERM_QUIET: 'true'
|
||||
NPM_CONFIG_AUDIT: 'false'
|
||||
NPM_CONFIG_FETCH_RETRIES: '10'
|
||||
NPM_CONFIG_FETCH_RETRY_FACTOR: '2'
|
||||
@@ -39,8 +42,7 @@ env:
|
||||
# `Native shell tests`(契约 + H5 / 微信 / 移动 / 桌面壳门禁 + 发布构建 smoke)、
|
||||
# `AI game creator shell web tests`(typecheck + 壳内测试)、`AI game creator shell
|
||||
# Rust lane 1/2`、`lane 2/2`(每条 lane 顺序运行两片 AGC 壳 bin 单测)、`AI game creator
|
||||
# shell Rust smoke`(agent-run smoke)与 `AI game creator shell Rust crates`(AGC 壳依赖的
|
||||
# 共享 / 平台 crate 测试)。各自的命令与拆分前逐一对应,本地
|
||||
# shell Rust crates`(AGC 壳依赖的共享 / 平台 crate 测试)。各自的命令与拆分前逐一对应,本地
|
||||
# `npm run check:native-shells` 仍是同一条串行序列。
|
||||
#
|
||||
# AGC 壳的 bin 单测(2466 条)仍按名单分 4 片、片内保持 `--test-threads=1`;两条 lane
|
||||
@@ -160,57 +162,6 @@ jobs:
|
||||
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
|
||||
run: python3 scripts/export-gitea-rust-cache.py
|
||||
|
||||
# agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样需要 npm 随包依赖,
|
||||
# 单独一个 job,免得把已经压到 4 分钟级的片 job 拖长。
|
||||
ai-game-creator-shell-rust-smoke:
|
||||
name: AI game creator shell Rust smoke
|
||||
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: Install npm dependencies
|
||||
run: bash scripts/ci-npm-ci-with-retry.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: Prepare isolated Rust compilation cache
|
||||
run: bash scripts/ci-rust-cache.sh prepare
|
||||
|
||||
- name: Run AI game creator shell agent-run smoke
|
||||
run: npm run check:native-shells:agc-rust-smoke
|
||||
|
||||
- name: Report isolated Rust compilation cache
|
||||
if: always()
|
||||
run: bash scripts/ci-rust-cache.sh report
|
||||
- name: Publish master Rust cache artifact
|
||||
continue-on-error: true
|
||||
if: ${{ !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/master' }}
|
||||
env:
|
||||
GENARRATIVE_GITEA_TOKEN: ${{ github.token }}
|
||||
run: python3 scripts/export-gitea-rust-cache.py
|
||||
|
||||
# AGC 壳依赖的共享 / 平台和编辑器插件 crate 各自预热独立 manifest,再运行对应测试。
|
||||
ai-game-creator-shell-rust-crates:
|
||||
name: AI game creator shell Rust crates
|
||||
|
||||
@@ -3,7 +3,11 @@ node_modules
|
||||
.git
|
||||
.codex-logs
|
||||
public/Icons
|
||||
# ts-rs 生成绑定:一律以原始输出提交,禁止 prettier 二次改写
|
||||
packages/shared/src/contracts/generated/
|
||||
apps/ai-game-creator-shell/src/features/ui-editor/types/
|
||||
apps/ai-game-creator-shell/src/view/project-development/chat/generated/
|
||||
apps/ai-game-creator-shell/src/services/generated/
|
||||
apps/ai-game-creator-shell/src-tauri/resources/agc-skills/
|
||||
media
|
||||
*.log
|
||||
|
||||
+1
-10
@@ -1,14 +1,5 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"overrides": [
|
||||
{
|
||||
"files": "packages/shared/src/contracts/generated/**/*.ts",
|
||||
"options": {
|
||||
"printWidth": 1000,
|
||||
"singleQuote": false
|
||||
}
|
||||
}
|
||||
]
|
||||
"trailingComma": "all"
|
||||
}
|
||||
|
||||
@@ -5,12 +5,16 @@ import {
|
||||
executeAdminRechargeRefund,
|
||||
getAdminAgcTemplates,
|
||||
getAdminFeatureGateConfig,
|
||||
getAdminGameReview,
|
||||
getAdminUserDetail,
|
||||
importAdminAgcTemplates,
|
||||
listAdminAgcTrackingEvents,
|
||||
listAdminGameDistributionGames,
|
||||
listAdminGameDistributionReviews,
|
||||
listAdminGameReviewGames,
|
||||
listAdminGameReviews,
|
||||
listAdminRechargeOrders,
|
||||
moderateAdminGameReview,
|
||||
reconcileAdminUserConsumption,
|
||||
resolveAdminRechargeRefundManualReview,
|
||||
restoreAdminGameDistributionGame,
|
||||
@@ -27,6 +31,52 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('用户评价查询编码筛选和目标,管理请求携带原创建时间及幂等key', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ ok: true, data: {} }), { status: 200 }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
await listAdminGameReviews('token', {
|
||||
gameId: 'game+1',
|
||||
userId: 'user/1',
|
||||
keyword: '中文 广告',
|
||||
status: 'hidden',
|
||||
page: 2,
|
||||
pageSize: 20,
|
||||
});
|
||||
expect(fetchMock.mock.calls[0]![0]).toBe(
|
||||
'/admin/api/game-distribution/user-reviews?gameId=game%2B1&userId=user%2F1&keyword=%E4%B8%AD%E6%96%87+%E5%B9%BF%E5%91%8A&status=hidden&page=2&pageSize=20',
|
||||
);
|
||||
await listAdminGameReviewGames('token', { query: ' 中文 ', page: 1 });
|
||||
expect(fetchMock.mock.calls[1]![0]).toBe(
|
||||
'/admin/api/game-distribution/user-review-games?query=%E4%B8%AD%E6%96%87&page=1',
|
||||
);
|
||||
await getAdminGameReview('token', '6:game/1user');
|
||||
expect(fetchMock.mock.calls[2]![0]).toBe(
|
||||
'/admin/api/game-distribution/user-reviews/6%3Agame%2F1user',
|
||||
);
|
||||
const body = {
|
||||
action: 'delete' as const,
|
||||
expectedCreatedAt: '2026-10-01T00:00:00Z',
|
||||
reason: '广告',
|
||||
};
|
||||
await moderateAdminGameReview('token', '6:game/1user', 'same-attempt', body);
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(
|
||||
'/admin/api/game-distribution/user-reviews/6%3Agame%2F1user/moderation',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer token',
|
||||
'Idempotency-Key': 'same-attempt',
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('客户端埋点查询传递筛选和游标并复用后台认证', async () => {
|
||||
const payload = { entries: [], nextCursor: null };
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
@@ -568,7 +618,7 @@ test('游戏管理列表与恢复动作使用约定的 URL、方法和幂等键'
|
||||
);
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'/admin/api/game-distribution/games?limit=50',
|
||||
'/admin/api/game-distribution/games?limit=80',
|
||||
);
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -606,6 +656,32 @@ test('游戏管理列表与恢复动作使用约定的 URL、方法和幂等键'
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('游戏管理列表把筛选与游标编码进查询串', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({ ok: true, data: { games: [], nextCursor: null } }),
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await listAdminGameDistributionGames('admin-token', {
|
||||
limit: 20,
|
||||
keyword: ' 拼图 ',
|
||||
owner: 'user_9',
|
||||
status: 'suspended',
|
||||
cursor: '1710000000000:game_1',
|
||||
});
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
'/admin/api/game-distribution/games?limit=20&keyword=%E6%8B%BC%E5%9B%BE&owner=user_9&status=suspended&cursor=1710000000000%3Agame_1',
|
||||
);
|
||||
});
|
||||
|
||||
test('安全下架请求携带公开修订号、原因与幂等键', async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
|
||||
@@ -33,15 +33,26 @@ import type {
|
||||
AdminExternalApiKeyListResponse,
|
||||
AdminFeatureGateConfigResponse,
|
||||
AdminGameDistributionGameListResponse,
|
||||
AdminGameDistributionGameStatus,
|
||||
AdminGameDistributionPreviewSessionResponse,
|
||||
AdminGameDistributionRestoreRequest,
|
||||
AdminGameDistributionRestoreResponse,
|
||||
AdminGameDistributionReviewListResponse,
|
||||
AdminGameDistributionReviewRequest,
|
||||
AdminGameDistributionReviewResponse,
|
||||
AdminGameDistributionVersionDetailResponse,
|
||||
AdminGameReviewDetailResponse,
|
||||
AdminGameReviewGamesQuery,
|
||||
AdminGameReviewGamesResponse,
|
||||
AdminGameReviewModerationRequest,
|
||||
AdminGameReviewModerationResponse,
|
||||
AdminGameReviewsQuery,
|
||||
AdminGameReviewsResponse,
|
||||
AdminImportAgcTemplatesResponse,
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
AdminOverviewResponse,
|
||||
AdminPaymentOrderListResponse,
|
||||
AdminProjectSnapshotChannelsResponse,
|
||||
AdminProjectSnapshotListQuery,
|
||||
AdminProjectSnapshotListResponse,
|
||||
@@ -794,6 +805,12 @@ export function listAdminRechargeOrders(
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminPaymentOrders(token: string) {
|
||||
return request<AdminPaymentOrderListResponse>('/admin/api/payment/orders', {
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export function getAdminUserDetail(token: string, query: AdminUserDetailQuery) {
|
||||
return request<AdminUserDetailResponse>(
|
||||
`/admin/api/profile/users/detail${buildAdminUserDetailQuery(query)}`,
|
||||
@@ -1243,17 +1260,117 @@ export function listAdminGameDistributionReviews(token: string, limit = 48) {
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminGameDistributionVersion(
|
||||
token: string,
|
||||
versionId: string,
|
||||
) {
|
||||
return request<AdminGameDistributionVersionDetailResponse>(
|
||||
`/admin/api/game-distribution/versions/${encodeURIComponent(versionId)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function createAdminGameDistributionPreviewSession(
|
||||
token: string,
|
||||
versionId: string,
|
||||
) {
|
||||
return request<AdminGameDistributionPreviewSessionResponse>(
|
||||
`/admin/api/game-distribution/versions/${encodeURIComponent(versionId)}/preview-session`,
|
||||
{ token, method: 'POST' },
|
||||
).then((response) => ({
|
||||
...response,
|
||||
previewUrl: new URL(
|
||||
response.previewUrl,
|
||||
ADMIN_API_BASE_URL || window.location.origin,
|
||||
).toString(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台作品管理列表。
|
||||
*
|
||||
* 关键词、作者与状态过滤以及游标分页都由服务端执行:前端只透传查询参数,
|
||||
* 不本地过滤,否则分页边界会与 `nextCursor` 的语义打架。
|
||||
*/
|
||||
|
||||
function gameReviewQuery(
|
||||
query: AdminGameReviewGamesQuery | AdminGameReviewsQuery,
|
||||
) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && String(value).trim())
|
||||
params.set(key, String(value).trim());
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export function listAdminGameReviewGames(
|
||||
token: string,
|
||||
query: AdminGameReviewGamesQuery = {},
|
||||
) {
|
||||
return request<AdminGameReviewGamesResponse>(
|
||||
`/admin/api/game-distribution/user-review-games?${gameReviewQuery(query)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminGameReviews(
|
||||
token: string,
|
||||
query: AdminGameReviewsQuery = {},
|
||||
) {
|
||||
return request<AdminGameReviewsResponse>(
|
||||
`/admin/api/game-distribution/user-reviews?${gameReviewQuery(query)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminGameReview(token: string, reviewId: string) {
|
||||
return request<AdminGameReviewDetailResponse>(
|
||||
`/admin/api/game-distribution/user-reviews/${encodeURIComponent(reviewId)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function moderateAdminGameReview(
|
||||
token: string,
|
||||
reviewId: string,
|
||||
idempotencyKey: string,
|
||||
payload: AdminGameReviewModerationRequest,
|
||||
) {
|
||||
return request<AdminGameReviewModerationResponse>(
|
||||
`/admin/api/game-distribution/user-reviews/${encodeURIComponent(reviewId)}/moderation`,
|
||||
{
|
||||
token,
|
||||
method: 'POST',
|
||||
headers: { 'Idempotency-Key': idempotencyKey },
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
export function listAdminGameDistributionGames(
|
||||
token: string,
|
||||
options: { limit?: number } = {},
|
||||
options: {
|
||||
limit?: number;
|
||||
keyword?: string;
|
||||
owner?: string;
|
||||
status?: AdminGameDistributionGameStatus | '';
|
||||
cursor?: string | null;
|
||||
} = {},
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const requestedLimit = options.limit ?? 50;
|
||||
const normalizedLimit = Number.isFinite(requestedLimit)
|
||||
? Math.min(Math.max(Math.trunc(requestedLimit), 1), 50)
|
||||
? Math.min(Math.max(Math.trunc(requestedLimit), 1), 200)
|
||||
: 50;
|
||||
const params = new URLSearchParams({ limit: String(normalizedLimit) });
|
||||
const keyword = options.keyword?.trim();
|
||||
if (keyword) params.set('keyword', keyword);
|
||||
const owner = options.owner?.trim();
|
||||
if (owner) params.set('owner', owner);
|
||||
if (options.status) params.set('status', options.status);
|
||||
if (options.cursor) params.set('cursor', options.cursor);
|
||||
return request<AdminGameDistributionGameListResponse>(
|
||||
`/admin/api/game-distribution/games?limit=${normalizedLimit}`,
|
||||
`/admin/api/game-distribution/games?${params.toString()}`,
|
||||
{ token, signal },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1054,6 +1054,39 @@ export interface AdminWechatPaymentCheckPayload {
|
||||
knownRefundsRefreshed: number;
|
||||
}
|
||||
|
||||
export interface AdminPaymentOrderEntry {
|
||||
orderId: string;
|
||||
appId: string;
|
||||
ownerUserId: string;
|
||||
merchantOrderId: string;
|
||||
title: string;
|
||||
amountCents: number;
|
||||
currency: string;
|
||||
provider: string;
|
||||
providerTradeNo: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
paidAt: string | null;
|
||||
}
|
||||
|
||||
export interface AdminPaymentOrderListResponse {
|
||||
entries: AdminPaymentOrderEntry[];
|
||||
webhooks: AdminPaymentWebhookEntry[];
|
||||
}
|
||||
|
||||
export interface AdminPaymentWebhookEntry {
|
||||
deliveryId: string;
|
||||
orderId: string;
|
||||
appId: string;
|
||||
callbackUrl: string;
|
||||
status: string;
|
||||
attemptCount: number;
|
||||
availableAt: string;
|
||||
lastErrorMessage: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminRechargeRefundPreviewResponse {
|
||||
order: AdminRechargeOrderEntryPayload;
|
||||
paymentCheck: AdminWechatPaymentCheckPayload;
|
||||
@@ -1076,6 +1109,10 @@ export interface AdminWalletRestrictionResponse {
|
||||
wallet: AdminProfileWalletPayload;
|
||||
}
|
||||
export type AdminAgcAgentMode = 'codex' | 'cc';
|
||||
export type AdminAgcModelProtocol =
|
||||
| 'openai_responses'
|
||||
| 'openai_chat'
|
||||
| 'anthropic';
|
||||
|
||||
export interface AdminAgcModel {
|
||||
id: string;
|
||||
@@ -1083,6 +1120,8 @@ export interface AdminAgcModel {
|
||||
modelId: string;
|
||||
enabled: boolean;
|
||||
agentMode: AdminAgcAgentMode;
|
||||
/** 上游线协议,与执行器正交。 */
|
||||
protocol?: AdminAgcModelProtocol;
|
||||
}
|
||||
|
||||
export interface AdminAgcModelCatalog {
|
||||
@@ -1104,6 +1143,52 @@ export interface AdminGameDistributionReviewEntry {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminGameDistributionGameDetail {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
description: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
coverObjectKey: string | null;
|
||||
screenshots: string[];
|
||||
author: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
deviceSupport: {
|
||||
desktop: boolean;
|
||||
mobile: boolean;
|
||||
touch: boolean;
|
||||
};
|
||||
inputModes: string[];
|
||||
orientation: string;
|
||||
status: string;
|
||||
publicationRevision: number;
|
||||
playCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AdminGameDistributionVersionDetail
|
||||
extends AdminGameDistributionReviewEntry {
|
||||
packageFileCount: number;
|
||||
entryUrl: string | null;
|
||||
recoveryAction: string;
|
||||
frozenMetadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface AdminGameDistributionVersionDetailResponse {
|
||||
game: AdminGameDistributionGameDetail;
|
||||
version: AdminGameDistributionVersionDetail;
|
||||
}
|
||||
|
||||
export interface AdminGameDistributionPreviewSessionResponse {
|
||||
previewUrl: string;
|
||||
expiresAt: string;
|
||||
versionId: string;
|
||||
}
|
||||
|
||||
export interface AdminGameDistributionReviewListResponse {
|
||||
entries: AdminGameDistributionReviewEntry[];
|
||||
nextCursor: string | null;
|
||||
@@ -1158,18 +1243,29 @@ export interface AdminGameDistributionGameEntry {
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
status: string;
|
||||
status: AdminGameDistributionGameStatus;
|
||||
versionCount: number;
|
||||
playCount: number;
|
||||
activeVersionId: string | null;
|
||||
publicationRevision: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** 作者软删除时间;为空表示未删除。 */
|
||||
deletedAt: string | null;
|
||||
versions: AdminGameDistributionGameVersionEntry[];
|
||||
}
|
||||
|
||||
/** 后台作品列表的状态:游戏可见性 + 作者软删除。 */
|
||||
export type AdminGameDistributionGameStatus =
|
||||
| 'published'
|
||||
| 'unpublished'
|
||||
| 'suspended'
|
||||
| 'deleted';
|
||||
|
||||
export interface AdminGameDistributionGameListResponse {
|
||||
games: AdminGameDistributionGameEntry[];
|
||||
/** 还有下一页时为下一页游标;旧响应没有该字段,按空值处理。 */
|
||||
nextCursor?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminGameDistributionRestoreRequest {
|
||||
@@ -1186,6 +1282,90 @@ export interface AdminGameDistributionRestoreResponse {
|
||||
replayed: boolean;
|
||||
}
|
||||
|
||||
/** 游戏用户评价管理,与发行版本审核分开。 */
|
||||
export interface AdminGameReviewGamesQuery {
|
||||
query?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewGame {
|
||||
gameId: string;
|
||||
title: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewGamesResponse {
|
||||
games: AdminGameReviewGame[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewsQuery {
|
||||
gameId?: string;
|
||||
userId?: string;
|
||||
keyword?: string;
|
||||
status?: 'all' | 'visible' | 'hidden';
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewGameInfo {
|
||||
title: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface AdminGameReview {
|
||||
id: string;
|
||||
gameId: string;
|
||||
game: AdminGameReviewGameInfo;
|
||||
author: { id: string; name: string; avatarUrl?: string | null };
|
||||
score: number;
|
||||
comment: string;
|
||||
isHidden: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewOperation {
|
||||
id: string;
|
||||
reviewId: string;
|
||||
gameId: string;
|
||||
userId: string;
|
||||
reviewCreatedAt: string;
|
||||
action: string;
|
||||
adminUserId: string;
|
||||
reason: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewsResponse {
|
||||
reviews: AdminGameReview[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewDetailResponse {
|
||||
review: AdminGameReview;
|
||||
operations: AdminGameReviewOperation[];
|
||||
}
|
||||
|
||||
export interface AdminGameReviewModerationRequest {
|
||||
action: 'hide' | 'restore' | 'delete';
|
||||
expectedCreatedAt: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface AdminGameReviewModerationResponse {
|
||||
review: AdminGameReview | null;
|
||||
operation: AdminGameReviewOperation;
|
||||
replayed: boolean;
|
||||
}
|
||||
|
||||
export interface AdminAgcTemplatePayload {
|
||||
id: string;
|
||||
title: string;
|
||||
|
||||
@@ -31,10 +31,12 @@ import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseRevie
|
||||
import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
|
||||
import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage';
|
||||
import { AdminGameManagementPage } from '../pages/AdminGameManagementPage';
|
||||
import { AdminGameReviewsPage } from '../pages/AdminGameReviewsPage';
|
||||
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
|
||||
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
|
||||
import { AdminLoginPage } from '../pages/AdminLoginPage';
|
||||
import { AdminOverviewPage } from '../pages/AdminOverviewPage';
|
||||
import { AdminPaymentOrderPage } from '../pages/AdminPaymentOrderPage';
|
||||
import { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage';
|
||||
import { AdminProjectSnapshotsPage } from '../pages/AdminProjectSnapshotsPage';
|
||||
import { AdminRechargeOrderPage } from '../pages/AdminRechargeOrderPage';
|
||||
@@ -141,7 +143,7 @@ export function AdminApp() {
|
||||
|
||||
setRouteId(nextRouteId);
|
||||
const nextHash = routeHash(nextRouteId);
|
||||
if (window.location.hash !== nextHash) {
|
||||
if ((window.location.hash.split('?')[0] ?? '').toLowerCase() !== nextHash) {
|
||||
window.history.replaceState(null, '', nextHash);
|
||||
}
|
||||
}, [accessibleRoutes, admin, routeId, status]);
|
||||
@@ -296,6 +298,12 @@ export function AdminApp() {
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'payment-orders' ? (
|
||||
<AdminPaymentOrderPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'editor-generation-pricing' ? (
|
||||
<AdminEditorGenerationPricingPage
|
||||
token={token}
|
||||
@@ -325,6 +333,15 @@ export function AdminApp() {
|
||||
) : null}
|
||||
{activeRouteId === 'game-management' ? (
|
||||
<AdminGameManagementPage
|
||||
token={token}
|
||||
canManageReviews={accessibleRoutes.some(
|
||||
(route) => route.id === 'game-reviews',
|
||||
)}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'game-reviews' ? (
|
||||
<AdminGameReviewsPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { AdminSessionPayload } from '../api/adminApiTypes';
|
||||
import { adminRoutes } from './adminRoutes';
|
||||
import { AdminShell } from './AdminShell';
|
||||
|
||||
const admin: AdminSessionPayload = {
|
||||
subject: 'admin-1',
|
||||
username: 'owner',
|
||||
displayName: '后台管理员',
|
||||
roles: ['owner'],
|
||||
accountRole: 'owner',
|
||||
tabPermissions: [],
|
||||
actionPermissions: [],
|
||||
issuedAt: '2026-10-04T00:00:00.000Z',
|
||||
expiresAt: '2026-10-05T00:00:00.000Z',
|
||||
};
|
||||
|
||||
test('侧边栏默认展开当前页分组并支持折叠其它分组', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRouteChange = vi.fn();
|
||||
const view = render(
|
||||
<AdminShell
|
||||
admin={admin}
|
||||
routeId="dashboard"
|
||||
routes={adminRoutes}
|
||||
onLogout={vi.fn()}
|
||||
onRouteChange={onRouteChange}
|
||||
>
|
||||
<div>页面内容</div>
|
||||
</AdminShell>,
|
||||
);
|
||||
|
||||
const sidebar = screen.getAllByRole('navigation', { name: '后台导航' })[0]!;
|
||||
const workspaceToggle = within(sidebar).getByRole('button', {
|
||||
name: /工作台/,
|
||||
});
|
||||
const observabilityToggle = within(sidebar).getByRole('button', {
|
||||
name: /数据与监控/,
|
||||
});
|
||||
|
||||
expect(workspaceToggle.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(observabilityToggle.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(
|
||||
within(sidebar).getByRole('button', { name: '服务总览' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(sidebar).queryByRole('button', { name: '埋点数据' }),
|
||||
).toBeNull();
|
||||
|
||||
await user.click(observabilityToggle);
|
||||
expect(observabilityToggle.getAttribute('aria-expanded')).toBe('true');
|
||||
await user.click(within(sidebar).getByRole('button', { name: '埋点数据' }));
|
||||
expect(onRouteChange).toHaveBeenCalledWith('tracking');
|
||||
|
||||
await user.click(observabilityToggle);
|
||||
expect(observabilityToggle.getAttribute('aria-expanded')).toBe('false');
|
||||
view.rerender(
|
||||
<AdminShell
|
||||
admin={admin}
|
||||
routeId="tracking"
|
||||
routes={adminRoutes}
|
||||
onLogout={vi.fn()}
|
||||
onRouteChange={onRouteChange}
|
||||
>
|
||||
<div>页面内容</div>
|
||||
</AdminShell>,
|
||||
);
|
||||
expect(
|
||||
within(screen.getAllByRole('navigation', { name: '后台导航' })[0]!)
|
||||
.getByRole('button', { name: /数据与监控/ })
|
||||
.getAttribute('aria-expanded'),
|
||||
).toBe('true');
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Activity,
|
||||
BadgeDollarSign,
|
||||
Bug,
|
||||
ChevronDown,
|
||||
Coins,
|
||||
Database,
|
||||
FolderArchive,
|
||||
@@ -22,10 +23,15 @@ import {
|
||||
Users,
|
||||
WalletCards,
|
||||
} from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
|
||||
import type { AdminSessionPayload } from '../api/adminApiTypes';
|
||||
import type { AdminRouteDefinition, AdminRouteId } from './adminRoutes';
|
||||
import {
|
||||
type AdminNavGroupId,
|
||||
type AdminRouteDefinition,
|
||||
type AdminRouteId,
|
||||
getAdminNavGroups,
|
||||
} from './adminRoutes';
|
||||
|
||||
interface AdminShellProps {
|
||||
admin: AdminSessionPayload;
|
||||
@@ -51,10 +57,12 @@ const routeIcons = {
|
||||
tasks: ListChecks,
|
||||
'recharge-products': BadgeDollarSign,
|
||||
'recharge-orders': ReceiptText,
|
||||
'payment-orders': ReceiptText,
|
||||
'editor-generation-pricing': Coins,
|
||||
'editor-showcase': Star,
|
||||
'game-distribution': Gamepad2,
|
||||
'game-management': Swords,
|
||||
'game-reviews': Star,
|
||||
'editor-assets': Images,
|
||||
'project-snapshots': FolderArchive,
|
||||
accounts: Users,
|
||||
@@ -70,6 +78,67 @@ export function AdminShell({
|
||||
onRouteChange,
|
||||
onLogout,
|
||||
}: AdminShellProps) {
|
||||
const navGroups = getAdminNavGroups(routes);
|
||||
const activeGroupId =
|
||||
navGroups.find((group) =>
|
||||
group.routes.some((route) => route.id === routeId),
|
||||
)?.id ?? navGroups[0]?.id;
|
||||
const [collapsedGroupIds, setCollapsedGroupIds] = useState<
|
||||
Set<AdminNavGroupId>
|
||||
>(() => {
|
||||
return new Set(
|
||||
navGroups
|
||||
.map((group) => group.id)
|
||||
.filter((groupId) => groupId !== activeGroupId),
|
||||
);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeGroupId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCollapsedGroupIds((current) => {
|
||||
if (!current.has(activeGroupId)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const next = new Set(current);
|
||||
next.delete(activeGroupId);
|
||||
return next;
|
||||
});
|
||||
}, [activeGroupId, routeId]);
|
||||
|
||||
const toggleGroup = (groupId: AdminNavGroupId) => {
|
||||
setCollapsedGroupIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(groupId)) {
|
||||
next.delete(groupId);
|
||||
} else {
|
||||
next.add(groupId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const renderRouteButton = (route: AdminRouteDefinition) => {
|
||||
const Icon = routeIcons[route.id];
|
||||
return (
|
||||
<button
|
||||
className="admin-nav-button"
|
||||
aria-current={route.id === routeId ? 'page' : undefined}
|
||||
data-active={route.id === routeId}
|
||||
key={route.id}
|
||||
title={route.label}
|
||||
type="button"
|
||||
onClick={() => onRouteChange(route.id)}
|
||||
>
|
||||
<Icon size={18} aria-hidden="true" />
|
||||
<span>{route.label}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-shell">
|
||||
<aside className="admin-sidebar">
|
||||
@@ -84,20 +153,35 @@ export function AdminShell({
|
||||
</div>
|
||||
|
||||
<nav className="admin-nav" aria-label="后台导航">
|
||||
{routes.map((route) => {
|
||||
const Icon = routeIcons[route.id];
|
||||
{navGroups.map((group) => {
|
||||
const isCollapsed = collapsedGroupIds.has(group.id);
|
||||
const groupItemsId = `admin-nav-group-${group.id}`;
|
||||
return (
|
||||
<button
|
||||
className="admin-nav-button"
|
||||
data-active={route.id === routeId}
|
||||
key={route.id}
|
||||
title={route.label}
|
||||
type="button"
|
||||
onClick={() => onRouteChange(route.id)}
|
||||
<div
|
||||
className="admin-nav-group"
|
||||
data-collapsed={isCollapsed}
|
||||
key={group.id}
|
||||
>
|
||||
<Icon size={18} aria-hidden="true" />
|
||||
<span>{route.label}</span>
|
||||
</button>
|
||||
<button
|
||||
aria-controls={groupItemsId}
|
||||
aria-expanded={!isCollapsed}
|
||||
className="admin-nav-group-toggle"
|
||||
type="button"
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
>
|
||||
<ChevronDown size={15} aria-hidden="true" />
|
||||
<span>{group.label}</span>
|
||||
<small aria-hidden="true">{group.routes.length}</small>
|
||||
</button>
|
||||
<div
|
||||
className="admin-nav-group-items"
|
||||
data-collapsed={isCollapsed}
|
||||
hidden={isCollapsed}
|
||||
id={groupItemsId}
|
||||
>
|
||||
{group.routes.map(renderRouteButton)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
@@ -3,11 +3,62 @@ import { expect, test } from 'vitest';
|
||||
import {
|
||||
adminRoutes,
|
||||
getAccessibleAdminRoutes,
|
||||
getAdminNavGroups,
|
||||
resolveAccessibleAdminRoute,
|
||||
resolveAdminRoute,
|
||||
routeHash,
|
||||
} from './adminRoutes';
|
||||
|
||||
test('后台侧边栏路由按业务分组且不丢失任何页签', () => {
|
||||
const groups = getAdminNavGroups(adminRoutes);
|
||||
const groupedRouteIds = groups.flatMap((group) =>
|
||||
group.routes.map((route) => route.id),
|
||||
);
|
||||
|
||||
expect(groups.map((group) => group.label)).toEqual([
|
||||
'工作台',
|
||||
'数据与监控',
|
||||
'运营配置',
|
||||
'充值与支付',
|
||||
'AGC 管理',
|
||||
'内容运营',
|
||||
'账号权限',
|
||||
]);
|
||||
expect(groupedRouteIds).toEqual(adminRoutes.map((route) => route.id));
|
||||
});
|
||||
|
||||
test('后台侧边栏分组只保留当前管理员可见的页签', () => {
|
||||
const groups = getAdminNavGroups(
|
||||
getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
tabPermissions: ['tracking', 'game-reviews', 'accounts'],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
groups.map((group) => [group.label, group.routes.map((route) => route.id)]),
|
||||
).toEqual([
|
||||
['数据与监控', ['tracking']],
|
||||
['内容运营', ['game-reviews']],
|
||||
]);
|
||||
});
|
||||
|
||||
test('游戏评价路由独立授权并支持游戏参数', () => {
|
||||
expect(resolveAdminRoute('#game-reviews?gameId=game_1')).toBe('game-reviews');
|
||||
expect(
|
||||
getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
tabPermissions: ['game-reviews'],
|
||||
}).map((route) => route.id),
|
||||
).toEqual(['game-reviews']);
|
||||
expect(
|
||||
getAccessibleAdminRoutes({
|
||||
accountRole: 'member',
|
||||
tabPermissions: ['game-management'],
|
||||
}).some((route) => route.id === 'game-reviews'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('客户端埋点路由遵守成员页签权限', () => {
|
||||
expect(resolveAdminRoute('#agc-tracking')).toBe('agc-tracking');
|
||||
expect(
|
||||
|
||||
@@ -14,10 +14,12 @@ export type AdminRouteId =
|
||||
| 'tasks'
|
||||
| 'recharge-products'
|
||||
| 'recharge-orders'
|
||||
| 'payment-orders'
|
||||
| 'editor-generation-pricing'
|
||||
| 'editor-showcase'
|
||||
| 'game-distribution'
|
||||
| 'game-management'
|
||||
| 'game-reviews'
|
||||
| 'editor-assets'
|
||||
| 'project-snapshots'
|
||||
| 'agc-models'
|
||||
@@ -52,6 +54,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{ id: 'tasks', label: '任务配置', hash: '#tasks' },
|
||||
{ id: 'recharge-products', label: '充值商品', hash: '#recharge-products' },
|
||||
{ id: 'recharge-orders', label: '充值管理', hash: '#recharge-orders' },
|
||||
{ id: 'payment-orders', label: '支付订单', hash: '#payment-orders' },
|
||||
{
|
||||
id: 'editor-generation-pricing',
|
||||
label: '模型定价',
|
||||
@@ -62,11 +65,97 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
|
||||
{ id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' },
|
||||
{ id: 'game-management', label: '游戏管理', hash: '#game-management' },
|
||||
{ id: 'game-reviews', label: '游戏评价', hash: '#game-reviews' },
|
||||
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
|
||||
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
|
||||
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
|
||||
];
|
||||
|
||||
/**
|
||||
* 桌面端侧边栏的导航分组。分组只影响展示顺序和折叠状态,不改变路由
|
||||
* 权限、hash 或移动端底栏的平铺顺序。
|
||||
*/
|
||||
export type AdminNavGroupId =
|
||||
| 'workspace'
|
||||
| 'observability'
|
||||
| 'operations'
|
||||
| 'billing'
|
||||
| 'content'
|
||||
| 'agc'
|
||||
| 'access';
|
||||
|
||||
export interface AdminNavGroupDefinition {
|
||||
id: AdminNavGroupId;
|
||||
label: string;
|
||||
routeIds: AdminRouteId[];
|
||||
}
|
||||
|
||||
export const adminNavGroups: AdminNavGroupDefinition[] = [
|
||||
{
|
||||
id: 'workspace',
|
||||
label: '工作台',
|
||||
routeIds: ['dashboard', 'overview', 'tables', 'debug'],
|
||||
},
|
||||
{
|
||||
id: 'observability',
|
||||
label: '数据与监控',
|
||||
routeIds: ['tracking', 'agc-tracking', 'error-reports'],
|
||||
},
|
||||
{
|
||||
id: 'operations',
|
||||
label: '运营配置',
|
||||
routeIds: ['gray-release', 'redeem', 'invite', 'profile-wallet', 'tasks'],
|
||||
},
|
||||
{
|
||||
id: 'billing',
|
||||
label: '充值与支付',
|
||||
routeIds: ['recharge-products', 'recharge-orders', 'payment-orders'],
|
||||
},
|
||||
{
|
||||
id: 'agc',
|
||||
label: 'AGC 管理',
|
||||
routeIds: ['editor-generation-pricing', 'agc-models', 'agc-templates'],
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
label: '内容运营',
|
||||
routeIds: [
|
||||
'editor-showcase',
|
||||
'game-distribution',
|
||||
'game-management',
|
||||
'game-reviews',
|
||||
'editor-assets',
|
||||
'project-snapshots',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'access',
|
||||
label: '账号权限',
|
||||
routeIds: ['accounts'],
|
||||
},
|
||||
];
|
||||
|
||||
export interface AdminNavGroup {
|
||||
id: AdminNavGroupId;
|
||||
label: string;
|
||||
routes: AdminRouteDefinition[];
|
||||
}
|
||||
|
||||
/** 按固定业务分组筛选当前管理员可见的路由。 */
|
||||
export function getAdminNavGroups(
|
||||
routes: AdminRouteDefinition[],
|
||||
): AdminNavGroup[] {
|
||||
return adminNavGroups
|
||||
.map((group) => ({
|
||||
id: group.id,
|
||||
label: group.label,
|
||||
routes: group.routeIds
|
||||
.map((routeId) => routes.find((route) => route.id === routeId))
|
||||
.filter((route): route is AdminRouteDefinition => route !== undefined),
|
||||
}))
|
||||
.filter((group) => group.routes.length > 0);
|
||||
}
|
||||
|
||||
export interface AdminRouteAccess {
|
||||
accountRole: 'owner' | 'member';
|
||||
tabPermissions: string[];
|
||||
|
||||
@@ -104,3 +104,52 @@ test('new models default to Codex and save the selected Agent mode', async () =>
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('协议与 Agent 模式是独立字段,可按模型单独选择', async () => {
|
||||
const catalog = {
|
||||
revision: 3,
|
||||
defaultModelId: 'quality',
|
||||
models: [
|
||||
{
|
||||
id: 'quality',
|
||||
alias: '高质量',
|
||||
modelId: 'claude-opus-5-5',
|
||||
enabled: true,
|
||||
agentMode: 'cc' as const,
|
||||
protocol: 'anthropic' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog);
|
||||
vi.mocked(saveAgcModelCatalog).mockImplementation(async (_, input) => ({
|
||||
...input,
|
||||
revision: 4,
|
||||
}));
|
||||
render(<AdminAgcModelsPage token="test" onUnauthorized={vi.fn()} />);
|
||||
await screen.findByDisplayValue('claude-opus-5-5');
|
||||
|
||||
const agentMode = screen.getByLabelText(
|
||||
'模型 1 Agent 模式',
|
||||
) as HTMLSelectElement;
|
||||
const protocol = screen.getByLabelText('模型 1 协议') as HTMLSelectElement;
|
||||
expect(agentMode.value).toBe('cc');
|
||||
expect(protocol.value).toBe('anthropic');
|
||||
// 执行器下拉里不再混入协议取值。
|
||||
expect(Array.from(agentMode.options).map((option) => option.value)).toEqual([
|
||||
'codex',
|
||||
'cc',
|
||||
]);
|
||||
|
||||
fireEvent.change(agentMode, { target: { value: 'codex' } });
|
||||
fireEvent.change(protocol, { target: { value: 'openai_chat' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = vi.mocked(saveAgcModelCatalog).mock.calls;
|
||||
const payload = calls[calls.length - 1]?.[1];
|
||||
expect(payload?.models[0]).toMatchObject({
|
||||
agentMode: 'codex',
|
||||
protocol: 'openai_chat',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,6 +117,7 @@ export function AdminAgcModelsPage({
|
||||
{ key: 'alias', label: '别名' },
|
||||
{ key: 'modelId', label: '实际模型名' },
|
||||
{ key: 'agentMode', label: 'Agent 模式' },
|
||||
{ key: 'protocol', label: '协议' },
|
||||
{ key: 'enabled', label: '启用' },
|
||||
{ key: 'default', label: '默认' },
|
||||
{ key: 'actions', label: '操作' },
|
||||
@@ -157,6 +158,7 @@ export function AdminAgcModelsPage({
|
||||
modelId: '',
|
||||
enabled: true,
|
||||
agentMode: 'codex',
|
||||
protocol: 'openai_responses',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -228,6 +230,22 @@ export function AdminAgcModelsPage({
|
||||
<option value="cc">Claude Code</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
aria-label={`模型 ${index + 1} 协议`}
|
||||
value={model.protocol ?? 'openai_responses'}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
update(model.id, {
|
||||
protocol: e.target.value as AdminAgcModel['protocol'],
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="openai_responses">OpenAI Responses</option>
|
||||
<option value="openai_chat">OpenAI Chat Completions</option>
|
||||
<option value="anthropic">Anthropic Messages</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`模型 ${index + 1} 启用`}
|
||||
|
||||
@@ -10,11 +10,17 @@ import {
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createAdminGameDistributionPreviewSession,
|
||||
getAdminGameDistributionVersion,
|
||||
listAdminGameDistributionGames,
|
||||
listAdminGameDistributionReviews,
|
||||
reviewAdminGameDistributionVersion,
|
||||
suspendAdminGameDistributionGame,
|
||||
} from '../api/adminApiClient';
|
||||
import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes';
|
||||
import type {
|
||||
AdminGameDistributionGameListResponse,
|
||||
AdminGameDistributionReviewEntry,
|
||||
AdminGameDistributionVersionDetailResponse,
|
||||
} from '../api/adminApiTypes';
|
||||
import { AdminGameDistributionReviewPage } from './AdminGameDistributionReviewPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
@@ -28,11 +34,12 @@ vi.mock('../api/adminApiClient', () => ({
|
||||
formatAdminApiError: vi.fn((error: unknown) =>
|
||||
error instanceof Error ? error.message : '请求失败',
|
||||
),
|
||||
createAdminGameDistributionPreviewSession: vi.fn(),
|
||||
getAdminGameDistributionVersion: vi.fn(),
|
||||
listAdminGameDistributionGames: vi.fn(),
|
||||
listAdminGameDistributionReviews: vi.fn(),
|
||||
reviewAdminGameDistributionVersion: vi.fn(),
|
||||
suspendAdminGameDistributionGame: vi.fn(),
|
||||
}));
|
||||
|
||||
const entry: AdminGameDistributionReviewEntry = {
|
||||
versionId: 'version-1',
|
||||
gameId: 'game_1',
|
||||
@@ -42,18 +49,72 @@ const entry: AdminGameDistributionReviewEntry = {
|
||||
status: 'pending_review',
|
||||
publicationRevision: 4,
|
||||
reviewReason: null,
|
||||
createdAt: '2026-09-20T08:00:00Z',
|
||||
updatedAt: '2026-09-20T08:00:00Z',
|
||||
// 真实后台下发的是 `seconds.microsZ`(非 ISO 8601),见 shared-kernel `format_timestamp_micros`。
|
||||
createdAt: '1791008678.817257Z',
|
||||
updatedAt: '1791008678.817257Z',
|
||||
};
|
||||
|
||||
/** 游戏名与作者由既有后台作品列表接口按 gameId 反查(待审列表本身不含这两个字段)。 */
|
||||
const gameTitle = '测试游戏名';
|
||||
const gameListResponse: AdminGameDistributionGameListResponse = {
|
||||
games: [
|
||||
{
|
||||
gameId: 'game_1',
|
||||
title: gameTitle,
|
||||
author: { id: 'user-9f1', name: '136****8867', avatarUrl: null },
|
||||
status: 'unpublished',
|
||||
versionCount: 1,
|
||||
playCount: 0,
|
||||
activeVersionId: null,
|
||||
publicationRevision: 4,
|
||||
createdAt: '2026-09-20T08:00:00Z',
|
||||
updatedAt: '2026-09-20T08:00:00Z',
|
||||
deletedAt: null,
|
||||
versions: [],
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
};
|
||||
|
||||
const detailResponse: AdminGameDistributionVersionDetailResponse = {
|
||||
game: {
|
||||
id: 'game_1',
|
||||
title: '测试游戏',
|
||||
summary: '测试简介',
|
||||
description: '测试详细介绍',
|
||||
category: '益智',
|
||||
tags: ['测试'],
|
||||
coverObjectKey: null,
|
||||
screenshots: [],
|
||||
author: { id: 'user-1', name: '测试作者', avatarUrl: null },
|
||||
deviceSupport: { desktop: true, mobile: false, touch: true },
|
||||
inputModes: ['keyboard'],
|
||||
orientation: 'landscape',
|
||||
status: 'unpublished',
|
||||
publicationRevision: 7,
|
||||
playCount: 0,
|
||||
createdAt: '2026-09-20T08:00:00Z',
|
||||
},
|
||||
version: {
|
||||
...entry,
|
||||
packageFileCount: 3,
|
||||
entryUrl: null,
|
||||
recoveryAction: 'wait',
|
||||
frozenMetadata: { title: '测试游戏' },
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(listAdminGameDistributionReviews).mockReset();
|
||||
vi.mocked(listAdminGameDistributionGames).mockReset();
|
||||
vi.mocked(reviewAdminGameDistributionVersion).mockReset();
|
||||
vi.mocked(suspendAdminGameDistributionGame).mockReset();
|
||||
vi.mocked(getAdminGameDistributionVersion).mockReset();
|
||||
vi.mocked(createAdminGameDistributionPreviewSession).mockReset();
|
||||
vi.mocked(listAdminGameDistributionReviews).mockResolvedValue({
|
||||
entries: [entry],
|
||||
nextCursor: null,
|
||||
});
|
||||
vi.mocked(listAdminGameDistributionGames).mockResolvedValue(gameListResponse);
|
||||
});
|
||||
|
||||
test('通过审核只提交当前 publicationRevision 并刷新列表', async () => {
|
||||
@@ -68,7 +129,7 @@ test('通过审核只提交当前 publicationRevision 并刷新列表', async ()
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText('game_1');
|
||||
await screen.findByText(gameTitle);
|
||||
|
||||
expect(screen.queryByLabelText('发行入口')).toBeNull();
|
||||
expect(screen.queryByText('通过后由系统分配发行地址')).toBeNull();
|
||||
@@ -107,7 +168,7 @@ test('点击拒绝后填写理由再提交审核接口', async () => {
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText('game_1');
|
||||
await screen.findByText(gameTitle);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '拒绝' }));
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
@@ -135,54 +196,44 @@ test('点击拒绝后填写理由再提交审核接口', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('安全下架需要先填写原因,再二次确认并携带公开修订号', async () => {
|
||||
vi.mocked(suspendAdminGameDistributionGame).mockResolvedValue({
|
||||
game: {
|
||||
id: 'game_1',
|
||||
title: '测试游戏',
|
||||
status: 'suspended',
|
||||
publicationRevision: 5,
|
||||
},
|
||||
replayed: false,
|
||||
});
|
||||
|
||||
test('待审版本行内只保留版本级审核动作,不提供游戏级安全下架', async () => {
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText('game_1');
|
||||
await screen.findByText(gameTitle);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
|
||||
const reasonDialog = await screen.findByRole('dialog');
|
||||
fireEvent.change(
|
||||
within(reasonDialog).getByRole('textbox', { name: '下架原因' }),
|
||||
{
|
||||
target: { value: '盗用素材' },
|
||||
},
|
||||
);
|
||||
fireEvent.click(
|
||||
within(reasonDialog).getByRole('button', { name: '继续下架' }),
|
||||
);
|
||||
// 「安全下架」是游戏级动作(强制下线线上版本),入口在「游戏管理」页;
|
||||
// 审核一个新版本时不该出现该动作。
|
||||
expect(screen.queryByRole('button', { name: '安全下架' })).toBeNull();
|
||||
expect(screen.queryByText('继续下架')).toBeNull();
|
||||
expect(screen.queryByLabelText('下架原因')).toBeNull();
|
||||
|
||||
await screen.findByText('确认操作');
|
||||
const confirmDialog = screen.getByRole('dialog');
|
||||
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确认' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '拒绝' }));
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
expect(within(dialog).queryByRole('button', { name: '继续下架' })).toBeNull();
|
||||
expect(within(dialog).getByLabelText('拒绝理由')).toBeTruthy();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1),
|
||||
test('提交时间列把后端的 seconds.microsZ 渲染成可读本地时间', async () => {
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText(gameTitle);
|
||||
|
||||
const row = screen.getByText(gameTitle).closest('tr');
|
||||
expect(row).not.toBeNull();
|
||||
const submittedCell = within(row as HTMLElement).getAllByRole('cell')[3];
|
||||
// 解析不出来时会回落到 `-`,这正是本次修复前的线上表现。
|
||||
expect(submittedCell?.textContent).not.toBe('-');
|
||||
expect(submittedCell?.textContent).toMatch(
|
||||
/^\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2}$/u,
|
||||
);
|
||||
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 () => {
|
||||
@@ -192,7 +243,7 @@ test('取消理由输入时不做审核操作', async () => {
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText('game_1');
|
||||
await screen.findByText(gameTitle);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '拒绝' }));
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
@@ -202,31 +253,67 @@ test('取消理由输入时不做审核操作', async () => {
|
||||
expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('取消安全下架确认时不下架', async () => {
|
||||
test('游戏列显示游戏名与作者,不再把 gameId 当主展示', async () => {
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText('game_1');
|
||||
await screen.findByText(gameTitle);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
|
||||
const reasonDialog = await screen.findByRole('dialog');
|
||||
fireEvent.change(
|
||||
within(reasonDialog).getByRole('textbox', { name: '下架原因' }),
|
||||
{
|
||||
target: { value: '盗用素材' },
|
||||
},
|
||||
);
|
||||
fireEvent.click(
|
||||
within(reasonDialog).getByRole('button', { name: '继续下架' }),
|
||||
// 按 gameId 反查一次,第二次刷新走缓存(本次只加载一次列表)。
|
||||
expect(listAdminGameDistributionGames).toHaveBeenCalledWith(
|
||||
'admin-token',
|
||||
expect.objectContaining({ keyword: 'game_1' }),
|
||||
);
|
||||
|
||||
await screen.findByText('确认操作');
|
||||
const confirmDialog = screen.getByRole('dialog');
|
||||
fireEvent.click(within(confirmDialog).getByRole('button', { name: '取消' }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
|
||||
const row = screen.getByText(gameTitle).closest('tr');
|
||||
expect(row).not.toBeNull();
|
||||
const gameCell = within(row as HTMLElement).getAllByRole('cell')[0];
|
||||
// 主展示是游戏名 + 打码作者名;gameId 只留在悬浮标题里。
|
||||
expect(gameCell?.textContent).toContain('136****8867');
|
||||
expect(gameCell?.textContent).not.toContain('game_1');
|
||||
expect(screen.getByTitle('user-9f1').textContent).toBe('user-9f1');
|
||||
expect(gameCell?.querySelector('[title="game_1"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('详情展示发布者和冻结资料,并试玩当前待审版本', async () => {
|
||||
vi.mocked(getAdminGameDistributionVersion).mockResolvedValue(detailResponse);
|
||||
vi.mocked(createAdminGameDistributionPreviewSession).mockResolvedValue({
|
||||
previewUrl:
|
||||
'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-1/',
|
||||
expiresAt: '2026-10-02T12:10:00Z',
|
||||
versionId: 'version-1',
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminGameDistributionReviewPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText(gameTitle);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '详情' }));
|
||||
expect((await screen.findAllByText(/测试作者/u)).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('测试详细介绍')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '试玩当前待审版本' }));
|
||||
const frame = await screen.findByTitle('测试游戏 待审版本试玩');
|
||||
expect(frame.getAttribute('src')).toBe(
|
||||
'http://127.0.0.1:8082/api/game-distribution/admin-previews/preview-1/',
|
||||
);
|
||||
expect(frame.getAttribute('sandbox')).toBe('allow-scripts');
|
||||
fireEvent.click(screen.getByRole('button', { name: '通过' }));
|
||||
await waitFor(() =>
|
||||
expect(reviewAdminGameDistributionVersion).toHaveBeenCalledWith(
|
||||
'admin-token',
|
||||
'version-1',
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
decision: 'approve',
|
||||
expectedPublicationRevision: 7,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,6 +68,7 @@ const publishedGame: AdminGameDistributionGameEntry = {
|
||||
publicationRevision: 4,
|
||||
createdAt: '2026-09-18T08:00:00Z',
|
||||
updatedAt: '2026-09-20T10:00:00Z',
|
||||
deletedAt: null,
|
||||
versions: [version],
|
||||
};
|
||||
|
||||
@@ -86,6 +87,26 @@ const suspendedGame: AdminGameDistributionGameEntry = {
|
||||
publicationRevision: 7,
|
||||
createdAt: '2026-09-19T08:00:00Z',
|
||||
updatedAt: '2026-09-21T08:00:00Z',
|
||||
deletedAt: null,
|
||||
versions: [],
|
||||
};
|
||||
|
||||
const deletedGame: AdminGameDistributionGameEntry = {
|
||||
gameId: 'game_3',
|
||||
title: '已删除作品',
|
||||
author: {
|
||||
id: 'user_3',
|
||||
name: '作者丙',
|
||||
avatarUrl: null,
|
||||
},
|
||||
status: 'deleted',
|
||||
versionCount: 1,
|
||||
playCount: 3,
|
||||
activeVersionId: null,
|
||||
publicationRevision: 2,
|
||||
createdAt: '2026-09-17T08:00:00Z',
|
||||
updatedAt: '2026-09-22T08:00:00Z',
|
||||
deletedAt: '2026-09-22T08:00:00Z',
|
||||
versions: [],
|
||||
};
|
||||
|
||||
@@ -117,6 +138,20 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
test('有评价权限时游戏管理提供带gameId的评价入口', async () => {
|
||||
window.history.replaceState(null, '', '#game-management');
|
||||
render(
|
||||
<AdminGameManagementPage
|
||||
token="token"
|
||||
canManageReviews
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '查看评价' }));
|
||||
expect(window.location.hash).toBe('#game-reviews?gameId=game_1');
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
@@ -183,20 +218,62 @@ test('恢复按钮只在下架态出现,成功后携带幂等键并刷新列
|
||||
expect(await screen.findByText('游戏《下架游戏》已恢复')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('下架需要原因和二次确认,提交原因与当前公开修订号', async () => {
|
||||
test('下架原因在独立面板内填写:空原因不提交并给出提示', async () => {
|
||||
render(
|
||||
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByText('测试游戏');
|
||||
fireEvent.change(screen.getByLabelText('下架原因'), {
|
||||
target: { value: '违规内容' },
|
||||
});
|
||||
// 操作列不再内嵌输入框,点按钮才打开面板。
|
||||
expect(screen.queryByLabelText('下架原因')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '下架' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '下架游戏' });
|
||||
const reasonInput = within(dialog).getByLabelText('下架原因');
|
||||
expect((reasonInput as HTMLTextAreaElement).value).toBe('');
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认下架' }));
|
||||
|
||||
expect(await within(dialog).findByText('请填写下架原因')).toBeTruthy();
|
||||
expect(reasonInput.getAttribute('aria-invalid')).toBe('true');
|
||||
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
|
||||
await screen.findByRole('dialog');
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认' }));
|
||||
expect(screen.queryByRole('dialog', { name: '确认操作' })).toBeNull();
|
||||
});
|
||||
|
||||
test('下架面板取消不触发请求', async () => {
|
||||
render(
|
||||
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByText('测试游戏');
|
||||
fireEvent.click(screen.getByRole('button', { name: '下架' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: '下架游戏' });
|
||||
fireEvent.change(within(dialog).getByLabelText('下架原因'), {
|
||||
target: { value: '违规内容' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('下架面板确认后仍需二次确认,提交原因与当前公开修订号', async () => {
|
||||
render(
|
||||
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByText('测试游戏');
|
||||
fireEvent.click(screen.getByRole('button', { name: '下架' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: '下架游戏' });
|
||||
fireEvent.change(within(dialog).getByLabelText('下架原因'), {
|
||||
target: { value: '违规内容' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认下架' }));
|
||||
|
||||
// 面板确认只进入原有的写入二次确认,此时还不能发请求。
|
||||
const confirmDialog = await screen.findByRole('dialog', { name: '确认操作' });
|
||||
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
|
||||
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1),
|
||||
@@ -263,3 +340,92 @@ test('401 交给 onUnauthorized 处理', async () => {
|
||||
);
|
||||
expect(screen.queryByRole('alert')).toBeNull();
|
||||
});
|
||||
|
||||
test('筛选条件按服务端口径透传,重置回到无条件第一页', async () => {
|
||||
render(
|
||||
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByText('测试游戏');
|
||||
fireEvent.change(screen.getByPlaceholderText('标题、gameId 或作者 ID'), {
|
||||
target: { value: ' 拼图 ' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText('作者 user ID'), {
|
||||
target: { value: 'user_9' },
|
||||
});
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '状态' }), {
|
||||
target: { value: 'suspended' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameDistributionGames).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
const [, applied] = vi.mocked(listAdminGameDistributionGames).mock.calls[1]!;
|
||||
expect(applied).toEqual({
|
||||
limit: 50,
|
||||
cursor: null,
|
||||
keyword: '拼图',
|
||||
owner: 'user_9',
|
||||
status: 'suspended',
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '重置' }));
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameDistributionGames).toHaveBeenCalledTimes(3),
|
||||
);
|
||||
const [, reset] = vi.mocked(listAdminGameDistributionGames).mock.calls[2]!;
|
||||
expect(reset).toEqual({
|
||||
limit: 50,
|
||||
cursor: null,
|
||||
keyword: '',
|
||||
owner: '',
|
||||
status: '',
|
||||
});
|
||||
expect(
|
||||
(screen.getByPlaceholderText('标题、gameId 或作者 ID') as HTMLInputElement)
|
||||
.value,
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
test('游标分页把上一页返回的 nextCursor 传给下一页', async () => {
|
||||
vi.mocked(listAdminGameDistributionGames)
|
||||
.mockResolvedValueOnce({ games: [publishedGame], nextCursor: 'cursor-1' })
|
||||
.mockResolvedValueOnce({ games: [suspendedGame], nextCursor: null });
|
||||
|
||||
render(
|
||||
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
await screen.findByText('测试游戏');
|
||||
const nextButton = screen.getByRole('button', { name: '下一页' });
|
||||
expect((nextButton as HTMLButtonElement).disabled).toBe(false);
|
||||
fireEvent.click(nextButton);
|
||||
|
||||
await screen.findByText('下架游戏');
|
||||
expect(screen.queryByText('测试游戏')).toBeNull();
|
||||
const [, secondPage] = vi.mocked(listAdminGameDistributionGames).mock
|
||||
.calls[1]!;
|
||||
expect(secondPage?.cursor).toBe('cursor-1');
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '下一页' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('已删除作品显示删除时间且不再提供下架与恢复', async () => {
|
||||
vi.mocked(listAdminGameDistributionGames).mockResolvedValue({
|
||||
games: [deletedGame],
|
||||
});
|
||||
|
||||
render(
|
||||
<AdminGameManagementPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
|
||||
const row = (await screen.findByText('已删除作品')).closest('tr')!;
|
||||
expect(within(row).getByText('已删除')).toBeTruthy();
|
||||
expect(within(row).getByText(/删除于/)).toBeTruthy();
|
||||
expect(within(row).queryByRole('button', { name: '下架' })).toBeNull();
|
||||
expect(within(row).queryByRole('button', { name: '恢复' })).toBeNull();
|
||||
expect(within(row).getByRole('button', { name: '版本历史' })).toBeTruthy();
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,441 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getAdminGameReview,
|
||||
listAdminGameReviewGames,
|
||||
listAdminGameReviews,
|
||||
moderateAdminGameReview,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminGameReview,
|
||||
AdminGameReviewDetailResponse,
|
||||
AdminGameReviewsResponse,
|
||||
} from '../api/adminApiTypes';
|
||||
import { AdminGameReviewsPage } from './AdminGameReviewsPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
listAdminGameReviews: vi.fn(),
|
||||
listAdminGameReviewGames: vi.fn(),
|
||||
getAdminGameReview: vi.fn(),
|
||||
moderateAdminGameReview: vi.fn(),
|
||||
isAdminApiError: (error: unknown) =>
|
||||
typeof error === 'object' && error !== null && 'status' in error,
|
||||
formatAdminApiError: (error: unknown) =>
|
||||
error instanceof Error ? error.message : '请求失败',
|
||||
}));
|
||||
|
||||
const review: AdminGameReview = {
|
||||
id: '6:game_1user_1',
|
||||
gameId: 'game_1',
|
||||
game: { title: '测试游戏', status: 'published' },
|
||||
author: { id: 'user_1', name: '用户甲' },
|
||||
score: 8,
|
||||
comment: '<b>中文😀</b>\n完整正文',
|
||||
isHidden: false,
|
||||
createdAt: '2026-10-01T01:00:00Z',
|
||||
updatedAt: '2026-10-01T01:00:00Z',
|
||||
};
|
||||
function list(
|
||||
overrides: Partial<AdminGameReviewsResponse> = {},
|
||||
): AdminGameReviewsResponse {
|
||||
return {
|
||||
reviews: [review],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
totalPages: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
const unauthorized = vi.fn();
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
window.history.replaceState(null, '', '#game-reviews');
|
||||
vi.mocked(listAdminGameReviews).mockResolvedValue(list());
|
||||
vi.mocked(listAdminGameReviewGames).mockResolvedValue({
|
||||
games: [{ gameId: 'game_2', title: '第二个游戏', status: 'suspended' }],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
totalPages: 1,
|
||||
});
|
||||
vi.mocked(getAdminGameReview).mockResolvedValue({ review, operations: [] });
|
||||
vi.mocked(moderateAdminGameReview).mockResolvedValue({
|
||||
review: { ...review, isHidden: true },
|
||||
operation: {
|
||||
id: 'op',
|
||||
reviewId: review.id,
|
||||
gameId: review.gameId,
|
||||
userId: review.author.id,
|
||||
reviewCreatedAt: review.createdAt,
|
||||
action: 'hide',
|
||||
adminUserId: 'admin',
|
||||
reason: '广告',
|
||||
createdAt: review.createdAt,
|
||||
},
|
||||
replayed: false,
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
function show() {
|
||||
return render(
|
||||
<AdminGameReviewsPage token="token" onUnauthorized={unauthorized} />,
|
||||
);
|
||||
}
|
||||
async function row() {
|
||||
return (await screen.findByText('用户甲')).closest('tr')!;
|
||||
}
|
||||
|
||||
test('入口游戏参数和hash变化生效,组合筛选分页在服务端查询,重置回第一页', async () => {
|
||||
window.history.replaceState(null, '', '#game-reviews?gameId=game_1');
|
||||
vi.mocked(listAdminGameReviews).mockResolvedValue(
|
||||
list({ total: 21, totalPages: 2 }),
|
||||
);
|
||||
show();
|
||||
await row();
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ gameId: 'game_1', page: 1 }),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('评价用户 ID'), {
|
||||
target: { value: 'user_1' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('评论关键词'), {
|
||||
target: { value: '中文' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('评价状态'), {
|
||||
target: { value: 'hidden' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith('token', {
|
||||
gameId: 'game_1',
|
||||
userId: 'user_1',
|
||||
keyword: '中文',
|
||||
status: 'hidden',
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
}),
|
||||
);
|
||||
await row();
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('navigation', { name: '评价分页' })).getByRole(
|
||||
'button',
|
||||
{ name: '下一页' },
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ page: 2, keyword: '中文' }),
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '重置' }));
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith('token', {
|
||||
gameId: '',
|
||||
userId: '',
|
||||
keyword: '',
|
||||
status: 'all',
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
}),
|
||||
);
|
||||
act(() => {
|
||||
window.history.replaceState(null, '', '#game-reviews?gameId=game_2');
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ gameId: 'game_2', page: 1 }),
|
||||
),
|
||||
);
|
||||
expect((screen.getByLabelText('游戏 ID') as HTMLInputElement).value).toBe(
|
||||
'game_2',
|
||||
);
|
||||
});
|
||||
|
||||
test('名称查找包含下架游戏,选择后按游戏ID筛选', async () => {
|
||||
show();
|
||||
await row();
|
||||
fireEvent.click(screen.getByRole('button', { name: '按名称查找游戏' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '查找游戏' });
|
||||
await within(dialog).findByText('第二个游戏');
|
||||
fireEvent.change(within(dialog).getByLabelText('游戏名称或 ID'), {
|
||||
target: { value: '第二个' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '搜索' }));
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviewGames).toHaveBeenLastCalledWith('token', {
|
||||
query: '第二个',
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
}),
|
||||
);
|
||||
await within(dialog).findByText('第二个游戏');
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '选择' }));
|
||||
expect((screen.getByLabelText('游戏 ID') as HTMLInputElement).value).toBe(
|
||||
'game_2',
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ gameId: 'game_2' }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('隐藏原因必填,失败保留草稿,同次重试复用幂等key,取消不写入', async () => {
|
||||
vi.mocked(moderateAdminGameReview).mockRejectedValueOnce(
|
||||
new Error('网络失败'),
|
||||
);
|
||||
show();
|
||||
fireEvent.click(within(await row()).getByRole('button', { name: '隐藏' }));
|
||||
let dialog = screen.getByRole('dialog', { name: '隐藏评价' });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认隐藏' }));
|
||||
expect(
|
||||
await within(dialog).findByText('原因必须为 1–4000 个字符'),
|
||||
).toBeTruthy();
|
||||
expect(moderateAdminGameReview).not.toHaveBeenCalled();
|
||||
fireEvent.change(within(dialog).getByLabelText('操作原因(必填)'), {
|
||||
target: { value: ' 广告😀 ' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认隐藏' }));
|
||||
await within(dialog).findByText('网络失败');
|
||||
expect(
|
||||
(within(dialog).getByLabelText('操作原因(必填)') as HTMLTextAreaElement)
|
||||
.value,
|
||||
).toBe(' 广告😀 ');
|
||||
const first = vi.mocked(moderateAdminGameReview).mock.calls[0]!;
|
||||
expect(first[3]).toEqual({
|
||||
action: 'hide',
|
||||
expectedCreatedAt: review.createdAt,
|
||||
reason: '广告😀',
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认隐藏' }));
|
||||
await screen.findByText('评价已隐藏');
|
||||
expect(vi.mocked(moderateAdminGameReview).mock.calls[1]![2]).toBe(first[2]);
|
||||
fireEvent.click(within(await row()).getByRole('button', { name: '删除' }));
|
||||
dialog = screen.getByRole('dialog', { name: '删除评价' });
|
||||
expect(
|
||||
within(dialog).getByText('删除后不可恢复,用户可以重新评价'),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '取消' }));
|
||||
expect(moderateAdminGameReview).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('4001码点原因不能提交,恢复复用确认且不要求原因', async () => {
|
||||
vi.mocked(listAdminGameReviews).mockResolvedValue(
|
||||
list({ reviews: [{ ...review, isHidden: true }] }),
|
||||
);
|
||||
show();
|
||||
fireEvent.click(within(await row()).getByRole('button', { name: '删除' }));
|
||||
const deletion = screen.getByRole('dialog', { name: '删除评价' });
|
||||
fireEvent.change(within(deletion).getByLabelText('操作原因(必填)'), {
|
||||
target: { value: '😀'.repeat(4001) },
|
||||
});
|
||||
expect(within(deletion).getByText('4001/4000')).toBeTruthy();
|
||||
expect(
|
||||
(
|
||||
within(deletion).getByRole('button', {
|
||||
name: '确认删除',
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
fireEvent.click(within(deletion).getByRole('button', { name: '取消' }));
|
||||
fireEvent.click(within(await row()).getByRole('button', { name: '恢复' }));
|
||||
const confirmation = screen.getByRole('dialog', { name: '确认操作' });
|
||||
fireEvent.click(within(confirmation).getByRole('button', { name: '确认' }));
|
||||
await waitFor(() =>
|
||||
expect(moderateAdminGameReview).toHaveBeenCalledWith(
|
||||
'token',
|
||||
review.id,
|
||||
expect.any(String),
|
||||
{ action: 'restore', expectedCreatedAt: review.createdAt },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('删除使当前页越界时回到最后一页并保留条件', async () => {
|
||||
vi.mocked(listAdminGameReviews).mockImplementation(async (_token, query) =>
|
||||
list({ page: query?.page ?? 1, total: 21, totalPages: 2 }),
|
||||
);
|
||||
show();
|
||||
await row();
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('navigation', { name: '评价分页' })).getByRole(
|
||||
'button',
|
||||
{ name: '下一页' },
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ page: 2 }),
|
||||
),
|
||||
);
|
||||
fireEvent.click(within(await row()).getByRole('button', { name: '删除' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '删除评价' });
|
||||
fireEvent.change(within(dialog).getByLabelText('操作原因(必填)'), {
|
||||
target: { value: '重复内容' },
|
||||
});
|
||||
vi.mocked(listAdminGameReviews).mockResolvedValue(
|
||||
list({ total: 20, totalPages: 1 }),
|
||||
);
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认删除' }));
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ page: 1 }),
|
||||
),
|
||||
);
|
||||
expect(screen.getByText('评价已删除')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('详情完整纯文本只读并展示后台操作原因', async () => {
|
||||
vi.mocked(getAdminGameReview).mockResolvedValue({
|
||||
review,
|
||||
operations: [
|
||||
{
|
||||
id: 'op',
|
||||
reviewId: review.id,
|
||||
gameId: review.gameId,
|
||||
userId: review.author.id,
|
||||
reviewCreatedAt: review.createdAt,
|
||||
action: 'hide',
|
||||
adminUserId: 'admin_1',
|
||||
reason: '广告内容',
|
||||
createdAt: review.createdAt,
|
||||
},
|
||||
],
|
||||
});
|
||||
show();
|
||||
fireEvent.click(
|
||||
within(await row()).getByRole('button', { name: '查看详情' }),
|
||||
);
|
||||
const dialog = screen.getByRole('dialog', { name: '评价详情' });
|
||||
expect(await within(dialog).findByText('广告内容')).toBeTruthy();
|
||||
expect(dialog.textContent).toContain(review.comment);
|
||||
expect(dialog.querySelector('b')).toBeNull();
|
||||
expect(within(dialog).getByText('admin_1')).toBeTruthy();
|
||||
expect(within(dialog).queryByRole('textbox')).toBeNull();
|
||||
});
|
||||
|
||||
test('详情关闭后旧响应不会覆盖新目标', async () => {
|
||||
const second = {
|
||||
...review,
|
||||
id: 'review_2',
|
||||
author: { id: 'user_2', name: '用户乙' },
|
||||
comment: '第二条正文',
|
||||
};
|
||||
vi.mocked(listAdminGameReviews).mockResolvedValue(
|
||||
list({ reviews: [review, second], total: 2 }),
|
||||
);
|
||||
let resolveOld!: (value: { review: AdminGameReview; operations: [] }) => void;
|
||||
vi.mocked(getAdminGameReview)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<AdminGameReviewDetailResponse>((resolve) => {
|
||||
resolveOld = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue({ review: second, operations: [] });
|
||||
show();
|
||||
fireEvent.click(
|
||||
within(await row()).getByRole('button', { name: '查看详情' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog')).getByRole('button', { name: '关闭' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByText('用户乙').closest('tr')!).getByRole('button', {
|
||||
name: '查看详情',
|
||||
}),
|
||||
);
|
||||
await within(screen.getByRole('dialog')).findByText('第二条正文');
|
||||
await act(async () => {
|
||||
resolveOld({ review, operations: [] });
|
||||
});
|
||||
expect(
|
||||
within(screen.getByRole('dialog')).getByText('第二条正文'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('dialog').textContent).not.toContain(review.comment);
|
||||
});
|
||||
|
||||
test('管理提交中切换游戏,成功后刷新当前游戏而不恢复旧筛选', async () => {
|
||||
let finish!: (
|
||||
value: Awaited<ReturnType<typeof moderateAdminGameReview>>,
|
||||
) => void;
|
||||
vi.mocked(moderateAdminGameReview).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Awaited<ReturnType<typeof moderateAdminGameReview>>>(
|
||||
(resolve) => {
|
||||
finish = resolve;
|
||||
},
|
||||
),
|
||||
);
|
||||
window.history.replaceState(null, '', '#game-reviews?gameId=game_1');
|
||||
show();
|
||||
fireEvent.click(within(await row()).getByRole('button', { name: '隐藏' }));
|
||||
const dialog = screen.getByRole('dialog', { name: '隐藏评价' });
|
||||
fireEvent.change(within(dialog).getByLabelText('操作原因(必填)'), {
|
||||
target: { value: '广告' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认隐藏' }));
|
||||
act(() => {
|
||||
window.history.replaceState(null, '', '#game-reviews?gameId=game_2');
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ gameId: 'game_2' }),
|
||||
),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(await row()).getByRole('button', { name: '查看详情' }),
|
||||
);
|
||||
await within(screen.getByRole('dialog', { name: '评价详情' })).findByText(
|
||||
'评价 ID',
|
||||
);
|
||||
await act(async () => {
|
||||
finish({
|
||||
review: null,
|
||||
operation: {
|
||||
id: 'op',
|
||||
reviewId: review.id,
|
||||
gameId: review.gameId,
|
||||
userId: review.author.id,
|
||||
reviewCreatedAt: review.createdAt,
|
||||
action: 'hide',
|
||||
adminUserId: 'admin',
|
||||
reason: '广告',
|
||||
createdAt: review.createdAt,
|
||||
},
|
||||
replayed: false,
|
||||
});
|
||||
});
|
||||
expect(listAdminGameReviews).toHaveBeenLastCalledWith(
|
||||
'token',
|
||||
expect.objectContaining({ gameId: 'game_2' }),
|
||||
);
|
||||
expect((screen.getByLabelText('游戏 ID') as HTMLInputElement).value).toBe(
|
||||
'game_2',
|
||||
);
|
||||
expect(screen.getByRole('dialog', { name: '评价详情' })).toBeTruthy();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -64,7 +64,7 @@ const FIXED_GATE_TARGETS: GateTargetOption[] = [
|
||||
key: 'game-distribution:publish',
|
||||
label: '游戏发布',
|
||||
description:
|
||||
'游戏发布入口灰度:未配置或关闭时对已登录作者默认开放,开启后只放行白名单 / 灰度命中',
|
||||
'游戏发布入口灰度:未配置或关闭时不开放,启用后按白名单 / 用户标签 / 灰度比例放行',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
AdminEmptyState,
|
||||
AdminListPanel,
|
||||
AdminPage,
|
||||
AdminPageHeading,
|
||||
AdminStatusPill,
|
||||
} from '@genarrative/shared/components';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
formatAdminApiError,
|
||||
listAdminPaymentOrders,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminPaymentOrderEntry,
|
||||
AdminPaymentWebhookEntry,
|
||||
} from '../api/adminApiTypes';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
type AdminPaymentOrderPageProps = {
|
||||
token: string;
|
||||
onUnauthorized: (message?: string) => void;
|
||||
};
|
||||
|
||||
function statusTone(status: string): 'ok' | 'pending' | 'error' {
|
||||
if (status === 'paid') return 'ok';
|
||||
if (status === 'closed' || status === 'expired') return 'error';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
export function AdminPaymentOrderPage({
|
||||
token,
|
||||
onUnauthorized,
|
||||
}: AdminPaymentOrderPageProps) {
|
||||
const [entries, setEntries] = useState<AdminPaymentOrderEntry[]>([]);
|
||||
const [webhooks, setWebhooks] = useState<AdminPaymentWebhookEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
void listAdminPaymentOrders(token)
|
||||
.then((response) => {
|
||||
setEntries(response.entries ?? []);
|
||||
setWebhooks(response.webhooks ?? []);
|
||||
})
|
||||
.catch((nextError: unknown) => {
|
||||
handlePageError(nextError, onUnauthorized, (message) =>
|
||||
setError(message || formatAdminApiError(nextError)),
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [onUnauthorized, token]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<AdminPage wide>
|
||||
<AdminPageHeading
|
||||
title="支付订单"
|
||||
description="平台统一收款的外部产品订单与到账状态。"
|
||||
actions={
|
||||
<button
|
||||
className="admin-button admin-button--secondary"
|
||||
type="button"
|
||||
onClick={load}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<AdminListPanel
|
||||
title="外部回调投递"
|
||||
count={`${webhooks.length} 条`}
|
||||
busy={isLoading}
|
||||
emptyText={<AdminEmptyState>暂无外部回调记录。</AdminEmptyState>}
|
||||
columns={[
|
||||
{
|
||||
key: 'delivery',
|
||||
label: '投递',
|
||||
render: (entry) => (
|
||||
<>
|
||||
<strong>{entry.deliveryId}</strong>
|
||||
<small className="admin-muted-block">{entry.orderId}</small>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{ key: 'app', label: '应用', render: (entry) => entry.appId },
|
||||
{
|
||||
key: 'callback',
|
||||
label: '回调地址',
|
||||
render: (entry) => entry.callbackUrl,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '状态',
|
||||
render: (entry) => (
|
||||
<AdminStatusPill
|
||||
tone={
|
||||
entry.status === 'delivered'
|
||||
? 'ok'
|
||||
: entry.status === 'failed'
|
||||
? 'error'
|
||||
: 'pending'
|
||||
}
|
||||
>
|
||||
{entry.status}
|
||||
</AdminStatusPill>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'attempts',
|
||||
label: '尝试次数',
|
||||
render: (entry) => entry.attemptCount,
|
||||
},
|
||||
{
|
||||
key: 'error',
|
||||
label: '最近错误',
|
||||
render: (entry) => entry.lastErrorMessage ?? '-',
|
||||
},
|
||||
{
|
||||
key: 'updated',
|
||||
label: '更新时间',
|
||||
render: (entry) => entry.updatedAt,
|
||||
},
|
||||
]}
|
||||
rows={webhooks}
|
||||
rowKey={(entry) => entry.deliveryId}
|
||||
/>
|
||||
{error ? (
|
||||
<div className="admin-alert admin-alert--error">{error}</div>
|
||||
) : null}
|
||||
<AdminListPanel
|
||||
title="订单记录"
|
||||
count={`${entries.length} 条`}
|
||||
busy={isLoading}
|
||||
emptyText={
|
||||
<AdminEmptyState>
|
||||
暂无支付订单,外部产品创建订单后会显示在这里。
|
||||
</AdminEmptyState>
|
||||
}
|
||||
columns={[
|
||||
{
|
||||
key: 'order',
|
||||
label: '订单',
|
||||
render: (entry) => (
|
||||
<>
|
||||
<strong>{entry.merchantOrderId}</strong>
|
||||
<small className="admin-muted-block">{entry.orderId}</small>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'app',
|
||||
label: '应用',
|
||||
render: (entry) => (
|
||||
<>
|
||||
<strong>{entry.title}</strong>
|
||||
<small className="admin-muted-block">{entry.appId}</small>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
label: '金额',
|
||||
render: (entry) =>
|
||||
`${entry.currency} ${(entry.amountCents / 100).toFixed(2)}`,
|
||||
},
|
||||
{ key: 'provider', label: '渠道', render: (entry) => entry.provider },
|
||||
{
|
||||
key: 'status',
|
||||
label: '状态',
|
||||
render: (entry) => (
|
||||
<AdminStatusPill tone={statusTone(entry.status)}>
|
||||
{entry.status}
|
||||
</AdminStatusPill>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created',
|
||||
label: '创建时间',
|
||||
render: (entry) => entry.createdAt,
|
||||
},
|
||||
{
|
||||
key: 'paid',
|
||||
label: '支付时间',
|
||||
render: (entry) => entry.paidAt ?? '-',
|
||||
},
|
||||
]}
|
||||
rows={entries}
|
||||
rowKey={(entry) => entry.orderId}
|
||||
/>
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* 「游戏审核」待审版本表的页面专属样式。
|
||||
*
|
||||
* 目标:1280 / 1440 / 1920 三档视口下,「详情 / 通过 / 拒绝」三个版本级动作
|
||||
* 都在面板可视带内、单行不换行,不依赖面板内横向滚动。
|
||||
*
|
||||
* 做法:只在本页表格上加自己的类,覆盖 `.admin-table-wide` 的 min-width 并固定列宽,
|
||||
* 把动作列按内容取宽;gameId 最长(37 字符)用省略号截断并保留 title 作为完整值出口。
|
||||
* 不改共享 `.admin-table-wide` 的全局语义,也不影响其它后台页面。
|
||||
*/
|
||||
|
||||
/* `.admin-table-wide` 是共享类,用元素+类提升优先级覆盖它,只作用于本页表格。 */
|
||||
table.admin-game-review-table {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
table.admin-game-review-table th:nth-child(2),
|
||||
table.admin-game-review-table td:nth-child(2) {
|
||||
/* `pending_review` 是最长状态值(约 120px + 单元格内边距),必须给足,否则会压到「发行包」列。 */
|
||||
width: 148px;
|
||||
}
|
||||
|
||||
table.admin-game-review-table th:nth-child(3),
|
||||
table.admin-game-review-table td:nth-child(3) {
|
||||
width: 124px;
|
||||
}
|
||||
|
||||
table.admin-game-review-table th:nth-child(4),
|
||||
table.admin-game-review-table td:nth-child(4) {
|
||||
width: 172px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
table.admin-game-review-table th:nth-child(5),
|
||||
table.admin-game-review-table td:nth-child(5) {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
/* 固定列宽下,任何溢出都应该在本列内被裁掉,而不是压到相邻列上。 */
|
||||
table.admin-game-review-table td {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
table.admin-game-review-table td .admin-muted-text {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-game-review-cell--actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-game-review-cell--actions .admin-review-actions {
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* 游戏列:主行游戏名、副行作者(打码名 + user_id 截断,完整值放 title)。 */
|
||||
.admin-game-review-game {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.admin-game-review-game-author {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-game-review-game-author-name {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-game-review-game-author-id {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* 移动端/窄屏:动作列保持不换行,仍靠省略号吸收 gameId 的宽度。 */
|
||||
@media (max-width: 980px) {
|
||||
table.admin-game-review-table {
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
table.admin-game-review-table th:nth-child(2),
|
||||
table.admin-game-review-table td:nth-child(2),
|
||||
table.admin-game-review-table th:nth-child(3),
|
||||
table.admin-game-review-table td:nth-child(3),
|
||||
table.admin-game-review-table th:nth-child(4),
|
||||
table.admin-game-review-table td:nth-child(4),
|
||||
table.admin-game-review-table th:nth-child(5),
|
||||
table.admin-game-review-table td:nth-child(5) {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
@@ -289,7 +289,66 @@ button:disabled {
|
||||
|
||||
.admin-nav {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-nav-group {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.admin-nav-group-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
padding: 0 8px;
|
||||
color: #a08472;
|
||||
background: transparent;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-nav-group-toggle svg {
|
||||
flex: 0 0 auto;
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.admin-nav-group[data-collapsed='true'] .admin-nav-group-toggle svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.admin-nav-group-toggle small {
|
||||
min-width: 18px;
|
||||
margin-left: auto;
|
||||
border-radius: 999px;
|
||||
padding: 2px 5px;
|
||||
color: #b18d76;
|
||||
background: #faf0e8;
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.admin-nav-group-toggle:hover,
|
||||
.admin-nav-group-toggle:focus-visible {
|
||||
color: #8f3f27;
|
||||
background: #fff8f2;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.admin-nav-group-items {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.admin-nav-group-items[data-collapsed='true'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-nav-button,
|
||||
@@ -305,8 +364,8 @@ button:disabled {
|
||||
.admin-nav-button {
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
min-height: 42px;
|
||||
padding: 0 12px;
|
||||
min-height: 40px;
|
||||
padding: 0 12px 0 28px;
|
||||
color: #755a49;
|
||||
background: transparent;
|
||||
}
|
||||
@@ -766,6 +825,47 @@ button:disabled {
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.admin-review-filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.admin-review-comment {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-table .admin-review-comment {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-review-comment-summary {
|
||||
min-width: 160px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.admin-game-reviews-table .admin-status {
|
||||
white-space: nowrap;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
.admin-game-reviews-table td:last-child .admin-action-row {
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.admin-game-reviews-table td:last-child .admin-action-row > button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.admin-review-filters {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.admin-table-query-grid {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
@@ -3480,3 +3580,55 @@ button:disabled {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-game-review-detail {
|
||||
max-width: 70rem;
|
||||
margin: 0 auto;
|
||||
padding: clamp(1rem, 2.4vw, 2.75rem);
|
||||
border-radius: 1.75rem;
|
||||
background: var(--platform-body-fill);
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
|
||||
.admin-game-review-preview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 42rem;
|
||||
margin-top: 1rem;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 1rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/*
|
||||
* 游戏管理列表(AdminGameManagementPage)专属排布,选择器全部框在本页表格内,
|
||||
* 不影响其它后台页面:
|
||||
* - 操作列按内容取宽且按钮组不换行,避免「查看评价」被挤到第二行;
|
||||
* - 单元格垂直居中,行高不再随最长单元格跳动;
|
||||
* - 标题列给最小宽度,避免被长 gameId 挤压成一行一个字。
|
||||
* 这里用 `.admin-table.admin-game-management-table` 复合选择器:共享样式表在 dev 下
|
||||
* 由组件模块后注入,单类选择器会被 `.admin-table th, .admin-table td` 反超。
|
||||
*/
|
||||
.admin-table.admin-game-management-table th,
|
||||
.admin-table.admin-game-management-table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.admin-game-management-table td:first-child {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.admin-game-management-table th:last-child,
|
||||
.admin-game-management-table td.admin-game-actions-cell {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-game-actions-cell .admin-action-row {
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.admin-game-author-cell {
|
||||
min-width: 190px;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user