合并 origin/master:网站游戏评分与评价
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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<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 } = {},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' ? (
|
||||
<AdminGameManagementPage
|
||||
token={token}
|
||||
canManageReviews={accessibleRoutes.some(
|
||||
(route) => route.id === 'game-reviews',
|
||||
)}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'game-reviews' ? (
|
||||
<AdminGameReviewsPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -117,6 +117,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();
|
||||
|
||||
@@ -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<AdminGameDistributionGameEntry[]>([]);
|
||||
@@ -327,6 +329,17 @@ export function AdminGameManagementPage({
|
||||
>
|
||||
版本历史
|
||||
</AdminButton>
|
||||
{canManageReviews ? (
|
||||
<AdminButton
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.location.hash = `#game-reviews?gameId=${encodeURIComponent(entry.gameId)}`;
|
||||
}}
|
||||
>
|
||||
查看评价
|
||||
</AdminButton>
|
||||
) : null}
|
||||
</AdminActionRow>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -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
@@ -766,6 +766,33 @@ 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;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.admin-review-filters {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.admin-table-query-grid {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
|
||||
@@ -1670,11 +1670,13 @@ if (!viteConfigSource.includes('allow: [repoRoot]')) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!(
|
||||
tauriConfig.build?.beforeDevCommand?.includes(
|
||||
'run ai-game-creator-shell:dev-server',
|
||||
) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve')
|
||||
)) {
|
||||
if (
|
||||
!(
|
||||
tauriConfig.build?.beforeDevCommand?.includes(
|
||||
'run ai-game-creator-shell:dev-server',
|
||||
) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve')
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'AI game creator shell beforeDevCommand must start the selected Vite dev server',
|
||||
);
|
||||
|
||||
@@ -269,7 +269,7 @@ async function prepareFrontendDev(endpoint, { onChild, signal }) {
|
||||
function isDirectModuleExecution() {
|
||||
return Boolean(
|
||||
process.argv[1] &&
|
||||
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
|
||||
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user