diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index 7c5464f06..8c6d85581 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -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( diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 8aae410af..ec574237d 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -38,6 +38,13 @@ import type { AdminGameDistributionReviewListResponse, AdminGameDistributionReviewRequest, AdminGameDistributionReviewResponse, + AdminGameReviewDetailResponse, + AdminGameReviewGamesQuery, + AdminGameReviewGamesResponse, + AdminGameReviewModerationRequest, + AdminGameReviewModerationResponse, + AdminGameReviewsQuery, + AdminGameReviewsResponse, AdminImportAgcTemplatesResponse, AdminLoginResponse, AdminMeResponse, @@ -1243,6 +1250,61 @@ export function listAdminGameDistributionReviews(token: string, limit = 48) { ); } +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( + `/admin/api/game-distribution/user-review-games?${gameReviewQuery(query)}`, + { token }, + ); +} + +export function listAdminGameReviews( + token: string, + query: AdminGameReviewsQuery = {}, +) { + return request( + `/admin/api/game-distribution/user-reviews?${gameReviewQuery(query)}`, + { token }, + ); +} + +export function getAdminGameReview(token: string, reviewId: string) { + return request( + `/admin/api/game-distribution/user-reviews/${encodeURIComponent(reviewId)}`, + { token }, + ); +} + +export function moderateAdminGameReview( + token: string, + reviewId: string, + idempotencyKey: string, + payload: AdminGameReviewModerationRequest, +) { + return request( + `/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 } = {}, diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 79acaa55c..c3a9e7256 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1186,6 +1186,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; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 8caad4598..374bc8e45 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -31,6 +31,7 @@ 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'; @@ -141,7 +142,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]); @@ -325,6 +326,15 @@ export function AdminApp() { ) : null} {activeRouteId === 'game-management' ? ( route.id === 'game-reviews', + )} + onUnauthorized={handleUnauthorized} + /> + ) : null} + {activeRouteId === 'game-reviews' ? ( + diff --git a/apps/admin-web/src/app/AdminShell.tsx b/apps/admin-web/src/app/AdminShell.tsx index 9db6e1c66..cc043db90 100644 --- a/apps/admin-web/src/app/AdminShell.tsx +++ b/apps/admin-web/src/app/AdminShell.tsx @@ -55,6 +55,7 @@ const routeIcons = { 'editor-showcase': Star, 'game-distribution': Gamepad2, 'game-management': Swords, + 'game-reviews': Star, 'editor-assets': Images, 'project-snapshots': FolderArchive, accounts: Users, diff --git a/apps/admin-web/src/app/adminRoutes.test.ts b/apps/admin-web/src/app/adminRoutes.test.ts index c6dd8430a..1e5898280 100644 --- a/apps/admin-web/src/app/adminRoutes.test.ts +++ b/apps/admin-web/src/app/adminRoutes.test.ts @@ -8,6 +8,22 @@ import { routeHash, } from './adminRoutes'; +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( diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index d6c948348..c72a231f8 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -18,6 +18,7 @@ export type AdminRouteId = | 'editor-showcase' | 'game-distribution' | 'game-management' + | 'game-reviews' | 'editor-assets' | 'project-snapshots' | 'agc-models' @@ -62,6 +63,7 @@ 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 }, diff --git a/apps/admin-web/src/pages/AdminGameManagementPage.test.tsx b/apps/admin-web/src/pages/AdminGameManagementPage.test.tsx index 18407c5bb..a105c679a 100644 --- a/apps/admin-web/src/pages/AdminGameManagementPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGameManagementPage.test.tsx @@ -117,6 +117,20 @@ beforeEach(() => { }); }); +test('有评价权限时游戏管理提供带gameId的评价入口', async () => { + window.history.replaceState(null, '', '#game-management'); + render( + , + ); + 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(); diff --git a/apps/admin-web/src/pages/AdminGameManagementPage.tsx b/apps/admin-web/src/pages/AdminGameManagementPage.tsx index 074a92927..75a8f8a29 100644 --- a/apps/admin-web/src/pages/AdminGameManagementPage.tsx +++ b/apps/admin-web/src/pages/AdminGameManagementPage.tsx @@ -28,6 +28,7 @@ import { handlePageError } from './pageUtils'; interface AdminGameManagementPageProps { token: string; + canManageReviews?: boolean; onUnauthorized: (message?: string) => void; } @@ -86,6 +87,7 @@ function createGameActionIdempotencyKey( export function AdminGameManagementPage({ token, + canManageReviews = false, onUnauthorized, }: AdminGameManagementPageProps) { const [games, setGames] = useState([]); @@ -327,6 +329,17 @@ export function AdminGameManagementPage({ > 版本历史 + {canManageReviews ? ( + { + window.location.hash = `#game-reviews?gameId=${encodeURIComponent(entry.gameId)}`; + }} + > + 查看评价 + + ) : null} diff --git a/apps/admin-web/src/pages/AdminGameReviewsPage.test.tsx b/apps/admin-web/src/pages/AdminGameReviewsPage.test.tsx new file mode 100644 index 000000000..286087a05 --- /dev/null +++ b/apps/admin-web/src/pages/AdminGameReviewsPage.test.tsx @@ -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: '中文😀\n完整正文', + isHidden: false, + createdAt: '2026-10-01T01:00:00Z', + updatedAt: '2026-10-01T01:00:00Z', +}; +function list( + overrides: Partial = {}, +): 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( + , + ); +} +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((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>, + ) => void; + vi.mocked(moderateAdminGameReview).mockImplementationOnce( + () => + new Promise>>( + (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(); +}); diff --git a/apps/admin-web/src/pages/AdminGameReviewsPage.tsx b/apps/admin-web/src/pages/AdminGameReviewsPage.tsx new file mode 100644 index 000000000..a7ad0e543 --- /dev/null +++ b/apps/admin-web/src/pages/AdminGameReviewsPage.tsx @@ -0,0 +1,673 @@ +import { + AdminActionRow, + AdminAlert, + AdminButton, + AdminDialog, + AdminFieldGroup, + AdminInfoList, + AdminListPanel, + AdminPage, + AdminPageHeading, + AdminPagination, + AdminPanel, + AdminStatusPill, + formatAdminDateTime, +} from '@genarrative/shared/components'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { + getAdminGameReview, + listAdminGameReviewGames, + listAdminGameReviews, + moderateAdminGameReview, +} from '../api/adminApiClient'; +import type { + AdminGameReview, + AdminGameReviewDetailResponse, + AdminGameReviewGamesResponse, + AdminGameReviewModerationRequest, + AdminGameReviewsQuery, + AdminGameReviewsResponse, +} from '../api/adminApiTypes'; +import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; +import { handlePageError } from './pageUtils'; + +interface PageProps { + token: string; + onUnauthorized: (message?: string) => void; +} + +type Filters = Required< + Pick +>; +type Action = AdminGameReviewModerationRequest['action']; +const actionLabels = { hide: '隐藏', restore: '恢复', delete: '删除' }; +const emptyFilters: Filters = { + gameId: '', + userId: '', + keyword: '', + status: 'all', +}; + +function filtersFromHash(): Filters { + const params = new URLSearchParams(window.location.hash.split('?')[1] ?? ''); + return { ...emptyFilters, gameId: params.get('gameId')?.trim() ?? '' }; +} + +function commentSummary(comment: string) { + if (!comment) return '仅评分'; + const chars = Array.from(comment); + return chars.length > 120 ? `${chars.slice(0, 120).join('')}…` : comment; +} + +export function AdminGameReviewsPage({ token, onUnauthorized }: PageProps) { + const [draft, setDraft] = useState(filtersFromHash); + const [query, setQuery] = useState(() => ({ + ...filtersFromHash(), + page: 1, + pageSize: 20, + })); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); + const [pickingGame, setPickingGame] = useState(false); + const [detailId, setDetailId] = useState(''); + const [pending, setPending] = useState<{ + review: AdminGameReview; + action: Action; + } | null>(null); + const [reason, setReason] = useState(''); + const [saveError, setSaveError] = useState(''); + const [saving, setSaving] = useState(false); + const listSerial = useRef(0); + const queryRef = useRef(query); + queryRef.current = query; + const attempt = useRef<{ fingerprint: string; key: string } | null>(null); + const writeConfirm = useAdminWriteConfirm(); + + const load = useCallback( + async (nextQuery: AdminGameReviewsQuery) => { + const serial = ++listSerial.current; + setLoading(true); + setError(''); + setResult(null); + try { + const response = await listAdminGameReviews(token, nextQuery); + if (serial !== listSerial.current) return; + const lastPage = Math.max(1, response.totalPages); + if ((nextQuery.page ?? 1) > lastPage) { + setQuery({ ...nextQuery, page: lastPage }); + return; + } + setResult(response); + } catch (failure) { + if (serial === listSerial.current) + handlePageError(failure, onUnauthorized, setError); + } finally { + if (serial === listSerial.current) setLoading(false); + } + }, + [token, onUnauthorized], + ); + + useEffect(() => { + const requestCounter = listSerial; + void load(query); + return () => { + requestCounter.current++; + }; + }, [load, query]); + + useEffect(() => { + const changed = () => { + const filters = filtersFromHash(); + setDraft(filters); + setQuery({ ...filters, page: 1, pageSize: 20 }); + setDetailId(''); + setPending(null); + setNotice(''); + }; + window.addEventListener('hashchange', changed); + return () => window.removeEventListener('hashchange', changed); + }, []); + + async function perform(target: { review: AdminGameReview; action: Action }) { + const submittedQuery = queryRef.current; + const normalizedReason = reason.replace(/\r\n?/g, '\n').trim(); + if ( + target.action !== 'restore' && + (!normalizedReason || Array.from(normalizedReason).length > 4000) + ) { + setSaveError('原因必须为 1–4000 个字符'); + return; + } + const body: AdminGameReviewModerationRequest = { + action: target.action, + expectedCreatedAt: target.review.createdAt, + ...(target.action === 'restore' ? {} : { reason: normalizedReason }), + }; + const fingerprint = JSON.stringify([target.review.id, body]); + if (attempt.current?.fingerprint !== fingerprint) { + attempt.current = { fingerprint, key: `review-${crypto.randomUUID()}` }; + } + setSaving(true); + setSaveError(''); + try { + await moderateAdminGameReview( + token, + target.review.id, + attempt.current.key, + body, + ); + if (queryRef.current === submittedQuery) { + setPending(null); + setDetailId(''); + setNotice(`评价已${actionLabels[target.action]}`); + } + await load(queryRef.current); + } catch (failure) { + handlePageError(failure, onUnauthorized, (message) => { + if (queryRef.current === submittedQuery) setSaveError(message); + }); + } finally { + setSaving(false); + } + } + + async function startAction(review: AdminGameReview, action: Action) { + if (action === 'restore') { + const confirmed = await writeConfirm.confirmWrite({ + action: '恢复评价', + target: `${review.game.title || review.gameId} · ${review.author.name || review.author.id}`, + }); + if (!confirmed) return; + } + const target = { review, action }; + setPending(target); + setReason(''); + setSaveError(''); + attempt.current = null; + if (action === 'restore') await perform(target); + } + + const page = query.page ?? 1; + const pages = result?.totalPages ?? 0; + return ( + + void load(query)} + > + 刷新 + + } + /> + {error ? ( + + {error} + + ) : null} + {notice ? {notice} : null} + +
{ + event.preventDefault(); + setNotice(''); + setQuery({ ...draft, page: 1, pageSize: 20 }); + }} + > + + + setDraft({ ...draft, gameId: event.target.value }) + } + /> + setPickingGame(true)} + > + 按名称查找游戏 + + + + + setDraft({ ...draft, userId: event.target.value }) + } + /> + + + + setDraft({ ...draft, keyword: event.target.value }) + } + /> + + + + + + 查询 + { + setDraft(emptyFilters); + setQuery({ ...emptyFilters, page: 1, pageSize: 20 }); + setNotice(''); + }} + > + 重置 + + +
+
+ ( + + + {review.game.title || '—'} +
+ {review.gameId} +
+ + + {review.author.name || '—'} +
+ {review.author.id} +
+ + {review.score}/10 + +

+ {commentSummary(review.comment)} +

+ + + + {review.isHidden ? '已隐藏' : '公开'} + + + {formatAdminDateTime(review.createdAt)} + {formatAdminDateTime(review.updatedAt)} + + + setDetailId(review.id)} + > + 查看详情 + + + void startAction( + review, + review.isHidden ? 'restore' : 'hide', + ) + } + > + {review.isHidden ? '恢复' : '隐藏'} + + void startAction(review, 'delete')} + > + 删除 + + + + + )} + /> + setQuery({ ...query, page: page - 1 }), + }} + next={{ + disabled: page >= pages, + onClick: () => setQuery({ ...query, page: page + 1 }), + }} + /> + {pickingGame ? ( + setPickingGame(false)} + onSelect={(gameId) => { + setDraft({ ...draft, gameId }); + setPickingGame(false); + }} + /> + ) : null} + {detailId ? ( + setDetailId('')} + /> + ) : null} + {pending ? ( + { + if (!saving) setPending(null); + }} + > +

+ {pending.review.game.title || pending.review.gameId} ·{' '} + {pending.review.author.name || pending.review.author.id} ·{' '} + {pending.review.score}/10 +

+ {pending.action === 'delete' ? ( + + 删除后不可恢复,用户可以重新评价 + + ) : null} + {pending.action !== 'restore' ? ( + +