新增zeeone游戏

This commit is contained in:
zhx 2026-05-25 15:42:45 +08:00
parent f59c374eae
commit d87cc49797
12 changed files with 270 additions and 40 deletions

View File

@ -1179,6 +1179,28 @@
"x-permissions": [ "x-permissions": [
"game:update" "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": { "/admin/game/games/{game_id}/status": {

View File

@ -37,6 +37,7 @@ export const PERMISSIONS = {
gameCreate: "game:create", gameCreate: "game:create",
gameUpdate: "game:update", gameUpdate: "game:update",
gameStatus: "game:status", gameStatus: "game:status",
gameDelete: "game:delete",
roleView: "role:view", roleView: "role:view",
roleCreate: "role:create", roleCreate: "role:create",
roleUpdate: "role:update", roleUpdate: "role:update",

View File

@ -30,6 +30,7 @@ export interface GameCatalogDto {
category: string; category: string;
iconUrl: string; iconUrl: string;
coverUrl: string; coverUrl: string;
launchUrl?: string;
launchMode: string; launchMode: string;
orientation: string; orientation: string;
minCoin: number; minCoin: number;
@ -140,6 +141,13 @@ export function setGameStatus(gameId: string, status: string): Promise<GameCatal
).then(normalizeCatalog); ).then(normalizeCatalog);
} }
export function deleteGameCatalog(gameId: string): Promise<void> {
const endpoint = API_ENDPOINTS.deleteCatalog;
return apiRequest<void>(apiEndpointPath(API_OPERATIONS.deleteCatalog, { game_id: gameId }), {
method: endpoint.method,
});
}
export function syncGamePlatformCatalog(platformCode: string, payload: GameSyncPayload = {}): Promise<GameSyncResult> { export function syncGamePlatformCatalog(platformCode: string, payload: GameSyncPayload = {}): Promise<GameSyncResult> {
return apiRequest<GameSyncResult, GameSyncPayload>( return apiRequest<GameSyncResult, GameSyncPayload>(
`/v1/admin/game/platforms/${encodeURIComponent(platformCode)}/sync-games`, `/v1/admin/game/platforms/${encodeURIComponent(platformCode)}/sync-games`,
@ -185,6 +193,7 @@ function normalizeCatalog(game: Partial<GameCatalogDto>): GameCatalogDto {
category: stringValue(game.category), category: stringValue(game.category),
iconUrl: stringValue(game.iconUrl), iconUrl: stringValue(game.iconUrl),
coverUrl: stringValue(game.coverUrl), coverUrl: stringValue(game.coverUrl),
launchUrl: stringValue(game.launchUrl),
launchMode: normalizeLaunchMode(game.launchMode), launchMode: normalizeLaunchMode(game.launchMode),
orientation: stringValue(game.orientation || "portrait"), orientation: stringValue(game.orientation || "portrait"),
minCoin: numberValue(game.minCoin), minCoin: numberValue(game.minCoin),

View File

@ -109,3 +109,8 @@
align-items: flex-end; align-items: flex-end;
gap: 3px; gap: 3px;
} }
.syncGameURL {
max-width: 360px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import {
deleteGameCatalog,
listGameCatalog, listGameCatalog,
listGamePlatforms, listGamePlatforms,
setGameStatus, setGameStatus,
@ -9,6 +10,7 @@ import {
} from "@/features/games/api"; } from "@/features/games/api";
import { useGameAbilities } from "@/features/games/permissions.js"; import { useGameAbilities } from "@/features/games/permissions.js";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js"; import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
import { normalizeJsonObjectString } from "@/shared/ui/JsonEditorField.jsx"; import { normalizeJsonObjectString } from "@/shared/ui/JsonEditorField.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx";
@ -20,6 +22,7 @@ const defaultGameForm = {
category: "casual", category: "casual",
iconUrl: "", iconUrl: "",
coverUrl: "", coverUrl: "",
gameUrl: "",
launchMode: "full_screen", launchMode: "full_screen",
orientation: "portrait", orientation: "portrait",
minCoin: 0, minCoin: 0,
@ -50,6 +53,7 @@ const defaultPageSize = 50;
export function useGamesPage() { export function useGamesPage() {
const abilities = useGameAbilities(); const abilities = useGameAbilities();
const confirm = useConfirm();
const { showToast } = useToast(); const { showToast } = useToast();
const [platformCode, setPlatformCode] = useState(""); const [platformCode, setPlatformCode] = useState("");
const [status, setStatus] = useState(""); const [status, setStatus] = useState("");
@ -153,7 +157,7 @@ export function useGamesPage() {
const openEditGame = (game) => { const openEditGame = (game) => {
setEditingGameId(game.gameId); setEditingGameId(game.gameId);
setGameForm(gameToForm(game)); setGameForm(gameToForm(game, platformData.items || []));
setActiveAction("edit-game"); setActiveAction("edit-game");
}; };
@ -190,6 +194,8 @@ export function useGamesPage() {
setLoadingAction(activeAction); setLoadingAction(activeAction);
try { try {
await upsertGameCatalog(payload, editingGameId); await upsertGameCatalog(payload, editingGameId);
await saveGameUrl(platformData.items || [], payload, gameForm.gameUrl);
await reloadPlatforms();
await reload(); await reload();
showToast(editingGameId ? "游戏已更新" : "游戏已创建", "success"); showToast(editingGameId ? "游戏已更新" : "游戏已创建", "success");
closeAction(); 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) => { const fetchPlatformGames = async (platform) => {
setLoadingAction(`fetch-games-${platform.platformCode}`); setLoadingAction(`fetch-games-${platform.platformCode}`);
try { try {
@ -324,6 +352,7 @@ export function useGamesPage() {
activeAction, activeAction,
allSyncGamesSelected, allSyncGamesSelected,
changeGameStatus, changeGameStatus,
deleteGame,
closeAction, closeAction,
data, data,
error: error || platformError, 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 { return {
gameId: game.gameId || "", gameId: game.gameId || "",
platformCode: game.platformCode || "", platformCode: game.platformCode || "",
@ -372,6 +445,7 @@ function gameToForm(game) {
category: game.category || "casual", category: game.category || "casual",
iconUrl: game.iconUrl || "", iconUrl: game.iconUrl || "",
coverUrl: game.coverUrl || "", coverUrl: game.coverUrl || "",
gameUrl: resolveGameURLFromPlatforms(platforms, game),
launchMode: normalizeLaunchMode(game.launchMode), launchMode: normalizeLaunchMode(game.launchMode),
orientation: game.orientation || "portrait", orientation: game.orientation || "portrait",
minCoin: Number(game.minCoin || 0), 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) { function platformToForm(platform) {
return { return {
platformCode: platform.platformCode || "", platformCode: platform.platformCode || "",

View File

@ -1,4 +1,5 @@
import Add from "@mui/icons-material/Add"; import Add from "@mui/icons-material/Add";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined"; import EditOutlined from "@mui/icons-material/EditOutlined";
import SettingsOutlined from "@mui/icons-material/SettingsOutlined"; import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined"; import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined";
@ -99,7 +100,7 @@ const baseColumns = [
{ {
key: "actions", key: "actions",
label: "操作", label: "操作",
width: "minmax(76px, 0.4fr)", width: "minmax(112px, 0.5fr)",
}, },
]; ];
@ -252,13 +253,19 @@ function GameRowActions({ page, game }) {
> >
<EditOutlined fontSize="small" /> <EditOutlined fontSize="small" />
</AdminActionIconButton> </AdminActionIconButton>
<AdminActionIconButton
disabled={!page.abilities.canDelete || page.loadingAction === `delete-${game.gameId}`}
label="删除游戏"
onClick={() => page.deleteGame(game)}
>
<DeleteOutlineOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions> </AdminRowActions>
); );
} }
function GameFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) { function GameFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) {
const disabled = mode === "edit" ? !page.abilities.canUpdate : !page.abilities.canCreate; const disabled = mode === "edit" ? !page.abilities.canUpdate : !page.abilities.canCreate;
const gameURL = resolveGameURL(page, form);
return ( return (
<AdminFormDialog <AdminFormDialog
disabled={disabled} disabled={disabled}
@ -342,8 +349,9 @@ function GameFormDialog({ form, loading, mode, open, page, onClose, onSubmit })
className={styles.fullField} className={styles.fullField}
minRows={2} minRows={2}
multiline multiline
slotProps={{ input: { readOnly: true } }} placeholder="填写当前游戏的 H5 测试/正式 URL"
value={gameURL || "未配置"} value={form.gameUrl}
onChange={(event) => page.setGameForm({ ...form, gameUrl: event.target.value })}
/> />
<TextField <TextField
label="最低金币" label="最低金币"
@ -592,6 +600,9 @@ function ProviderGameListDialog({ open, page, onClose }) {
<div className={styles.syncGameMeta}> <div className={styles.syncGameMeta}>
<span className={styles.meta}>{game.providerGameId}</span> <span className={styles.meta}>{game.providerGameId}</span>
<span className={styles.meta}>默认下架</span> <span className={styles.meta}>默认下架</span>
{game.launchUrl ? (
<span className={`${styles.meta} ${styles.syncGameURL}`}>{game.launchUrl}</span>
) : null}
</div> </div>
<AdminActionIconButton <AdminActionIconButton
disabled={page.loadingAction === `import-sync-game-${game.providerGameId}`} disabled={page.loadingAction === `import-sync-game-${game.providerGameId}`}
@ -633,28 +644,3 @@ function formatNumber(value) {
function launchModeLabel(value) { function launchModeLabel(value) {
return launchModeOptions.find(([mode]) => mode === value)?.[1] || "全屏"; return launchModeOptions.find(([mode]) => 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 {};
}
}

View File

@ -6,6 +6,7 @@ export function useGameAbilities() {
return { return {
canCreate: can(PERMISSIONS.gameCreate), canCreate: can(PERMISSIONS.gameCreate),
canDelete: can(PERMISSIONS.gameDelete) || can(PERMISSIONS.gameUpdate),
canStatus: can(PERMISSIONS.gameStatus), canStatus: can(PERMISSIONS.gameStatus),
canUpdate: can(PERMISSIONS.gameUpdate), canUpdate: can(PERMISSIONS.gameUpdate),
canUpload: can(PERMISSIONS.uploadCreate), canUpload: can(PERMISSIONS.uploadCreate),

View File

@ -55,6 +55,7 @@ export const API_OPERATIONS = {
deleteAppVersion: "deleteAppVersion", deleteAppVersion: "deleteAppVersion",
deleteBanner: "deleteBanner", deleteBanner: "deleteBanner",
deleteBDLeader: "deleteBDLeader", deleteBDLeader: "deleteBDLeader",
deleteCatalog: "deleteCatalog",
deleteCountry: "deleteCountry", deleteCountry: "deleteCountry",
deleteMenu: "deleteMenu", deleteMenu: "deleteMenu",
deletePermission: "deletePermission", deletePermission: "deletePermission",
@ -484,6 +485,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "bd:update", permission: "bd:update",
permissions: ["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: { deleteCountry: {
method: "DELETE", method: "DELETE",
operationId: API_OPERATIONS.deleteCountry, operationId: API_OPERATIONS.deleteCountry,

View File

@ -734,7 +734,7 @@ export interface paths {
get?: never; get?: never;
put?: never; put?: never;
post?: never; post?: never;
delete?: never; delete: operations["deleteCatalog"];
options?: never; options?: never;
head?: never; head?: never;
patch: operations["updateCatalog"]; patch: operations["updateCatalog"];
@ -3318,6 +3318,20 @@ export interface operations {
200: components["responses"]["EmptyResponse"]; 200: components["responses"]["EmptyResponse"];
}; };
}; };
deleteCatalog: {
parameters: {
query?: never;
header?: never;
path: {
game_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
updateCatalog: { updateCatalog: {
parameters: { parameters: {
query?: never; query?: never;

View File

@ -28,6 +28,20 @@ function labeledSwitchSx(checkedLabel, uncheckedLabel) {
"--switch-thumb-size": "20px", "--switch-thumb-size": "20px",
"--switch-padding": "3px", "--switch-padding": "3px",
"--switch-translate-x": `${width - 26}px`, "--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": { "& .MuiSwitch-track": {
overflow: "hidden", overflow: "hidden",
position: "relative", position: "relative",
@ -37,16 +51,17 @@ function labeledSwitchSx(checkedLabel, uncheckedLabel) {
bottom: 0, bottom: 0,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
fontSize: 10, fontSize: "10px",
fontWeight: 720, fontWeight: 720,
letterSpacing: 0,
lineHeight: 1, lineHeight: 1,
whiteSpace: "nowrap", whiteSpace: "nowrap",
pointerEvents: "none", pointerEvents: "none",
transition: "opacity 180ms cubic-bezier(0.2, 0, 0, 1)", transition: "opacity 180ms cubic-bezier(0.2, 0, 0, 1)",
}, },
"&::before": { "&::before": {
right: 24,
left: 5, left: 5,
right: 20,
justifyContent: "flex-start", justifyContent: "flex-start",
color: "var(--active-contrast)", color: "var(--active-contrast)",
content: cssContent(checkedLabel), content: cssContent(checkedLabel),
@ -54,7 +69,7 @@ function labeledSwitchSx(checkedLabel, uncheckedLabel) {
}, },
"&::after": { "&::after": {
right: 5, right: 5,
left: 24, left: 20,
justifyContent: "flex-end", justifyContent: "flex-end",
color: "var(--text-tertiary)", color: "var(--text-tertiary)",
content: cssContent(uncheckedLabel), 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 hasInlineLabel = Boolean(checkedLabel || uncheckedLabel);
const switchLabelSx = hasInlineLabel ? labeledSwitchSx(checkedLabel, uncheckedLabel) : null; const switchLabelSx = hasInlineLabel ? labeledSwitchSx(checkedLabel, uncheckedLabel) : null;
const mergedSx = Array.isArray(sx) const mergedSx = Array.isArray(sx)
? [baseSwitchSx, switchLabelSx, ...sx].filter(Boolean) ? [baseSwitchSx, switchLabelSx, ...sx].filter(Boolean)
: [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,
} }
: inputProps; : undefined;
const mergedSlotProps =
fallbackInputProps || slotProps
? {
...slotProps,
input: mergeInputSlotProps(slotProps?.input, fallbackInputProps),
}
: undefined;
return <Switch {...props} inputProps={mergedInputProps} sx={mergedSx} />; return <Switch {...props} slotProps={mergedSlotProps} sx={mergedSx} />;
} }

View File

@ -59,6 +59,7 @@ export function ResourceGroupSelectField({
<SideDrawer <SideDrawer
className={styles.drawer} className={styles.drawer}
contentClassName={styles.drawerBody} contentClassName={styles.drawerBody}
drawerProps={{ sx: { zIndex: 1600 } }}
open={open} open={open}
title={drawerTitle} title={drawerTitle}
width="wide" width="wide"

View File

@ -0,0 +1,38 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
vi.mock("@/shared/ui/SideDrawer.jsx", () => ({
SideDrawer({ children, drawerProps, onClose, open, title }) {
if (!open) {
return null;
}
return (
<aside aria-label={title} data-z-index={drawerProps?.sx?.zIndex} role="dialog">
<button type="button" onClick={onClose}>
关闭
</button>
{children}
</aside>
);
},
}));
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(<ResourceGroupSelectField allowEmpty groups={groups} value="" />);
fireEvent.click(screen.getByPlaceholderText("请选择资源组"));
expect(screen.getByRole("dialog", { name: "选择资源组" })).toHaveAttribute("data-z-index", "1600");
expect(screen.getByText("不配置")).toBeInTheDocument();
expect(screen.getByText("VIP1 Rewards")).toBeInTheDocument();
});