From d87cc49797771cb1d43d0de2d31cc35d8e04dea2 Mon Sep 17 00:00:00 2001 From: zhx Date: Mon, 25 May 2026 15:42:45 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9Ezeeone=E6=B8=B8=E6=88=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/admin-openapi.json | 22 ++++ src/app/permissions.ts | 1 + src/features/games/api.ts | 9 ++ src/features/games/games.module.css | 5 + src/features/games/hooks/useGamesPage.js | 110 +++++++++++++++++- src/features/games/pages/GameListPage.jsx | 44 +++---- src/features/games/permissions.js | 1 + src/shared/api/generated/endpoints.ts | 8 ++ src/shared/api/generated/schema.d.ts | 16 ++- src/shared/ui/AdminSwitch.jsx | 55 +++++++-- src/shared/ui/ResourceGroupSelectDrawer.jsx | 1 + .../ui/ResourceGroupSelectDrawer.test.jsx | 38 ++++++ 12 files changed, 270 insertions(+), 40 deletions(-) create mode 100644 src/shared/ui/ResourceGroupSelectDrawer.test.jsx diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index df8fdbe..90c4047 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -1179,6 +1179,28 @@ "x-permissions": [ "game:update" ] + }, + "delete": { + "operationId": "deleteCatalog", + "responses": { + "200": { + "$ref": "#/components/responses/EmptyResponse" + } + }, + "parameters": [ + { + "in": "path", + "name": "game_id", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "x-permission": "game:delete", + "x-permissions": [ + "game:delete" + ] } }, "/admin/game/games/{game_id}/status": { diff --git a/src/app/permissions.ts b/src/app/permissions.ts index 11ab304..16167ed 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -37,6 +37,7 @@ export const PERMISSIONS = { gameCreate: "game:create", gameUpdate: "game:update", gameStatus: "game:status", + gameDelete: "game:delete", roleView: "role:view", roleCreate: "role:create", roleUpdate: "role:update", diff --git a/src/features/games/api.ts b/src/features/games/api.ts index 90ffab7..75ce2d3 100644 --- a/src/features/games/api.ts +++ b/src/features/games/api.ts @@ -30,6 +30,7 @@ export interface GameCatalogDto { category: string; iconUrl: string; coverUrl: string; + launchUrl?: string; launchMode: string; orientation: string; minCoin: number; @@ -140,6 +141,13 @@ export function setGameStatus(gameId: string, status: string): Promise { + const endpoint = API_ENDPOINTS.deleteCatalog; + return apiRequest(apiEndpointPath(API_OPERATIONS.deleteCatalog, { game_id: gameId }), { + method: endpoint.method, + }); +} + export function syncGamePlatformCatalog(platformCode: string, payload: GameSyncPayload = {}): Promise { return apiRequest( `/v1/admin/game/platforms/${encodeURIComponent(platformCode)}/sync-games`, @@ -185,6 +193,7 @@ function normalizeCatalog(game: Partial): GameCatalogDto { category: stringValue(game.category), iconUrl: stringValue(game.iconUrl), coverUrl: stringValue(game.coverUrl), + launchUrl: stringValue(game.launchUrl), launchMode: normalizeLaunchMode(game.launchMode), orientation: stringValue(game.orientation || "portrait"), minCoin: numberValue(game.minCoin), diff --git a/src/features/games/games.module.css b/src/features/games/games.module.css index 97165e2..23ea8db 100644 --- a/src/features/games/games.module.css +++ b/src/features/games/games.module.css @@ -109,3 +109,8 @@ align-items: flex-end; gap: 3px; } + +.syncGameURL { + max-width: 360px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; +} diff --git a/src/features/games/hooks/useGamesPage.js b/src/features/games/hooks/useGamesPage.js index ed0f51f..0e01581 100644 --- a/src/features/games/hooks/useGamesPage.js +++ b/src/features/games/hooks/useGamesPage.js @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { + deleteGameCatalog, listGameCatalog, listGamePlatforms, setGameStatus, @@ -9,6 +10,7 @@ import { } from "@/features/games/api"; import { useGameAbilities } from "@/features/games/permissions.js"; import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; +import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx"; import { normalizeJsonObjectString } from "@/shared/ui/JsonEditorField.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; @@ -20,6 +22,7 @@ const defaultGameForm = { category: "casual", iconUrl: "", coverUrl: "", + gameUrl: "", launchMode: "full_screen", orientation: "portrait", minCoin: 0, @@ -50,6 +53,7 @@ const defaultPageSize = 50; export function useGamesPage() { const abilities = useGameAbilities(); + const confirm = useConfirm(); const { showToast } = useToast(); const [platformCode, setPlatformCode] = useState(""); const [status, setStatus] = useState(""); @@ -153,7 +157,7 @@ export function useGamesPage() { const openEditGame = (game) => { setEditingGameId(game.gameId); - setGameForm(gameToForm(game)); + setGameForm(gameToForm(game, platformData.items || [])); setActiveAction("edit-game"); }; @@ -190,6 +194,8 @@ export function useGamesPage() { setLoadingAction(activeAction); try { await upsertGameCatalog(payload, editingGameId); + await saveGameUrl(platformData.items || [], payload, gameForm.gameUrl); + await reloadPlatforms(); await reload(); showToast(editingGameId ? "游戏已更新" : "游戏已创建", "success"); closeAction(); @@ -237,6 +243,28 @@ export function useGamesPage() { } }; + const deleteGame = async (game) => { + const ok = await confirm({ + confirmText: "删除", + message: `删除后 App 游戏列表不再展示「${game.gameName || game.gameId}」,历史订单和启动记录会保留用于审计。`, + title: "删除游戏", + tone: "danger", + }); + if (!ok) { + return; + } + setLoadingAction(`delete-${game.gameId}`); + try { + await deleteGameCatalog(game.gameId); + await reload(); + showToast("游戏已删除", "success"); + } catch (err) { + showToast(err.message || "删除游戏失败", "error"); + } finally { + setLoadingAction(""); + } + }; + const fetchPlatformGames = async (platform) => { setLoadingAction(`fetch-games-${platform.platformCode}`); try { @@ -324,6 +352,7 @@ export function useGamesPage() { activeAction, allSyncGamesSelected, changeGameStatus, + deleteGame, closeAction, data, error: error || platformError, @@ -363,7 +392,51 @@ export function useGamesPage() { }; } -function gameToForm(game) { +async function saveGameUrl(platforms, game, gameUrl) { + const platform = platforms.find((item) => item.platformCode === game.platformCode); + if (!platform) { + return; + } + const nextURL = String(gameUrl || "").trim(); + const currentURL = resolveGameURLFromPlatforms(platforms, game); + if (nextURL === currentURL) { + return; + } + + const config = parseJsonObject(platform.adapterConfigJson); + const gameURLs = config.game_urls && typeof config.game_urls === "object" && !Array.isArray(config.game_urls) + ? { ...config.game_urls } + : {}; + const keys = gameUrlKeys(game); + for (const key of keys) { + delete gameURLs[key]; + } + if (nextURL) { + gameURLs[keys[0]] = nextURL; + } + if (Object.keys(gameURLs).length > 0) { + config.game_urls = gameURLs; + } else { + delete config.game_urls; + } + + await upsertGamePlatform( + { + platformCode: platform.platformCode, + platformName: platform.platformName, + status: platform.status, + apiBaseUrl: platform.apiBaseUrl, + adapterType: platform.adapterType, + callbackSecret: "", + callbackIpWhitelist: platform.callbackIpWhitelist || [], + adapterConfigJson: JSON.stringify(config, null, 2), + sortOrder: Number(platform.sortOrder || 0), + }, + platform.platformCode, + ); +} + +function gameToForm(game, platforms = []) { return { gameId: game.gameId || "", platformCode: game.platformCode || "", @@ -372,6 +445,7 @@ function gameToForm(game) { category: game.category || "casual", iconUrl: game.iconUrl || "", coverUrl: game.coverUrl || "", + gameUrl: resolveGameURLFromPlatforms(platforms, game), launchMode: normalizeLaunchMode(game.launchMode), orientation: game.orientation || "portrait", minCoin: Number(game.minCoin || 0), @@ -381,6 +455,38 @@ function gameToForm(game) { }; } +function resolveGameURLFromPlatforms(platforms, game) { + const platform = platforms.find((item) => item.platformCode === game.platformCode); + if (!platform) { + return ""; + } + const config = parseJsonObject(platform.adapterConfigJson); + const gameURLs = config.game_urls && typeof config.game_urls === "object" ? config.game_urls : {}; + for (const key of gameUrlKeys(game)) { + const value = gameURLs[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + return ""; +} + +function gameUrlKeys(game) { + const keys = [game.providerGameId, game.gameId, String(game.gameId || "").toLowerCase()] + .map((value) => String(value || "").trim()) + .filter(Boolean); + return [...new Set(keys)]; +} + +function parseJsonObject(raw) { + try { + const value = JSON.parse(raw || "{}"); + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; + } catch { + return {}; + } +} + function platformToForm(platform) { return { platformCode: platform.platformCode || "", diff --git a/src/features/games/pages/GameListPage.jsx b/src/features/games/pages/GameListPage.jsx index d15de8a..e82d42d 100644 --- a/src/features/games/pages/GameListPage.jsx +++ b/src/features/games/pages/GameListPage.jsx @@ -1,4 +1,5 @@ import Add from "@mui/icons-material/Add"; +import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; import EditOutlined from "@mui/icons-material/EditOutlined"; import SettingsOutlined from "@mui/icons-material/SettingsOutlined"; import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined"; @@ -99,7 +100,7 @@ const baseColumns = [ { key: "actions", label: "操作", - width: "minmax(76px, 0.4fr)", + width: "minmax(112px, 0.5fr)", }, ]; @@ -252,13 +253,19 @@ function GameRowActions({ page, game }) { > + page.deleteGame(game)} + > + + ); } function GameFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) { const disabled = mode === "edit" ? !page.abilities.canUpdate : !page.abilities.canCreate; - const gameURL = resolveGameURL(page, form); return ( page.setGameForm({ ...form, gameUrl: event.target.value })} /> {game.providerGameId} 默认下架 + {game.launchUrl ? ( + {game.launchUrl} + ) : null} mode === value)?.[1] || "全屏"; } - -function resolveGameURL(page, form) { - const platform = (page.platformData.items || []).find((item) => item.platformCode === form.platformCode); - if (!platform) { - return ""; - } - const config = parseAdapterConfig(platform.adapterConfigJson); - const gameURLs = config?.game_urls && typeof config.game_urls === "object" ? config.game_urls : {}; - const candidates = [form.providerGameId, form.gameId, String(form.gameId || "").toLowerCase()].filter(Boolean); - for (const key of candidates) { - const value = gameURLs[key]; - if (typeof value === "string" && value.trim()) { - return value.trim(); - } - } - return platform.apiBaseUrl || ""; -} - -function parseAdapterConfig(raw) { - try { - return JSON.parse(raw || "{}"); - } catch { - return {}; - } -} diff --git a/src/features/games/permissions.js b/src/features/games/permissions.js index f788c97..2eb5495 100644 --- a/src/features/games/permissions.js +++ b/src/features/games/permissions.js @@ -6,6 +6,7 @@ export function useGameAbilities() { return { canCreate: can(PERMISSIONS.gameCreate), + canDelete: can(PERMISSIONS.gameDelete) || can(PERMISSIONS.gameUpdate), canStatus: can(PERMISSIONS.gameStatus), canUpdate: can(PERMISSIONS.gameUpdate), canUpload: can(PERMISSIONS.uploadCreate), diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 24f19c3..3586050 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -55,6 +55,7 @@ export const API_OPERATIONS = { deleteAppVersion: "deleteAppVersion", deleteBanner: "deleteBanner", deleteBDLeader: "deleteBDLeader", + deleteCatalog: "deleteCatalog", deleteCountry: "deleteCountry", deleteMenu: "deleteMenu", deletePermission: "deletePermission", @@ -484,6 +485,13 @@ export const API_ENDPOINTS: Record = { permission: "bd:update", permissions: ["bd:update"] }, + deleteCatalog: { + method: "DELETE", + operationId: API_OPERATIONS.deleteCatalog, + path: "/v1/admin/game/games/{game_id}", + permission: "game:delete", + permissions: ["game:delete"] + }, deleteCountry: { method: "DELETE", operationId: API_OPERATIONS.deleteCountry, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 2369b76..f445606 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -734,7 +734,7 @@ export interface paths { get?: never; put?: never; post?: never; - delete?: never; + delete: operations["deleteCatalog"]; options?: never; head?: never; patch: operations["updateCatalog"]; @@ -3318,6 +3318,20 @@ export interface operations { 200: components["responses"]["EmptyResponse"]; }; }; + deleteCatalog: { + parameters: { + query?: never; + header?: never; + path: { + game_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["EmptyResponse"]; + }; + }; updateCatalog: { parameters: { query?: never; diff --git a/src/shared/ui/AdminSwitch.jsx b/src/shared/ui/AdminSwitch.jsx index 600a06e..debce43 100644 --- a/src/shared/ui/AdminSwitch.jsx +++ b/src/shared/ui/AdminSwitch.jsx @@ -28,6 +28,20 @@ function labeledSwitchSx(checkedLabel, uncheckedLabel) { "--switch-thumb-size": "20px", "--switch-padding": "3px", "--switch-translate-x": `${width - 26}px`, + width: "var(--switch-width)", + height: "var(--switch-height)", + padding: 0, + overflow: "visible", + "& .MuiSwitch-switchBase": { + padding: "var(--switch-padding)", + "&.Mui-checked": { + transform: "translateX(var(--switch-translate-x))", + }, + }, + "& .MuiSwitch-thumb": { + width: "var(--switch-thumb-size)", + height: "var(--switch-thumb-size)", + }, "& .MuiSwitch-track": { overflow: "hidden", position: "relative", @@ -37,16 +51,17 @@ function labeledSwitchSx(checkedLabel, uncheckedLabel) { bottom: 0, display: "flex", alignItems: "center", - fontSize: 10, + fontSize: "10px", fontWeight: 720, + letterSpacing: 0, lineHeight: 1, whiteSpace: "nowrap", pointerEvents: "none", transition: "opacity 180ms cubic-bezier(0.2, 0, 0, 1)", }, "&::before": { - right: 24, left: 5, + right: 20, justifyContent: "flex-start", color: "var(--active-contrast)", content: cssContent(checkedLabel), @@ -54,7 +69,7 @@ function labeledSwitchSx(checkedLabel, uncheckedLabel) { }, "&::after": { right: 5, - left: 24, + left: 20, justifyContent: "flex-end", color: "var(--text-tertiary)", content: cssContent(uncheckedLabel), @@ -70,18 +85,42 @@ function labeledSwitchSx(checkedLabel, uncheckedLabel) { }; } -export function AdminSwitch({ checkedLabel, inputProps, label, sx, uncheckedLabel, ...props }) { +function mergeInputSlotProps(slotInputProps, fallbackInputProps) { + if (!fallbackInputProps) { + return slotInputProps; + } + if (typeof slotInputProps === "function") { + return (ownerState) => ({ + ...fallbackInputProps, + ...slotInputProps(ownerState), + }); + } + return { + ...fallbackInputProps, + ...slotInputProps, + }; +} + +export function AdminSwitch({ checkedLabel, inputProps, label, slotProps, sx, uncheckedLabel, ...props }) { const hasInlineLabel = Boolean(checkedLabel || uncheckedLabel); const switchLabelSx = hasInlineLabel ? labeledSwitchSx(checkedLabel, uncheckedLabel) : null; const mergedSx = Array.isArray(sx) ? [baseSwitchSx, switchLabelSx, ...sx].filter(Boolean) : [baseSwitchSx, switchLabelSx, sx].filter(Boolean); - const mergedInputProps = label + const fallbackInputProps = label || inputProps || slotProps?.input ? { - "aria-label": label, + role: "switch", + ...(label ? { "aria-label": label } : null), ...inputProps, } - : inputProps; + : undefined; + const mergedSlotProps = + fallbackInputProps || slotProps + ? { + ...slotProps, + input: mergeInputSlotProps(slotProps?.input, fallbackInputProps), + } + : undefined; - return ; + return ; } diff --git a/src/shared/ui/ResourceGroupSelectDrawer.jsx b/src/shared/ui/ResourceGroupSelectDrawer.jsx index ee0a771..7de22e1 100644 --- a/src/shared/ui/ResourceGroupSelectDrawer.jsx +++ b/src/shared/ui/ResourceGroupSelectDrawer.jsx @@ -59,6 +59,7 @@ export function ResourceGroupSelectField({ ({ + SideDrawer({ children, drawerProps, onClose, open, title }) { + if (!open) { + return null; + } + return ( + + ); + }, +})); + +const groups = [ + { + groupCode: "vip1_rewards", + groupId: 1, + items: [{ itemType: "resource", resource: { name: "VIP1 Avatar Frame" }, resourceId: 101 }], + name: "VIP1 Rewards", + }, +]; + +test("resource group drawer stays above dialogs", () => { + render(); + + fireEvent.click(screen.getByPlaceholderText("请选择资源组")); + + expect(screen.getByRole("dialog", { name: "选择资源组" })).toHaveAttribute("data-z-index", "1600"); + expect(screen.getByText("不配置")).toBeInTheDocument(); + expect(screen.getByText("VIP1 Rewards")).toBeInTheDocument(); +});