新增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": [
"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": {

View File

@ -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",

View File

@ -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<GameCatal
).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> {
return apiRequest<GameSyncResult, GameSyncPayload>(
`/v1/admin/game/platforms/${encodeURIComponent(platformCode)}/sync-games`,
@ -185,6 +193,7 @@ function normalizeCatalog(game: Partial<GameCatalogDto>): 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),

View File

@ -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;
}

View File

@ -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 || "",

View File

@ -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 }) {
>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton
disabled={!page.abilities.canDelete || page.loadingAction === `delete-${game.gameId}`}
label="删除游戏"
onClick={() => page.deleteGame(game)}
>
<DeleteOutlineOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
);
}
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 (
<AdminFormDialog
disabled={disabled}
@ -342,8 +349,9 @@ function GameFormDialog({ form, loading, mode, open, page, onClose, onSubmit })
className={styles.fullField}
minRows={2}
multiline
slotProps={{ input: { readOnly: true } }}
value={gameURL || "未配置"}
placeholder="填写当前游戏的 H5 测试/正式 URL"
value={form.gameUrl}
onChange={(event) => page.setGameForm({ ...form, gameUrl: event.target.value })}
/>
<TextField
label="最低金币"
@ -592,6 +600,9 @@ function ProviderGameListDialog({ open, page, onClose }) {
<div className={styles.syncGameMeta}>
<span className={styles.meta}>{game.providerGameId}</span>
<span className={styles.meta}>默认下架</span>
{game.launchUrl ? (
<span className={`${styles.meta} ${styles.syncGameURL}`}>{game.launchUrl}</span>
) : null}
</div>
<AdminActionIconButton
disabled={page.loadingAction === `import-sync-game-${game.providerGameId}`}
@ -633,28 +644,3 @@ function formatNumber(value) {
function launchModeLabel(value) {
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 {
canCreate: can(PERMISSIONS.gameCreate),
canDelete: can(PERMISSIONS.gameDelete) || can(PERMISSIONS.gameUpdate),
canStatus: can(PERMISSIONS.gameStatus),
canUpdate: can(PERMISSIONS.gameUpdate),
canUpload: can(PERMISSIONS.uploadCreate),

View File

@ -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<ApiOperationId, ApiEndpoint> = {
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,

View File

@ -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;

View File

@ -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 <Switch {...props} inputProps={mergedInputProps} sx={mergedSx} />;
return <Switch {...props} slotProps={mergedSlotProps} sx={mergedSx} />;
}

View File

@ -59,6 +59,7 @@ export function ResourceGroupSelectField({
<SideDrawer
className={styles.drawer}
contentClassName={styles.drawerBody}
drawerProps={{ sx: { zIndex: 1600 } }}
open={open}
title={drawerTitle}
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();
});