新增zeeone游戏
This commit is contained in:
parent
f59c374eae
commit
d87cc49797
@ -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": {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 || "",
|
||||
|
||||
@ -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 {};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
16
src/shared/api/generated/schema.d.ts
vendored
16
src/shared/api/generated/schema.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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} />;
|
||||
}
|
||||
|
||||
@ -59,6 +59,7 @@ export function ResourceGroupSelectField({
|
||||
<SideDrawer
|
||||
className={styles.drawer}
|
||||
contentClassName={styles.drawerBody}
|
||||
drawerProps={{ sx: { zIndex: 1600 } }}
|
||||
open={open}
|
||||
title={drawerTitle}
|
||||
width="wide"
|
||||
|
||||
38
src/shared/ui/ResourceGroupSelectDrawer.test.jsx
Normal file
38
src/shared/ui/ResourceGroupSelectDrawer.test.jsx
Normal 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();
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user