diff --git a/.codex/skills/genarrative-admin-backoffice/references/spacetimedb-http-sql-sats-display.md b/.codex/skills/genarrative-admin-backoffice/references/spacetimedb-http-sql-sats-display.md index e6ba4d50c..916f60814 100644 --- a/.codex/skills/genarrative-admin-backoffice/references/spacetimedb-http-sql-sats-display.md +++ b/.codex/skills/genarrative-admin-backoffice/references/spacetimedb-http-sql-sats-display.md @@ -41,3 +41,14 @@ ## 注意 不同 enum 的 variant 顺序必须以生成 binding 或 module 源码为准,不能复用其他 enum 的索引映射。 + +## 通用表查询页的枚举展示(2026-09-23 起) + +后台“表查询”(`#tables`)不再逐表硬编码枚举映射,改为按 schema 自动解析: + +- api-server 在 `server-rs/crates/api-server/src/admin.rs` 读取 schema 的 `typespace.types` 和表的 `product_type_ref`,对每个“`Sum` 且所有变体都是单元变体(`Product.elements` 为空)”的列生成 `列名 -> [按变体索引排列的展示名]`,变体名归一到 snake_case。 +- `Option<枚举>` 列单独标记为可空:`[0, [索引, []]]` 出变体名,`[1, []]` 仍是空值。`Option<普通值>` 与带载荷的 Sum 直接跳过,交回通用解码,避免把普通 `Option` 列误标成枚举名。 +- 映射同时应用到 `cells` 与 `raw`,因此关键词搜索、结构化筛选、稳定排序解析到的都是展示名。 +- 单变体枚举也要出名字;变体索引顺序以 schema 为准,不依赖生成 binding 的副本。 + +因此新增表或新增枚举列无需再改后端映射,只要模块已发布且 schema 可读;如果 schema 读取失败,表查询会以“表不存在”失败,而不是退回展示数字。定向验证:`cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server admin_database`。 diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index 05cfdc10e..7c5464f06 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -8,10 +8,12 @@ import { getAdminUserDetail, importAdminAgcTemplates, listAdminAgcTrackingEvents, + listAdminGameDistributionGames, listAdminGameDistributionReviews, listAdminRechargeOrders, reconcileAdminUserConsumption, resolveAdminRechargeRefundManualReview, + restoreAdminGameDistributionGame, reviewAdminGameDistributionVersion, suspendAdminGameDistributionGame, updateAdminAccount, @@ -501,7 +503,6 @@ test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键' { decision: 'approve', expectedPublicationRevision: 3, - entryUrl: 'https://games.example.test/releases/game_1/index.html', }, ); @@ -521,12 +522,90 @@ test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键' body: JSON.stringify({ decision: 'approve', expectedPublicationRevision: 3, - entryUrl: 'https://games.example.test/releases/game_1/index.html', }), }), ); }); +test('游戏管理列表与恢复动作使用约定的 URL、方法和幂等键', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true, data: { games: [] } }), { + status: 200, + }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + ok: true, + data: { + game: { + id: 'game/1', + title: '测试游戏', + status: 'published', + publicationRevision: 10, + }, + replayed: false, + }, + }), + { status: 200 }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + const controller = new AbortController(); + + await listAdminGameDistributionGames( + 'admin-token', + { limit: 80 }, + controller.signal, + ); + await restoreAdminGameDistributionGame( + 'admin-token', + ' game/1 ', + ' game-restore-key-1 ', + { expectedPublicationRevision: 9 }, + ); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + '/admin/api/game-distribution/games?limit=50', + ); + expect(fetchMock.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + method: 'GET', + signal: controller.signal, + headers: expect.objectContaining({ + Authorization: 'Bearer admin-token', + }), + }), + ); + expect(fetchMock.mock.calls[1]?.[0]).toBe( + '/admin/api/game-distribution/games/game%2F1/restore', + ); + expect(fetchMock.mock.calls[1]?.[1]).toEqual( + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer admin-token', + 'Idempotency-Key': 'game-restore-key-1', + }), + body: JSON.stringify({ expectedPublicationRevision: 9 }), + }), + ); + + expect(() => + restoreAdminGameDistributionGame('admin-token', ' ', 'key', { + expectedPublicationRevision: 9, + }), + ).toThrow('缺少游戏 ID'); + expect(() => + restoreAdminGameDistributionGame('admin-token', 'game-1', ' ', { + expectedPublicationRevision: 9, + }), + ).toThrow('恢复幂等键必须是 1 到 128 个字符'); + expect(fetchMock).toHaveBeenCalledTimes(2); +}); + test('安全下架请求携带公开修订号、原因与幂等键', async () => { const fetchMock = vi.fn().mockImplementation(() => Promise.resolve( diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 31b1760c2..ed80aa51f 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -32,6 +32,9 @@ import type { AdminExternalApiKeyListQuery, AdminExternalApiKeyListResponse, AdminFeatureGateConfigResponse, + AdminGameDistributionGameListResponse, + AdminGameDistributionRestoreRequest, + AdminGameDistributionRestoreResponse, AdminGameDistributionReviewListResponse, AdminGameDistributionReviewRequest, AdminGameDistributionReviewResponse, @@ -1241,6 +1244,46 @@ export function listAdminGameDistributionReviews(token: string, limit = 48) { ); } +export function listAdminGameDistributionGames( + token: string, + options: { limit?: number } = {}, + signal?: AbortSignal, +) { + const requestedLimit = options.limit ?? 50; + const normalizedLimit = Number.isFinite(requestedLimit) + ? Math.min(Math.max(Math.trunc(requestedLimit), 1), 50) + : 50; + return request( + `/admin/api/game-distribution/games?limit=${normalizedLimit}`, + { token, signal }, + ); +} + +export function restoreAdminGameDistributionGame( + token: string, + gameId: string, + idempotencyKey: string, + payload: AdminGameDistributionRestoreRequest, +) { + 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( + `/admin/api/game-distribution/games/${encodeURIComponent(normalizedGameId)}/restore`, + { + method: 'POST', + token, + headers: { 'Idempotency-Key': normalizedKey }, + body: payload, + }, + ); +} + /** * 审核游戏发行版本。幂等键由调用方生成并在同一次提交内复用,避免重复点击产生 * 两条审核结论。 diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 74e8828ca..6ca31fa56 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -193,7 +193,10 @@ export interface AdminDashboardRangePayload { export interface AdminDashboardMetricsPayload { generatedAssets: number; + /** 已对冲退还(生成失败 / 精选审核返还 / LLM Router 冲正)的净消耗泥点。 */ consumedMudPoints: number; + /** 同期退还泥点,用于核对「消耗 + 退还」的毛消耗口径。 */ + refundedMudPoints: number; totalRegisteredUsers: number; newRegisteredUsers: number; newUserPaymentConversion: AdminDashboardPaymentConversionPayload; @@ -989,6 +992,8 @@ export interface AdminRechargeOrderEntryPayload { productTitle: string; productKind: string; amountCents: number; + /** 真实支付金额(分):未支付 / 已关闭 / 已过期订单固定为 0,不能拿订单金额当实付。 */ + paidAmountCents: number; status: string; paymentChannel: string; paidAtMicros?: number | null; @@ -1028,6 +1033,8 @@ export interface AdminUserDetailResponse { phoneBound: boolean; wechatBound: boolean; historicalConsumedPoints: number; + /** 累计充值金额(分):读取失败或命中读取上限时为 null,前端按未知展示。 */ + cumulativeRechargedCents?: number | null; canReconcileConsumption: boolean; wallet: AdminProfileWalletPayload; rechargeOrders: AdminRechargeOrderEntryPayload[]; @@ -1137,7 +1144,6 @@ export interface AdminGameDistributionReviewRequest { decision: 'approve' | 'reject'; expectedPublicationRevision: number; reviewReason?: string; - entryUrl?: string; } export interface AdminGameDistributionReviewResponse { @@ -1160,6 +1166,57 @@ export interface AdminGameDistributionSuspendResponse { replayed: boolean; } +export interface AdminGameDistributionGameVersionEntry { + versionId: string; + gameId: string; + versionNumber: number; + status: string; + reviewReason: string | null; + packageBytes: number; + packageSha256: string; + createdAt: string; + updatedAt: string; + reviewedAt: string | null; + publishedAt: string | null; + entryUrl: string | null; +} + +export interface AdminGameDistributionGameEntry { + gameId: string; + title: string; + author: { + id: string; + name: string; + avatarUrl: string | null; + }; + status: string; + versionCount: number; + playCount: number; + activeVersionId: string | null; + publicationRevision: number; + createdAt: string; + updatedAt: string; + versions: AdminGameDistributionGameVersionEntry[]; +} + +export interface AdminGameDistributionGameListResponse { + games: AdminGameDistributionGameEntry[]; +} + +export interface AdminGameDistributionRestoreRequest { + expectedPublicationRevision: number; +} + +export interface AdminGameDistributionRestoreResponse { + game: { + id: string; + title: string; + status: string; + publicationRevision: number; + }; + replayed: boolean; +} + export interface AdminAgcTemplatePayload { id: string; title: string; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 73b48d326..f9294a7a5 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -29,6 +29,7 @@ import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGeneration import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage'; import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage'; import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage'; +import { AdminGameManagementPage } from '../pages/AdminGameManagementPage'; import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage'; import { AdminInviteCodePage } from '../pages/AdminInviteCodePage'; import { AdminLoginPage } from '../pages/AdminLoginPage'; @@ -321,6 +322,12 @@ export function AdminApp() { onUnauthorized={handleUnauthorized} /> ) : null} + {activeRouteId === 'game-management' ? ( + + ) : null} {activeRouteId === 'editor-assets' ? ( { expect(routeHash('game-distribution')).toBe('#game-distribution'); }); +test('后台游戏管理路由可通过导航和 hash 访问', () => { + expect(adminRoutes).toContainEqual({ + id: 'game-management', + label: '游戏管理', + hash: '#game-management', + }); + expect(resolveAdminRoute('#game-management')).toBe('game-management'); + expect(routeHash('game-management')).toBe('#game-management'); +}); + test('member 可单独获得游戏审核 Tab 权限', () => { const routes = getAccessibleAdminRoutes({ accountRole: 'member', diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index 46aa13750..d6c948348 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -17,6 +17,7 @@ export type AdminRouteId = | 'editor-generation-pricing' | 'editor-showcase' | 'game-distribution' + | 'game-management' | 'editor-assets' | 'project-snapshots' | 'agc-models' @@ -60,6 +61,7 @@ export const adminRoutes: AdminRouteDefinition[] = [ { id: 'agc-templates', label: '模板管理', hash: '#agc-templates' }, { id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' }, { id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' }, + { id: 'game-management', label: '游戏管理', hash: '#game-management' }, { id: 'editor-assets', label: '素材查询', hash: '#editor-assets' }, { id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' }, { id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true }, diff --git a/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx b/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx index a8ba9f380..0c14b58be 100644 --- a/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx +++ b/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx @@ -51,6 +51,7 @@ const detail: AdminUserDetailResponse = { phoneBound: true, wechatBound: true, historicalConsumedPoints: 1234, + cumulativeRechargedCents: 128800, canReconcileConsumption: true, wallet, rechargeOrders: [ @@ -62,6 +63,7 @@ const detail: AdminUserDetailResponse = { productTitle: '60泥点', productKind: 'points', amountCents: 600, + paidAmountCents: 600, status: 'paid', paymentChannel: 'wechat_native', paidAtMicros: 1_720_000_000_000_000, @@ -124,7 +126,11 @@ test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退 expect(screen.getByText('25', { selector: 'strong' })).toBeTruthy(); expect(screen.getByText('历史花费')).toBeTruthy(); expect(screen.getByText('1234', { selector: 'strong' })).toBeTruthy(); + expect(screen.getByText('累计充值')).toBeTruthy(); + expect(screen.getByText('¥1288.00')).toBeTruthy(); expect(screen.getByText('order-1')).toBeTruthy(); + expect(screen.getByRole('columnheader', { name: '实付' })).toBeTruthy(); + expect(screen.getByRole('columnheader', { name: '发放泥点' })).toBeTruthy(); await user.keyboard('{Escape}'); await waitFor(() => @@ -133,6 +139,29 @@ test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退 await waitFor(() => expect(document.activeElement).toBe(trigger)); }); +test('累计充值读取不到时展示未知,不用订单列表近似', async () => { + vi.mocked(getAdminUserDetail).mockResolvedValue({ + ...detail, + cumulativeRechargedCents: null, + }); + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', { name: '查看用户信息' })); + await screen.findByText('陶泥用户'); + + expect(screen.getByText('累计充值')).toBeTruthy(); + expect(screen.getByText('累计充值').nextElementSibling?.textContent).toBe( + '未知', + ); +}); + test('只有陶泥号时按 publicUserCode 查询用户', async () => { const user = userEvent.setup(); render( diff --git a/apps/admin-web/src/components/AdminUserDetailDialog.tsx b/apps/admin-web/src/components/AdminUserDetailDialog.tsx index 8aa07d1be..66725d952 100644 --- a/apps/admin-web/src/components/AdminUserDetailDialog.tsx +++ b/apps/admin-web/src/components/AdminUserDetailDialog.tsx @@ -364,6 +364,7 @@ export function AdminUserDetailDialog({ 订单 商品 实付 + 发放泥点 退款 状态 @@ -377,11 +378,13 @@ export function AdminUserDetailDialog({ {formatMicros(order.createdAtMicros)} + {order.productTitle || order.productId} - {order.productTitle || order.productId} - 发放 {order.pointsDelta} 泥点 + {order.paidAmountCents > 0 + ? formatMoney(order.paidAmountCents) + : '未支付'} - {formatMoney(order.amountCents)} + {order.pointsDelta} 泥点 {formatMoney(order.cumulativeSuccessRefundCents)} 欠账 {order.unrecoveredPoints} 泥点 @@ -435,6 +438,14 @@ function UserIdentityHeader({ detail }: { detail: AdminUserDetailResponse }) {
登录方式
{detail.loginMethod || '-'}
+
+
累计充值
+
+ {typeof detail.cumulativeRechargedCents === 'number' + ? formatMoney(detail.cumulativeRechargedCents) + : '未知'} +
+
绑定状态
diff --git a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx index a278a19dc..3653df930 100644 --- a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx +++ b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx @@ -34,6 +34,7 @@ const dashboardResponse: AdminDashboardResponse = { metrics: { generatedAssets: 12, consumedMudPoints: 88, + refundedMudPoints: 24, totalRegisteredUsers: 1200, newRegisteredUsers: 16, newUserPaymentConversion: { @@ -105,6 +106,8 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () = expect(await screen.findByText('总计数据')).toBeTruthy(); expect(screen.getByText('时段数据')).toBeTruthy(); expect(screen.getByText('本日生产素材数')).toBeTruthy(); + expect(screen.getByText('本日消耗泥点数')).toBeTruthy(); + expect(screen.getByText('本日退还泥点数')).toBeTruthy(); expect(screen.getByText('总注册用户')).toBeTruthy(); expect(screen.getByText('本日新增用户数')).toBeTruthy(); expect(screen.getByText('新增用户转化与留存')).toBeTruthy(); diff --git a/apps/admin-web/src/pages/AdminDashboardPage.tsx b/apps/admin-web/src/pages/AdminDashboardPage.tsx index 933abf45a..111e7e9a0 100644 --- a/apps/admin-web/src/pages/AdminDashboardPage.tsx +++ b/apps/admin-web/src/pages/AdminDashboardPage.tsx @@ -128,6 +128,12 @@ export function AdminDashboardPage({ value: metrics?.consumedMudPoints ?? 0, unit: '泥点', }, + { + id: 'refunded-mud-points', + label: `${rangePrefix(granularity)}退还泥点数`, + value: metrics?.refundedMudPoints ?? 0, + unit: '泥点', + }, { id: 'new-registered-users', label: `${rangePrefix(granularity)}新增用户数`, diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx index ae00212d9..8291e815e 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx @@ -1,6 +1,12 @@ /* @vitest-environment jsdom */ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; import { beforeEach, expect, test, vi } from 'vitest'; import { @@ -9,10 +15,7 @@ import { suspendAdminGameDistributionGame, } from '../api/adminApiClient'; import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes'; -import { - AdminGameDistributionReviewPage, - resolveGameReleaseEntryUrlError, -} from './AdminGameDistributionReviewPage'; +import { AdminGameDistributionReviewPage } from './AdminGameDistributionReviewPage'; vi.mock('../api/adminApiClient', () => ({ isAdminApiError: vi.fn( @@ -53,23 +56,7 @@ beforeEach(() => { }); }); -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 () => { +test('通过审核只提交当前 publicationRevision 并刷新列表', async () => { vi.mocked(reviewAdminGameDistributionVersion).mockResolvedValue({ version: { ...entry, status: 'published' }, replayed: false, @@ -83,9 +70,10 @@ test('通过审核时提交当前 publicationRevision 与发行入口并刷新 ); await screen.findByText('game_1'); - fireEvent.change(screen.getByLabelText('发行入口'), { - target: { value: 'https://games.test/releases/game_1/index.html' }, - }); + expect(screen.queryByLabelText('发行入口')).toBeNull(); + expect(screen.queryByText('通过后由系统分配发行地址')).toBeNull(); + expect(screen.queryByLabelText('拒绝理由')).toBeNull(); + expect(screen.queryByLabelText('下架原因')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '通过' })); await waitFor(() => @@ -99,7 +87,6 @@ test('通过审核时提交当前 publicationRevision 与发行入口并刷新 expect(payload).toEqual({ decision: 'approve', expectedPublicationRevision: 4, - entryUrl: 'https://games.test/releases/game_1/index.html', }); await waitFor(() => expect(vi.mocked(listAdminGameDistributionReviews)).toHaveBeenCalledTimes( @@ -108,7 +95,12 @@ test('通过审核时提交当前 publicationRevision 与发行入口并刷新 ); }); -test('缺少拒绝理由时不调用审核接口', async () => { +test('点击拒绝后填写理由再提交审核接口', async () => { + vi.mocked(reviewAdminGameDistributionVersion).mockResolvedValue({ + version: { ...entry, status: 'rejected', reviewReason: '运行时报错' }, + replayed: false, + }); + render( { await screen.findByText('game_1'); fireEvent.click(screen.getByRole('button', { name: '拒绝' })); + const dialog = await screen.findByRole('dialog'); + const reasonInput = within(dialog).getByRole('textbox', { + name: '拒绝理由', + }); - expect(await screen.findByText('拒绝审核必须填写理由')).toBeTruthy(); + fireEvent.click(within(dialog).getByRole('button', { name: '确认拒绝' })); + expect(await within(dialog).findByText('拒绝审核必须填写理由')).toBeTruthy(); expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled(); + + fireEvent.change(reasonInput, { target: { value: '运行时报错' } }); + fireEvent.click(within(dialog).getByRole('button', { name: '确认拒绝' })); + + await waitFor(() => + expect(reviewAdminGameDistributionVersion).toHaveBeenCalledTimes(1), + ); + const [, versionId, , payload] = + vi.mocked(reviewAdminGameDistributionVersion).mock.calls[0] ?? []; + expect(versionId).toBe('version-1'); + expect(payload).toEqual({ + decision: 'reject', + expectedPublicationRevision: 4, + reviewReason: '运行时报错', + }); }); -test('安全下架需要二次确认,并携带公开修订号与原因', async () => { +test('安全下架需要先填写原因,再二次确认并携带公开修订号', async () => { vi.mocked(suspendAdminGameDistributionGame).mockResolvedValue({ game: { id: 'game_1', @@ -142,16 +154,21 @@ test('安全下架需要二次确认,并携带公开修订号与原因', async ); await screen.findByText('game_1'); - fireEvent.change(screen.getByLabelText('下架原因'), { - target: { value: '盗用素材' }, - }); 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(suspendAdminGameDistributionGame).not.toHaveBeenCalled(); - expect(await screen.findByRole('dialog')).toBeTruthy(); - - fireEvent.click(screen.getByRole('button', { name: '确认' })); + await screen.findByText('确认操作'); + const confirmDialog = screen.getByRole('dialog'); + fireEvent.click(within(confirmDialog).getByRole('button', { name: '确认' })); await waitFor(() => expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1), @@ -168,7 +185,24 @@ test('安全下架需要二次确认,并携带公开修订号与原因', async expect(await screen.findByText(/已安全下架/u)).toBeTruthy(); }); -test('取消确认时不下架', async () => { +test('取消理由输入时不做审核操作', async () => { + render( + , + ); + await screen.findByText('game_1'); + + fireEvent.click(screen.getByRole('button', { name: '拒绝' })); + const dialog = await screen.findByRole('dialog'); + fireEvent.click(within(dialog).getByRole('button', { name: '取消' })); + + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled(); +}); + +test('取消安全下架确认时不下架', async () => { render( { await screen.findByText('game_1'); fireEvent.click(screen.getByRole('button', { name: '安全下架' })); - await screen.findByRole('dialog'); - 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: '继续下架' }), + ); + + 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(); diff --git a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx index b1e993ed7..7a3d8890e 100644 --- a/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx +++ b/apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx @@ -1,3 +1,4 @@ +import { Modal, TextField } from '@genarrative/shared/components'; import { RefreshCcw } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; @@ -15,6 +16,11 @@ interface AdminGameDistributionReviewPageProps { onUnauthorized: (message?: string) => void; } +interface ReviewReasonPrompt { + decision: 'reject' | 'suspend'; + entry: AdminGameDistributionReviewEntry; +} + function formatBytes(value: number) { if (value >= 1024 * 1024) { return `${(value / (1024 * 1024)).toFixed(1)} MiB`; @@ -47,26 +53,6 @@ function createReviewIdempotencyKey(versionId: string) { 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, @@ -78,15 +64,11 @@ export function AdminGameDistributionReviewPage({ const [busyVersionId, setBusyVersionId] = useState(''); const [errorMessage, setErrorMessage] = useState(''); const [statusMessage, setStatusMessage] = useState(''); - const [entryUrlByVersion, setEntryUrlByVersion] = useState< - Record - >({}); - const [reasonByVersion, setReasonByVersion] = useState< - Record - >({}); - const [suspendReasonByGame, setSuspendReasonByGame] = useState< - Record - >({}); + const [reasonPrompt, setReasonPrompt] = useState( + null, + ); + const [reasonDraft, setReasonDraft] = useState(''); + const [reasonError, setReasonError] = useState(''); const [busyGameId, setBusyGameId] = useState(''); const writeConfirm = useAdminWriteConfirm(); @@ -110,16 +92,10 @@ export function AdminGameDistributionReviewPage({ async function submitReview( entry: AdminGameDistributionReviewEntry, decision: 'approve' | 'reject', + reason = '', ) { - 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) { + const trimmedReason = reason.trim(); + if (decision === 'reject' && !trimmedReason) { setErrorMessage('拒绝审核必须填写理由'); return; } @@ -135,12 +111,11 @@ export function AdminGameDistributionReviewPage({ ? { decision, expectedPublicationRevision: entry.publicationRevision, - entryUrl, } : { decision, expectedPublicationRevision: entry.publicationRevision, - reviewReason: reason, + reviewReason: trimmedReason, }, ); setStatusMessage( @@ -160,8 +135,11 @@ export function AdminGameDistributionReviewPage({ * 管理员安全下架:先二次确认,再带当前公开修订号调用后端;并发审核导致修订号变化时 * 由服务端返回冲突,前端只提示刷新,不静默重试。 */ - async function suspendGame(entry: AdminGameDistributionReviewEntry) { - const reason = (suspendReasonByGame[entry.gameId] ?? '').trim(); + async function suspendGame( + entry: AdminGameDistributionReviewEntry, + reason: string, + ) { + const trimmedReason = reason.trim(); const confirmed = await writeConfirm.confirmWrite({ action: '安全下架游戏', target: `${entry.gameId}(版本 v${entry.versionNumber})`, @@ -177,11 +155,10 @@ export function AdminGameDistributionReviewPage({ createSuspendIdempotencyKey(entry.gameId), { expectedPublicationRevision: entry.publicationRevision, - ...(reason ? { reason } : {}), + ...(trimmedReason ? { reason: trimmedReason } : {}), }, ); setStatusMessage(`游戏 ${entry.gameId} 已安全下架,发行入口已关闭`); - setSuspendReasonByGame((current) => ({ ...current, [entry.gameId]: '' })); await loadReviews(); } catch (error) { handlePageError(error, onUnauthorized, setErrorMessage); @@ -190,6 +167,39 @@ export function AdminGameDistributionReviewPage({ } } + function openReasonPrompt( + entry: AdminGameDistributionReviewEntry, + decision: ReviewReasonPrompt['decision'], + ) { + setReasonDraft(''); + setReasonError(''); + setReasonPrompt({ decision, entry }); + } + + function closeReasonPrompt() { + setReasonPrompt(null); + setReasonDraft(''); + setReasonError(''); + } + + function confirmReasonPrompt() { + if (!reasonPrompt) return; + + const reason = reasonDraft.trim(); + if (reasonPrompt.decision === 'reject' && !reason) { + setReasonError('拒绝审核必须填写理由'); + return; + } + + const { decision, entry } = reasonPrompt; + closeReasonPrompt(); + if (decision === 'reject') { + void submitReview(entry, 'reject', reason); + return; + } + void suspendGame(entry, reason); + } + return (
@@ -268,25 +278,6 @@ export function AdminGameDistributionReviewPage({ {formatTime(entry.createdAt)}
-
- - - setEntryUrlByVersion((current) => ({ - ...current, - [entry.versionId]: event.target.value, - })) - } - disabled={busy} - /> -
-
- - - setReasonByVersion((current) => ({ - ...current, - [entry.versionId]: event.target.value, - })) - } - disabled={busy} - /> -
-
- - - setSuspendReasonByGame((current) => ({ - ...current, - [entry.gameId]: event.target.value, - })) - } - disabled={busy} - /> -
) : null}
+ {reasonPrompt ? ( + + + +
+ } + > + { + setReasonDraft(event.target.value); + if (reasonError) setReasonError(''); + }} + /> + + ) : null} {writeConfirm.confirmDialog} ); diff --git a/apps/admin-web/src/pages/AdminGameManagementPage.test.tsx b/apps/admin-web/src/pages/AdminGameManagementPage.test.tsx new file mode 100644 index 000000000..18407c5bb --- /dev/null +++ b/apps/admin-web/src/pages/AdminGameManagementPage.test.tsx @@ -0,0 +1,265 @@ +/* @vitest-environment jsdom */ + +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; + +import { + listAdminGameDistributionGames, + restoreAdminGameDistributionGame, + suspendAdminGameDistributionGame, +} from '../api/adminApiClient'; +import type { + AdminGameDistributionGameEntry, + AdminGameDistributionGameVersionEntry, +} from '../api/adminApiTypes'; +import { AdminGameManagementPage } from './AdminGameManagementPage'; + +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 : '请求失败', + ), + listAdminGameDistributionGames: vi.fn(), + restoreAdminGameDistributionGame: vi.fn(), + suspendAdminGameDistributionGame: vi.fn(), +})); + +const version: AdminGameDistributionGameVersionEntry = { + versionId: 'version-3', + gameId: 'game_1', + versionNumber: 3, + status: 'published', + reviewReason: '测试原因', + packageBytes: 2048, + packageSha256: + 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789', + createdAt: '2026-09-20T08:00:00Z', + updatedAt: '2026-09-20T08:00:00Z', + reviewedAt: '2026-09-20T09:00:00Z', + publishedAt: '2026-09-20T10:00:00Z', + entryUrl: 'https://game.example.com/game_1', +}; + +const publishedGame: AdminGameDistributionGameEntry = { + gameId: 'game_1', + title: '测试游戏', + author: { + id: 'user_1', + name: '作者甲', + avatarUrl: 'https://example.com/avatar.png', + }, + status: 'published', + versionCount: 21, + playCount: 345, + activeVersionId: 'version-3', + publicationRevision: 4, + createdAt: '2026-09-18T08:00:00Z', + updatedAt: '2026-09-20T10:00:00Z', + versions: [version], +}; + +const suspendedGame: AdminGameDistributionGameEntry = { + gameId: 'game_2', + title: '下架游戏', + author: { + id: 'user_2', + name: '作者乙', + avatarUrl: null, + }, + status: 'suspended', + versionCount: 2, + playCount: 8, + activeVersionId: null, + publicationRevision: 7, + createdAt: '2026-09-19T08:00:00Z', + updatedAt: '2026-09-21T08:00:00Z', + versions: [], +}; + +beforeEach(() => { + vi.mocked(listAdminGameDistributionGames) + .mockReset() + .mockResolvedValue({ games: [publishedGame] }); + vi.mocked(restoreAdminGameDistributionGame) + .mockReset() + .mockResolvedValue({ + game: { + id: suspendedGame.gameId, + title: suspendedGame.title, + status: 'published', + publicationRevision: 8, + }, + replayed: false, + }); + vi.mocked(suspendAdminGameDistributionGame) + .mockReset() + .mockResolvedValue({ + game: { + id: publishedGame.gameId, + title: publishedGame.title, + status: 'suspended', + publicationRevision: 5, + }, + replayed: false, + }); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +test('列表展示作者头像、状态、版本数和游玩数', async () => { + vi.mocked(listAdminGameDistributionGames).mockResolvedValue({ + games: [publishedGame, suspendedGame], + }); + + render( + , + ); + + const publishedRow = (await screen.findByText('测试游戏')).closest('tr')!; + expect(within(publishedRow).getByText('作者甲')).toBeTruthy(); + const avatar = within(publishedRow).getByRole('img', { + name: '作者甲 头像', + }); + expect(avatar.getAttribute('src')).toBe('https://example.com/avatar.png'); + expect(within(publishedRow).getByText('已公开')).toBeTruthy(); + expect(within(publishedRow).getByText('21')).toBeTruthy(); + expect(within(publishedRow).getByText('345')).toBeTruthy(); + + const suspendedRow = (await screen.findByText('下架游戏')).closest('tr')!; + expect(within(suspendedRow).getByText('作者乙')).toBeTruthy(); + expect(suspendedRow.querySelector('.admin-user-avatar')?.textContent).toBe( + '作', + ); + expect(within(suspendedRow).getByText('已下架')).toBeTruthy(); +}); + +test('恢复按钮只在下架态出现,成功后携带幂等键并刷新列表', async () => { + vi.mocked(listAdminGameDistributionGames) + .mockResolvedValueOnce({ games: [suspendedGame] }) + .mockResolvedValueOnce({ + games: [{ ...suspendedGame, status: 'published' }], + }); + + render( + , + ); + + await screen.findByText('下架游戏'); + expect(screen.queryByRole('button', { name: '下架' })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '恢复' })); + + expect(restoreAdminGameDistributionGame).not.toHaveBeenCalled(); + await screen.findByRole('dialog'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + await waitFor(() => + expect(restoreAdminGameDistributionGame).toHaveBeenCalledTimes(1), + ); + const [token, gameId, idempotencyKey, payload] = + vi.mocked(restoreAdminGameDistributionGame).mock.calls[0] ?? []; + expect(token).toBe('admin-token'); + expect(gameId).toBe('game_2'); + expect(String(idempotencyKey)).toContain('game_2'); + expect(payload).toEqual({ expectedPublicationRevision: 7 }); + await waitFor(() => + expect(listAdminGameDistributionGames).toHaveBeenCalledTimes(2), + ); + expect(await screen.findByText('游戏《下架游戏》已恢复')).toBeTruthy(); +}); + +test('下架需要原因和二次确认,提交原因与当前公开修订号', async () => { + render( + , + ); + + await screen.findByText('测试游戏'); + fireEvent.change(screen.getByLabelText('下架原因'), { + target: { value: '违规内容' }, + }); + fireEvent.click(screen.getByRole('button', { name: '下架' })); + + expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled(); + await screen.findByRole('dialog'); + 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('游戏《测试游戏》已下架')).toBeTruthy(); +}); + +test('版本历史弹层展示版本条目与统计信息', async () => { + render( + , + ); + + await screen.findByText('测试游戏'); + fireEvent.click(screen.getByRole('button', { name: '版本历史' })); + const dialog = await screen.findByRole('dialog'); + + expect(within(dialog).getByText('共 21 个版本,展示最近 20 个')).toBeTruthy(); + expect(within(dialog).getByText('v3')).toBeTruthy(); + expect(within(dialog).getByText('published')).toBeTruthy(); + expect(within(dialog).getByText('2.0 KiB')).toBeTruthy(); + expect(within(dialog).getByText('abcdef012345')).toBeTruthy(); + expect(within(dialog).getByText('测试原因')).toBeTruthy(); + expect( + within(dialog).getByText('https://game.example.com/game_1'), + ).toBeTruthy(); +}); + +test('接口失败显示错误文案', async () => { + vi.mocked(listAdminGameDistributionGames).mockRejectedValue( + new Error('游戏列表读取失败'), + ); + + render( + , + ); + + expect(await screen.findByText('游戏列表读取失败')).toBeTruthy(); +}); + +test('401 交给 onUnauthorized 处理', async () => { + const onUnauthorized = vi.fn(); + vi.mocked(listAdminGameDistributionGames).mockRejectedValue( + Object.assign(new Error('未授权'), { status: 401 }), + ); + + render( + , + ); + + await waitFor(() => + expect(onUnauthorized).toHaveBeenCalledWith('登录状态已失效'), + ); + expect(screen.queryByRole('alert')).toBeNull(); +}); diff --git a/apps/admin-web/src/pages/AdminGameManagementPage.tsx b/apps/admin-web/src/pages/AdminGameManagementPage.tsx new file mode 100644 index 000000000..2ab32bff4 --- /dev/null +++ b/apps/admin-web/src/pages/AdminGameManagementPage.tsx @@ -0,0 +1,456 @@ +import { RefreshCcw, X } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; + +import { + listAdminGameDistributionGames, + restoreAdminGameDistributionGame, + suspendAdminGameDistributionGame, +} from '../api/adminApiClient'; +import type { + AdminGameDistributionGameEntry, + AdminGameDistributionGameVersionEntry, +} from '../api/adminApiTypes'; +import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; +import { handlePageError } from './pageUtils'; + +interface AdminGameManagementPageProps { + token: string; + onUnauthorized: (message?: string) => void; +} + +const GAME_STATUS_META: Record = { + published: { label: '已公开', className: 'admin-status-ok' }, + suspended: { label: '已下架', className: 'admin-status-error' }, + unpublished: { label: '未公开', className: 'admin-status-pending' }, +}; + +function gameStatusMeta(status: string) { + return ( + GAME_STATUS_META[status] ?? { + label: status || '—', + className: 'admin-status-pending', + } + ); +} + +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 formatOptionalTime(value: string | null) { + return value ? formatTime(value) : '—'; +} + +function authorName(entry: AdminGameDistributionGameEntry) { + return entry.author?.name?.trim() || '—'; +} + +function authorInitial(entry: AdminGameDistributionGameEntry) { + const name = authorName(entry); + return name === '—' ? '—' : (Array.from(name)[0] ?? '—'); +} + +function createGameActionIdempotencyKey( + action: 'suspend' | 'restore', + gameId: string, +) { + const random = + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`; + const prefix = action === 'suspend' ? 'game-suspend' : 'game-restore'; + return `${prefix}-${gameId}-${random}`.slice(0, 128); +} + +export function AdminGameManagementPage({ + token, + onUnauthorized, +}: AdminGameManagementPageProps) { + const [games, setGames] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [busyGameId, setBusyGameId] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const [statusMessage, setStatusMessage] = useState(''); + const [suspendReasonByGame, setSuspendReasonByGame] = useState< + Record + >({}); + const [versionGame, setVersionGame] = + useState(null); + const writeConfirm = useAdminWriteConfirm(); + + const loadGames = useCallback(async () => { + setIsLoading(true); + setErrorMessage(''); + try { + const response = await listAdminGameDistributionGames(token, { + limit: 50, + }); + setGames(response.games); + } catch (error) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsLoading(false); + } + }, [token, onUnauthorized]); + + useEffect(() => { + void loadGames(); + }, [loadGames]); + + useEffect(() => { + if (!versionGame) return undefined; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setVersionGame(null); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [versionGame]); + + async function suspendGame(entry: AdminGameDistributionGameEntry) { + const reason = (suspendReasonByGame[entry.gameId] ?? '').trim(); + if (!reason) { + setErrorMessage('下架原因不能为空'); + setStatusMessage(''); + return; + } + + const confirmed = await writeConfirm.confirmWrite({ + action: '下架游戏', + target: `${entry.title || entry.gameId}(${entry.gameId})`, + }); + if (!confirmed) return; + + setBusyGameId(entry.gameId); + setErrorMessage(''); + setStatusMessage(''); + try { + await suspendAdminGameDistributionGame( + token, + entry.gameId, + createGameActionIdempotencyKey('suspend', entry.gameId), + { + expectedPublicationRevision: entry.publicationRevision, + reason, + }, + ); + setStatusMessage(`游戏《${entry.title || entry.gameId}》已下架`); + setSuspendReasonByGame((current) => ({ + ...current, + [entry.gameId]: '', + })); + await loadGames(); + } catch (error) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setBusyGameId(''); + } + } + + async function restoreGame(entry: AdminGameDistributionGameEntry) { + const confirmed = await writeConfirm.confirmWrite({ + action: '恢复游戏', + target: `${entry.title || entry.gameId}(${entry.gameId})`, + }); + if (!confirmed) return; + + setBusyGameId(entry.gameId); + setErrorMessage(''); + setStatusMessage(''); + try { + await restoreAdminGameDistributionGame( + token, + entry.gameId, + createGameActionIdempotencyKey('restore', entry.gameId), + { expectedPublicationRevision: entry.publicationRevision }, + ); + setStatusMessage(`游戏《${entry.title || entry.gameId}》已恢复`); + await loadGames(); + } catch (error) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setBusyGameId(''); + } + } + + return ( +
+
+

游戏管理

+ +
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + {statusMessage ? ( +
+ {statusMessage} +
+ ) : null} + +
+
+

游戏列表

+ 共 {games.length} 条 +
+ + {isLoading ? ( +

正在加载游戏列表…

+ ) : null} + + {!isLoading && games.length === 0 ? ( +

暂无游戏。

+ ) : null} + + {!isLoading && games.length > 0 ? ( +
+ + + + + + + + + + + + + + {games.map((entry) => { + const status = gameStatusMeta(entry.status); + const isSuspended = entry.status === 'suspended'; + const busy = busyGameId === entry.gameId; + return ( + + + + + + + + + + ); + })} + +
标题作者gameId状态版本数游玩数操作
+ {entry.title?.trim() || '—'} + +
+
+ {entry.author?.avatarUrl ? ( + {`${authorName(entry)} + ) : ( + authorInitial(entry) + )} +
+
+ {authorName(entry)} + {entry.author?.id?.trim() || '—'} +
+
+
+ {entry.gameId} + + + {status.label} + + {entry.versionCount}{entry.playCount} +
+ {!isSuspended ? ( + <> +
+ + + setSuspendReasonByGame((current) => ({ + ...current, + [entry.gameId]: event.target.value, + })) + } + disabled={busy} + /> +
+ + + ) : ( + + )} + +
+
+
+ ) : null} +
+ + {versionGame ? ( +
{ + if (event.target === event.currentTarget) { + setVersionGame(null); + } + }} + > +
+
+
+

版本历史

+ + {versionGame.title?.trim() || '—'}({versionGame.gameId}) + +
+ +
+

+ 共 {versionGame.versionCount} 个版本,展示最近 20 个 +

+ {versionGame.versions.length === 0 ? ( +

暂无版本记录。

+ ) : ( +
+ + + + + + + + + + + + + + + + {versionGame.versions.map((version) => ( + + ))} + +
版本状态包大小SHA创建时间审核时间公开时间审核原因发行入口
+
+ )} +
+
+ ) : null} + + {writeConfirm.confirmDialog} +
+ ); +} + +function GameVersionRow({ + version, +}: { + version: AdminGameDistributionGameVersionEntry; +}) { + return ( + + v{version.versionNumber} + {version.status || '—'} + {formatBytes(version.packageBytes)} + + {version.packageSha256?.slice(0, 12) || '—'} + + {formatOptionalTime(version.createdAt)} + {formatOptionalTime(version.reviewedAt)} + {formatOptionalTime(version.publishedAt)} + {version.reviewReason?.trim() || '—'} + + {version.entryUrl ? ( + + {version.entryUrl} + + ) : ( + '—' + )} + + + ); +} diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx index 81e4fcbde..79f150653 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx @@ -64,6 +64,7 @@ const baseOrder: AdminRechargeOrderEntryPayload = { productTitle: '60泥点', productKind: 'points', amountCents: 600, + paidAmountCents: 600, status: 'paid', paymentChannel: 'wechat_native', paidAtMicros: 1_720_000_000_000_000, @@ -129,6 +130,40 @@ beforeEach(() => { ); }); +test('未支付订单不显示实付金额,发放泥点单独成列', async () => { + vi.mocked(listAdminRechargeOrders).mockResolvedValue({ + entries: [ + { + ...baseOrder, + orderId: 'order-pending', + status: 'pending', + paidAtMicros: null, + paidAmountCents: 0, + pointsDelta: 0, + }, + { ...baseOrder, orderId: 'order-paid' }, + ], + }); + renderPage(); + + expect( + await screen.findByRole('columnheader', { name: '实付' }), + ).toBeTruthy(); + expect(screen.getByRole('columnheader', { name: '发放泥点' })).toBeTruthy(); + + const unpaidRow = (await screen.findByText('order-pending')).closest( + 'tr', + ) as HTMLElement; + const unpaidCells = within(unpaidRow).getAllByRole('cell'); + expect(unpaidCells[3]?.textContent).toContain('未支付'); + expect(unpaidCells[4]?.textContent).toBe('0 泥点'); + + const paidRow = screen.getByText('order-paid').closest('tr') as HTMLElement; + const paidCells = within(paidRow).getAllByRole('cell'); + expect(paidCells[3]?.textContent).toBe('¥6.00'); + expect(paidCells[4]?.textContent).toBe('60 泥点'); +}); + test('充值订单查询传递全部筛选字段', async () => { const user = userEvent.setup(); renderPage(); diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx index d1f4848b0..04188469c 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx @@ -658,7 +658,8 @@ export function AdminRechargeOrderPage({ 用户 订单 支付 - 金额 / 泥点 + 实付 + 发放泥点 退款与追回 钱包 状态 @@ -715,7 +716,7 @@ export function AdminRechargeOrderPage({
- {formatMoney(order.amountCents)} - 发放 {order.pointsDelta} 泥点 + {formatOrderPaidAmount(order)} + {order.paidAmountCents > 0 ? null : ( + 订单 {formatMoney(order.amountCents)} + )} + + + {order.pointsDelta} 泥点 累计 {formatMoney(order.cumulativeSuccessRefundCents)} @@ -1312,6 +1318,13 @@ function formatMoney(cents: number) { return `¥${(cents / 100).toFixed(2)}`; } +/** 实付只属于真正支付过的订单:未支付 / 已关闭 / 已过期订单显示“未支付”。 */ +function formatOrderPaidAmount(order: AdminRechargeOrderEntryPayload) { + return order.paidAmountCents > 0 + ? formatMoney(order.paidAmountCents) + : '未支付'; +} + function formatCentsInput(cents: number) { return (cents / 100).toFixed(2); } diff --git a/apps/admin-web/src/pages/AdminRedeemCodePage.tsx b/apps/admin-web/src/pages/AdminRedeemCodePage.tsx index 0e745a308..1519bb867 100644 --- a/apps/admin-web/src/pages/AdminRedeemCodePage.tsx +++ b/apps/admin-web/src/pages/AdminRedeemCodePage.tsx @@ -217,7 +217,7 @@ export function AdminRedeemCodePage({