diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index 8141587..2d77d94 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -2406,6 +2406,100 @@ "x-permissions": ["game:status"] } }, + "/admin/game/games/{game_id}/whitelist": { + "patch": { + "operationId": "setGameWhitelistEnabled", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "game_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "x-permission": "game:update", + "x-permissions": ["game:update"] + } + }, + "/admin/game/games/{game_id}/whitelist/users": { + "get": { + "operationId": "listGameWhitelistUsers", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "game_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "x-permission": "game:view", + "x-permissions": ["game:view"] + }, + "post": { + "operationId": "addGameWhitelistUser", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "game_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "x-permission": "game:update", + "x-permissions": ["game:update"] + } + }, + "/admin/game/games/{game_id}/whitelist/users/{user_id}": { + "delete": { + "operationId": "deleteGameWhitelistUser", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "game_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "x-permission": "game:update", + "x-permissions": ["game:update"] + } + }, "/admin/game/platforms": { "get": { "operationId": "listPlatforms", diff --git a/src/features/games/api.ts b/src/features/games/api.ts index 924667b..1ff9519 100644 --- a/src/features/games/api.ts +++ b/src/features/games/api.ts @@ -40,6 +40,17 @@ export interface GameCatalogDto { tags: string[]; createdAtMs?: number; updatedAtMs?: number; + whitelistEnabled: boolean; +} + +export interface GameWhitelistUserDto { + userId: string; + displayUserId?: string; + username?: string; + avatar?: string; + status?: string; + createdByAdminId?: number; + createdAtMs?: number; } export interface GameCatalogPage { @@ -254,7 +265,7 @@ export type GamePlatformPayload = Omit< GamePlatformDto, "appCode" | "createdAtMs" | "updatedAtMs" | "callbackSecretSet" >; -export type GameCatalogPayload = Omit; +export type GameCatalogPayload = Omit; export interface GameCatalogQuery { platformCode?: string; @@ -338,6 +349,40 @@ export function deleteGameCatalog(gameId: string): Promise { }); } +export function setGameWhitelistEnabled(gameId: string, enabled: boolean): Promise { + const endpoint = API_ENDPOINTS.setGameWhitelistEnabled; + return apiRequest( + apiEndpointPath(API_OPERATIONS.setGameWhitelistEnabled, { game_id: gameId }), + { body: { enabled }, method: endpoint.method }, + ).then(normalizeCatalog); +} + +export function listGameWhitelistUsers( + gameId: string, +): Promise<{ items: GameWhitelistUserDto[]; serverTimeMs?: number }> { + const endpoint = API_ENDPOINTS.listGameWhitelistUsers; + return apiRequest<{ items: GameWhitelistUserDto[]; serverTimeMs?: number }>( + apiEndpointPath(API_OPERATIONS.listGameWhitelistUsers, { game_id: gameId }), + { method: endpoint.method, query: { pageSize: 200 } }, + ).then((result) => ({ ...result, items: (result.items || []).map(normalizeGameWhitelistUser) })); +} + +export function addGameWhitelistUser(gameId: string, userId: string): Promise { + const endpoint = API_ENDPOINTS.addGameWhitelistUser; + return apiRequest( + apiEndpointPath(API_OPERATIONS.addGameWhitelistUser, { game_id: gameId }), + { body: { userId }, method: endpoint.method }, + ).then(normalizeGameWhitelistUser); +} + +export function deleteGameWhitelistUser(gameId: string, userId: string): Promise { + const endpoint = API_ENDPOINTS.deleteGameWhitelistUser; + return apiRequest( + apiEndpointPath(API_OPERATIONS.deleteGameWhitelistUser, { game_id: gameId, user_id: userId }), + { method: endpoint.method }, + ); +} + export function syncGamePlatformCatalog(platformCode: string, payload: GameSyncPayload = {}): Promise { return apiRequest( `/v1/admin/game/platforms/${encodeURIComponent(platformCode)}/sync-games`, @@ -559,6 +604,19 @@ function normalizeCatalog(game: Partial): GameCatalogDto { tags: Array.isArray(game.tags) ? game.tags.map(String).filter(Boolean) : [], createdAtMs: numberValue(game.createdAtMs), updatedAtMs: numberValue(game.updatedAtMs), + whitelistEnabled: game.whitelistEnabled === true, + }; +} + +function normalizeGameWhitelistUser(user: Partial): GameWhitelistUserDto { + return { + userId: stringValue(user.userId), + displayUserId: stringValue(user.displayUserId), + username: stringValue(user.username), + avatar: stringValue(user.avatar), + status: stringValue(user.status), + createdByAdminId: numberValue(user.createdByAdminId), + createdAtMs: numberValue(user.createdAtMs), }; } diff --git a/src/features/games/components/GameWhitelistDialog.jsx b/src/features/games/components/GameWhitelistDialog.jsx new file mode 100644 index 0000000..3ee0b63 --- /dev/null +++ b/src/features/games/components/GameWhitelistDialog.jsx @@ -0,0 +1,119 @@ +import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; +import PersonAddAltOutlined from "@mui/icons-material/PersonAddAltOutlined"; +import Avatar from "@mui/material/Avatar"; +import CircularProgress from "@mui/material/CircularProgress"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import TextField from "@mui/material/TextField"; +import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; +import { Button } from "@/shared/ui/Button.jsx"; +import { AdminActionIconButton } from "@/shared/ui/AdminListLayout.jsx"; +import { formatMillis } from "@/shared/utils/time.js"; +import styles from "@/features/games/games.module.css"; + +export function GameWhitelistDialog({ open, page, onClose }) { + const game = page.whitelistGame; + const enabledLoading = page.loadingAction === `whitelist-enabled-${game?.gameId}`; + const addLoading = page.loadingAction === `whitelist-add-${game?.gameId}`; + + return ( + + 游戏白名单 · {game?.gameName || game?.gameId || ""} + +
+
+ 仅对白名单用户开放 + 开启后,非成员不会在游戏列表看到此游戏,也不能直接启动。 +
+ page.changeWhitelistEnabled(event.target.checked)} + /> +
+ +
+ page.setWhitelistUserId(event.target.value)} + /> + + + +
+
+ 用户 + 用户 ID + 状态 + 加入时间 + 操作 +
+ {page.whitelistLoading ? ( +
+ +
+ ) : page.whitelistItems.length > 0 ? ( + page.whitelistItems.map((item) => ( +
+
+ + {(item.username || item.displayUserId || item.userId).slice(0, 1)} + + {item.username || "-"} +
+
+ {item.userId} + {item.displayUserId ? ( + {item.displayUserId} + ) : null} +
+ {item.status || "-"} + {formatMillis(item.createdAtMs)} + page.removeWhitelistUser(item)} + > + + +
+ )) + ) : ( +
当前没有白名单用户
+ )} +
+
+ + + +
+ ); +} diff --git a/src/features/games/components/GameWhitelistDialog.test.jsx b/src/features/games/components/GameWhitelistDialog.test.jsx new file mode 100644 index 0000000..2e1a9aa --- /dev/null +++ b/src/features/games/components/GameWhitelistDialog.test.jsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { expect, test, vi } from "vitest"; +import { AppProviders } from "@/app/providers.jsx"; +import { GameWhitelistDialog } from "./GameWhitelistDialog.jsx"; + +test("renders current members and exposes whitelist toggle, add and remove actions", async () => { + const user = userEvent.setup(); + const page = { + abilities: { canUpdate: true }, + addWhitelistUser: vi.fn((event) => event.preventDefault()), + changeWhitelistEnabled: vi.fn(), + loadingAction: "", + removeWhitelistUser: vi.fn(), + setWhitelistUserId: vi.fn(), + whitelistGame: { gameId: "private_slot", gameName: "Private Slot", whitelistEnabled: true }, + whitelistItems: [ + { + createdAtMs: 1700000000000, + displayUserId: "90001", + status: "active", + userId: "42", + username: "Alice", + }, + ], + whitelistLoading: false, + whitelistUserId: "42", + }; + + render( + + + , + ); + + expect(screen.getByText("游戏白名单 · Private Slot")).toBeInTheDocument(); + expect(screen.getByText("Alice")).toBeInTheDocument(); + expect(screen.getByText("90001")).toBeInTheDocument(); + + await user.click(screen.getByRole("switch", { name: "游戏白名单状态" })); + expect(page.changeWhitelistEnabled).toHaveBeenCalledWith(false); + + await user.click(screen.getByRole("button", { name: "添加" })); + expect(page.addWhitelistUser).toHaveBeenCalledOnce(); + + await user.click(screen.getByRole("button", { name: "移出白名单" })); + expect(page.removeWhitelistUser).toHaveBeenCalledWith(page.whitelistItems[0]); +}); diff --git a/src/features/games/games.module.css b/src/features/games/games.module.css index 55e6107..ebeb116 100644 --- a/src/features/games/games.module.css +++ b/src/features/games/games.module.css @@ -334,3 +334,98 @@ max-width: 360px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; } + +.whitelistDialogPaper { + width: min(980px, calc(100vw - 32px)); + max-height: min(760px, calc(100vh - 32px)); + border-radius: 12px; +} + +.whitelistDialogContent { + display: grid; + gap: 16px; +} + +.whitelistPolicyRow, +.whitelistSearchRow { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 16px; + align-items: center; +} + +.whitelistPolicyRow { + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-card-strong); +} + +.whitelistSearchRow > :last-child { + min-width: 104px; + min-height: 52px; +} + +.whitelistTable { + overflow: auto; + max-height: 440px; + border: 1px solid var(--border); + border-radius: 10px; +} + +.whitelistTableHeader, +.whitelistTableRow { + display: grid; + grid-template-columns: minmax(180px, 1.2fr) minmax(150px, 1fr) 88px 180px 64px; + gap: 12px; + align-items: center; + min-width: 760px; + padding: 10px 14px; +} + +.whitelistTableHeader { + position: sticky; + z-index: 1; + top: 0; + border-bottom: 1px solid var(--border); + background: var(--bg-table-head); + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; +} + +.whitelistTableRow { + border-bottom: 1px solid var(--border-soft); +} + +.whitelistTableRow:last-child { + border-bottom: 0; +} + +.whitelistUserIdentity { + display: flex; + min-width: 0; + align-items: center; + gap: 10px; +} + +.whitelistAvatar { + width: 36px; + height: 36px; + flex: 0 0 auto; +} + +.whitelistEmpty { + display: flex; + min-height: 160px; + align-items: center; + justify-content: center; + color: var(--text-tertiary); +} + +@media (max-width: 720px) { + .whitelistPolicyRow, + .whitelistSearchRow { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/src/features/games/hooks/useGamesPage.js b/src/features/games/hooks/useGamesPage.js index d104e04..2b827c6 100644 --- a/src/features/games/hooks/useGamesPage.js +++ b/src/features/games/hooks/useGamesPage.js @@ -1,9 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + addGameWhitelistUser, deleteGameCatalog, + deleteGameWhitelistUser, listGameCatalog, listGamePlatforms, + listGameWhitelistUsers, setGameStatus, + setGameWhitelistEnabled, syncGamePlatformCatalog, upsertGameCatalog, upsertGamePlatform, @@ -89,6 +93,10 @@ export function useGamesPage() { const [syncPlatform, setSyncPlatform] = useState(null); const [syncCandidates, setSyncCandidates] = useState([]); const [selectedSyncGameIds, setSelectedSyncGameIds] = useState([]); + const [whitelistGame, setWhitelistGame] = useState(null); + const [whitelistItems, setWhitelistItems] = useState([]); + const [whitelistUserId, setWhitelistUserId] = useState(""); + const [whitelistLoading, setWhitelistLoading] = useState(false); const catalogRequestRef = useRef(0); const platformsQueryFn = useCallback(() => listGamePlatforms(), []); @@ -208,6 +216,29 @@ export function useGamesPage() { setActiveAction("edit-game"); }; + const loadWhitelist = async (game) => { + if (!game?.gameId) { + return; + } + setWhitelistLoading(true); + try { + const result = await listGameWhitelistUsers(game.gameId); + setWhitelistItems(result.items || []); + } catch (err) { + showToast(err.message || "加载游戏白名单失败", "error"); + } finally { + setWhitelistLoading(false); + } + }; + + const openWhitelist = (game) => { + setWhitelistGame(game); + setWhitelistItems([]); + setWhitelistUserId(""); + setActiveAction("game-whitelist"); + loadWhitelist(game); + }; + const openCreatePlatform = () => { setEditingPlatformCode(""); setPlatformForm(defaultPlatformForm); @@ -238,6 +269,11 @@ export function useGamesPage() { if (activeAction === "bridge-script") { setBridgeScriptForm(defaultBridgeScriptForm); } + if (activeAction === "game-whitelist") { + setWhitelistGame(null); + setWhitelistItems([]); + setWhitelistUserId(""); + } setActiveAction(""); setEditingGameId(""); setEditingPlatformCode(""); @@ -377,6 +413,76 @@ export function useGamesPage() { } }; + const changeWhitelistEnabled = async (checked) => { + if (!whitelistGame) { + return; + } + setLoadingAction(`whitelist-enabled-${whitelistGame.gameId}`); + try { + const updated = await setGameWhitelistEnabled(whitelistGame.gameId, checked); + setWhitelistGame((current) => + current ? { ...current, whitelistEnabled: updated.whitelistEnabled } : current, + ); + setCatalogItems((current) => + current.map((game) => + game.gameId === whitelistGame.gameId + ? { ...game, whitelistEnabled: updated.whitelistEnabled } + : game, + ), + ); + showToast(checked ? "游戏白名单已开启" : "游戏白名单已关闭", "success"); + } catch (err) { + showToast(err.message || "白名单状态更新失败", "error"); + } finally { + setLoadingAction(""); + } + }; + + const addWhitelistUser = async (event) => { + event.preventDefault(); + const userId = whitelistUserId.trim(); + if (!whitelistGame || !/^\d+$/.test(userId) || userId === "0") { + showToast("请输入正确的用户 ID", "error"); + return; + } + setLoadingAction(`whitelist-add-${whitelistGame.gameId}`); + try { + await addGameWhitelistUser(whitelistGame.gameId, userId); + setWhitelistUserId(""); + await loadWhitelist(whitelistGame); + showToast("用户已加入白名单", "success"); + } catch (err) { + showToast(err.message || "添加白名单用户失败", "error"); + } finally { + setLoadingAction(""); + } + }; + + const removeWhitelistUser = async (item) => { + if (!whitelistGame) { + return; + } + const ok = await confirm({ + confirmText: "移除", + message: `移除用户 ${item.displayUserId || item.userId} 后,该用户将看不到且无法启动此游戏。`, + title: "移除白名单用户", + tone: "danger", + }); + if (!ok) { + return; + } + setLoadingAction(`whitelist-delete-${item.userId}`); + try { + await deleteGameWhitelistUser(whitelistGame.gameId, item.userId); + setWhitelistItems((current) => current.filter((user) => user.userId !== item.userId)); + showToast("用户已移出白名单", "success"); + } catch (err) { + showToast(err.message || "移除白名单用户失败", "error"); + } finally { + setLoadingAction(""); + } + }; + const fetchPlatformGames = async (platform) => { setLoadingAction(`fetch-games-${platform.platformCode}`); try { @@ -464,7 +570,9 @@ export function useGamesPage() { activeAction, allSyncGamesSelected, bridgeScriptForm, + addWhitelistUser, changeGameStatus, + changeWhitelistEnabled, deleteGame, closeAction, data: { @@ -487,6 +595,7 @@ export function useGamesPage() { openBridgeScript, openEditGame, openEditPlatform, + openWhitelist, openManagePlatforms, pageSize, platformCode, @@ -496,6 +605,7 @@ export function useGamesPage() { loadNextPage, reload: reloadCatalog, resetFilters, + removeWhitelistUser, resolveGameURL, changePageSize, setGameForm, @@ -512,6 +622,11 @@ export function useGamesPage() { selectedSyncGameIds, toggleAllSyncGames, toggleSyncGame, + whitelistGame, + whitelistItems, + whitelistLoading, + whitelistUserId, + setWhitelistUserId, }; } diff --git a/src/features/games/pages/GameListPage.jsx b/src/features/games/pages/GameListPage.jsx index 2c7043c..21f64f5 100644 --- a/src/features/games/pages/GameListPage.jsx +++ b/src/features/games/pages/GameListPage.jsx @@ -3,6 +3,7 @@ import CodeOutlined from "@mui/icons-material/CodeOutlined"; import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; import EditOutlined from "@mui/icons-material/EditOutlined"; import SettingsOutlined from "@mui/icons-material/SettingsOutlined"; +import GroupOutlined from "@mui/icons-material/GroupOutlined"; import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined"; import SyncOutlined from "@mui/icons-material/SyncOutlined"; import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx"; @@ -25,6 +26,7 @@ import { createOptionsColumnFilter } from "@/shared/ui/tableFilters.js"; import { UploadField } from "@/shared/ui/UploadField.jsx"; import { formatMillis } from "@/shared/utils/time.js"; import { useGamesPage } from "@/features/games/hooks/useGamesPage.js"; +import { GameWhitelistDialog } from "@/features/games/components/GameWhitelistDialog.jsx"; import styles from "@/features/games/games.module.css"; const statusOptions = [ @@ -105,7 +107,7 @@ const baseColumns = [ { key: "actions", label: "操作", - width: "minmax(112px, 0.5fr)", + width: "minmax(152px, 0.65fr)", }, ]; @@ -231,6 +233,7 @@ export function GameListPage() { onClose={page.closeAction} /> + ); } @@ -269,6 +272,15 @@ function GameStatusSwitch({ page, game }) { function GameRowActions({ page, game }) { return ( + page.openWhitelist(game)} + > + + = { + addGameWhitelistUser: { + method: "POST", + operationId: API_OPERATIONS.addGameWhitelistUser, + path: "/v1/admin/game/games/{game_id}/whitelist/users", + permission: "game:update", + permissions: ["game:update"] + }, adjustDicePool: { method: "POST", operationId: API_OPERATIONS.adjustDicePool, @@ -898,6 +909,13 @@ export const API_ENDPOINTS: Record = { permission: "app-config:update", permissions: ["app-config:update"] }, + deleteGameWhitelistUser: { + method: "DELETE", + operationId: API_OPERATIONS.deleteGameWhitelistUser, + path: "/v1/admin/game/games/{game_id}/whitelist/users/{user_id}", + permission: "game:update", + permissions: ["game:update"] + }, deleteGift: { method: "DELETE", operationId: API_OPERATIONS.deleteGift, @@ -1659,6 +1677,13 @@ export const API_ENDPOINTS: Record = { permission: "first-recharge-reward:view", permissions: ["first-recharge-reward:view"] }, + listGameWhitelistUsers: { + method: "GET", + operationId: API_OPERATIONS.listGameWhitelistUsers, + path: "/v1/admin/game/games/{game_id}/whitelist/users", + permission: "game:view", + permissions: ["game:view"] + }, listGifts: { method: "GET", operationId: API_OPERATIONS.listGifts, @@ -2333,6 +2358,13 @@ export const API_ENDPOINTS: Record = { permission: "game:status", permissions: ["game:status"] }, + setGameWhitelistEnabled: { + method: "PATCH", + operationId: API_OPERATIONS.setGameWhitelistEnabled, + path: "/v1/admin/game/games/{game_id}/whitelist", + permission: "game:update", + permissions: ["game:update"] + }, setPrettyIdStatus: { method: "POST", operationId: API_OPERATIONS.setPrettyIdStatus, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index db4ad97..2acc320 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -1516,6 +1516,54 @@ export interface paths { patch: operations["setGameStatus"]; trace?: never; }; + "/admin/game/games/{game_id}/whitelist": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["setGameWhitelistEnabled"]; + trace?: never; + }; + "/admin/game/games/{game_id}/whitelist/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listGameWhitelistUsers"]; + put?: never; + post: operations["addGameWhitelistUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/game/games/{game_id}/whitelist/users/{user_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["deleteGameWhitelistUser"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/game/platforms": { parameters: { query?: never; @@ -8466,6 +8514,63 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + setGameWhitelistEnabled: { + parameters: { + query?: never; + header?: never; + path: { + game_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + listGameWhitelistUsers: { + parameters: { + query?: never; + header?: never; + path: { + game_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + addGameWhitelistUser: { + parameters: { + query?: never; + header?: never; + path: { + game_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; + deleteGameWhitelistUser: { + parameters: { + query?: never; + header?: never; + path: { + game_id: string; + user_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; listPlatforms: { parameters: { query?: never;