百顺游戏

This commit is contained in:
zhx 2026-05-23 12:06:47 +08:00
parent d06718cc49
commit f59c374eae
7 changed files with 379 additions and 86 deletions

View File

@ -185,7 +185,7 @@ function normalizeCatalog(game: Partial<GameCatalogDto>): GameCatalogDto {
category: stringValue(game.category),
iconUrl: stringValue(game.iconUrl),
coverUrl: stringValue(game.coverUrl),
launchMode: stringValue(game.launchMode || "h5_popup"),
launchMode: normalizeLaunchMode(game.launchMode),
orientation: stringValue(game.orientation || "portrait"),
minCoin: numberValue(game.minCoin),
status: stringValue(game.status || "active"),
@ -204,3 +204,8 @@ function numberValue(value: unknown) {
const number = Number(value || 0);
return Number.isFinite(number) ? number : 0;
}
function normalizeLaunchMode(value: unknown) {
const mode = stringValue(value);
return mode === "half_screen" || mode === "three_quarter_screen" || mode === "full_screen" ? mode : "full_screen";
}

View File

@ -9,6 +9,7 @@ import {
} from "@/features/games/api";
import { useGameAbilities } from "@/features/games/permissions.js";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { normalizeJsonObjectString } from "@/shared/ui/JsonEditorField.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
const defaultGameForm = {
@ -19,7 +20,7 @@ const defaultGameForm = {
category: "casual",
iconUrl: "",
coverUrl: "",
launchMode: "h5_popup",
launchMode: "full_screen",
orientation: "portrait",
minCoin: 0,
status: "active",
@ -201,7 +202,13 @@ export function useGamesPage() {
const submitPlatform = async (event) => {
event.preventDefault();
const payload = platformPayload(platformForm);
let payload;
try {
payload = platformPayload(platformForm);
} catch (err) {
showToast(err.message || "适配器配置 JSON 不正确", "error");
return;
}
setLoadingAction(activeAction);
try {
await upsertGamePlatform(payload, editingPlatformCode);
@ -365,7 +372,7 @@ function gameToForm(game) {
category: game.category || "casual",
iconUrl: game.iconUrl || "",
coverUrl: game.coverUrl || "",
launchMode: game.launchMode || "h5_popup",
launchMode: normalizeLaunchMode(game.launchMode),
orientation: game.orientation || "portrait",
minCoin: Number(game.minCoin || 0),
status: game.status || "active",
@ -399,7 +406,7 @@ function gamePayload(form) {
category: form.category.trim(),
iconUrl: form.iconUrl.trim(),
coverUrl: form.coverUrl.trim(),
launchMode: form.launchMode.trim() || "h5_popup",
launchMode: normalizeLaunchMode(form.launchMode),
orientation: form.orientation.trim() || "portrait",
minCoin: Number(form.minCoin || 0),
status: form.status.trim() || "active",
@ -411,6 +418,14 @@ function gamePayload(form) {
};
}
function normalizeLaunchMode(value) {
const mode = String(value || "").trim();
if (mode === "half_screen" || mode === "three_quarter_screen" || mode === "full_screen") {
return mode;
}
return "full_screen";
}
function platformPayload(form) {
const isLeaderCC = form.adapterType.trim() === "leadercc_v1";
return {
@ -428,8 +443,7 @@ function platformPayload(form) {
.split(/[\n,]/)
.map((item) => item.trim())
.filter(Boolean),
// JSON 不在前端强解析,避免前端版本限制后端新增厂商字段。
adapterConfigJson: form.adapterConfigJson.trim() || "{}",
adapterConfigJson: normalizeJsonObjectString(form.adapterConfigJson),
sortOrder: Number(form.sortOrder || 0),
};
}

View File

@ -11,6 +11,7 @@ import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { JsonEditorField } from "@/shared/ui/JsonEditorField.jsx";
import {
AdminActionIconButton,
AdminListBody,
@ -35,7 +36,11 @@ const orientationOptions = [
["portrait", "Portrait"],
["landscape", "Landscape"],
];
const launchModeOptions = [["h5_popup", "H5 弹窗"]];
const launchModeOptions = [
["full_screen", "全屏"],
["half_screen", "半屏"],
["three_quarter_screen", "3/4屏"],
];
// game-service adapter_type
const adapterTypeOptions = [
["demo", "Demo"],
@ -69,7 +74,7 @@ const baseColumns = [
width: "minmax(150px, 0.75fr)",
render: (game) => (
<div className={styles.stack}>
<span>{game.launchMode}</span>
<span>{launchModeLabel(game.launchMode)}</span>
<span className={styles.meta}>{game.orientation}</span>
</div>
),
@ -223,14 +228,15 @@ function GameIdentity({ game }) {
}
function GameStatusSwitch({ page, game }) {
const disabled =
!page.abilities.canStatus || game.status === "disabled" || page.loadingAction === `status-${game.gameId}`;
const disabled = !page.abilities.canStatus || page.loadingAction === `status-${game.gameId}`;
return (
<AdminSwitch
checked={game.status === "active"}
checkedLabel="启用"
disabled={disabled}
label={`${game.gameName || game.gameId} 状态`}
size="small"
uncheckedLabel={game.status === "disabled" ? "下架" : "维护"}
onChange={(event) => page.changeGameStatus(game, event.target.checked)}
/>
);
@ -252,6 +258,7 @@ function GameRowActions({ page, 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 (
<AdminFormDialog
disabled={disabled}
@ -330,6 +337,14 @@ function GameFormDialog({ form, loading, mode, open, page, onClose, onSubmit })
</MenuItem>
))}
</TextField>
<TextField
label="游戏 URL"
className={styles.fullField}
minRows={2}
multiline
slotProps={{ input: { readOnly: true } }}
value={gameURL || "未配置"}
/>
<TextField
label="最低金币"
type="number"
@ -472,13 +487,12 @@ function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit
/>
) : null}
{/* 厂商差异字段放 JSON如 uid_mode、default_lang、token_ttl_seconds、game_level_uid。 */}
<TextField
<JsonEditorField
className={styles.fullField}
label="适配器配置 JSON"
minRows={5}
multiline
value={form.adapterConfigJson}
onChange={(event) => page.setPlatformForm({ ...form, adapterConfigJson: event.target.value })}
onChange={(adapterConfigJson) => page.setPlatformForm({ ...form, adapterConfigJson })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
@ -615,3 +629,32 @@ function SwitchOnlyField({ checked, checkedLabel, disabled, label, onChange, unc
function formatNumber(value) {
return Number(value || 0).toLocaleString();
}
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

@ -1,87 +1,87 @@
import Switch from "@mui/material/Switch";
const baseSwitchSx = {
"--switch-width": "40px",
"--switch-height": "24px",
"--switch-thumb-size": "18px",
"--switch-padding": "3px",
"--switch-translate-x": "16px",
margin: 0
"--switch-width": "40px",
"--switch-height": "24px",
"--switch-thumb-size": "18px",
"--switch-padding": "3px",
"--switch-translate-x": "16px",
margin: 0,
};
function cssContent(value) {
return `"${String(value ?? "").replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
return `"${String(value ?? "")
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')}"`;
}
function labeledSwitchSx(checkedLabel, uncheckedLabel) {
const labelLength = Math.max(Array.from(String(checkedLabel || "")).length, Array.from(String(uncheckedLabel || "")).length);
const width = Math.max(56, Math.min(84, 34 + labelLength * 11));
const labelLength = Math.max(
Array.from(String(checkedLabel || "")).length,
Array.from(String(uncheckedLabel || "")).length,
);
const width = Math.max(56, Math.min(84, 34 + labelLength * 11));
return {
"--switch-width": `${width}px`,
"--switch-height": "26px",
"--switch-thumb-size": "20px",
"--switch-padding": "3px",
"--switch-translate-x": `${width - 26}px`,
"& .MuiSwitch-track": {
overflow: "hidden",
position: "relative",
"&::before, &::after": {
position: "absolute",
top: 0,
bottom: 0,
display: "flex",
alignItems: "center",
fontSize: 10.5,
fontWeight: 760,
lineHeight: 1,
pointerEvents: "none",
transition: "opacity 180ms cubic-bezier(0.2, 0, 0, 1)"
},
"&::before": {
right: 26,
left: 7,
justifyContent: "flex-start",
color: "var(--active-contrast)",
content: cssContent(checkedLabel),
opacity: 0
},
"&::after": {
right: 7,
left: 26,
justifyContent: "flex-end",
color: "var(--text-tertiary)",
content: cssContent(uncheckedLabel),
opacity: 1
}
},
"& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track::before": {
opacity: 1
},
"& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track::after": {
opacity: 0
}
};
return {
"--switch-width": `${width}px`,
"--switch-height": "26px",
"--switch-thumb-size": "20px",
"--switch-padding": "3px",
"--switch-translate-x": `${width - 26}px`,
"& .MuiSwitch-track": {
overflow: "hidden",
position: "relative",
"&::before, &::after": {
position: "absolute",
top: 0,
bottom: 0,
display: "flex",
alignItems: "center",
fontSize: 10,
fontWeight: 720,
lineHeight: 1,
whiteSpace: "nowrap",
pointerEvents: "none",
transition: "opacity 180ms cubic-bezier(0.2, 0, 0, 1)",
},
"&::before": {
right: 24,
left: 5,
justifyContent: "flex-start",
color: "var(--active-contrast)",
content: cssContent(checkedLabel),
opacity: 0,
},
"&::after": {
right: 5,
left: 24,
justifyContent: "flex-end",
color: "var(--text-tertiary)",
content: cssContent(uncheckedLabel),
opacity: 1,
},
},
"& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track::before": {
opacity: 1,
},
"& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track::after": {
opacity: 0,
},
};
}
export function AdminSwitch({ checkedLabel, inputProps, label, 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
? {
"aria-label": label,
...inputProps
}
: inputProps;
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
? {
"aria-label": label,
...inputProps,
}
: inputProps;
return (
<Switch
{...props}
inputProps={mergedInputProps}
sx={mergedSx}
/>
);
return <Switch {...props} inputProps={mergedInputProps} sx={mergedSx} />;
}

View File

@ -0,0 +1,114 @@
import { useMemo, useState } from "react";
import TextField from "@mui/material/TextField";
import { Button } from "@/shared/ui/Button.jsx";
import styles from "@/shared/ui/JsonEditorField.module.css";
export function JsonEditorField({
className = "",
disabled = false,
label,
minRows = 6,
onChange,
required = false,
value,
}) {
const [validated, setValidated] = useState(false);
const validation = useMemo(() => validateJsonObject(value), [value]);
const showError = validated && !validation.valid;
const helperText = showError
? validation.message
: validated
? "JSON 格式正确"
: "支持格式化和校验,内容必须是 JSON 对象";
const lineCount = Math.max(1, String(value || "").split("\n").length);
const formatJson = () => {
const result = parseJsonObject(value);
setValidated(true);
if (!result.valid) {
return;
}
onChange(JSON.stringify(result.value, null, 2));
};
return (
<div className={[styles.root, className].filter(Boolean).join(" ")}>
<div className={styles.header}>
<div className={styles.title}>
<span>{label}</span>
{required ? <span className={styles.required}>*</span> : null}
</div>
<div className={styles.actions}>
<Button disabled={disabled} onClick={() => setValidated(true)}>
校验 JSON
</Button>
<Button disabled={disabled} onClick={formatJson}>
格式化 JSON
</Button>
</div>
</div>
<div className={[styles.editor, showError ? styles.editorInvalid : ""].filter(Boolean).join(" ")}>
<pre className={styles.gutter} aria-hidden="true">
{Array.from({ length: lineCount }, (_, index) => index + 1).join("\n")}
</pre>
<TextField
className={styles.editorField}
disabled={disabled}
minRows={minRows}
multiline
slotProps={{
input: { disableUnderline: true },
htmlInput: {
"aria-label": label,
autoCapitalize: "off",
autoCorrect: "off",
spellCheck: false,
},
}}
value={value}
variant="standard"
onBlur={() => setValidated(true)}
onChange={(event) => {
setValidated(false);
onChange(event.target.value);
}}
/>
</div>
<div
className={[styles.helper, showError ? styles.helperInvalid : validated ? styles.helperValid : ""].join(
" ",
)}
>
{helperText}
</div>
</div>
);
}
export function normalizeJsonObjectString(raw) {
const result = parseJsonObject(raw);
if (!result.valid) {
throw new Error(result.message);
}
return JSON.stringify(result.value);
}
function validateJsonObject(raw) {
return parseJsonObject(raw);
}
function parseJsonObject(raw) {
const value = String(raw || "").trim();
if (!value) {
return { valid: true, value: {} };
}
try {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return { valid: false, message: '配置 JSON 必须是对象,例如 {"app_id": 123}' };
}
return { valid: true, value: parsed };
} catch (err) {
return { valid: false, message: `JSON 格式错误:${err.message}` };
}
}

View File

@ -0,0 +1,105 @@
.root {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.title {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--text-secondary);
font-size: 13px;
font-weight: 700;
}
.required {
color: var(--danger);
}
.actions {
display: inline-flex;
gap: 8px;
}
.editor {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
min-width: 0;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 8px;
background: linear-gradient(90deg, var(--bg-card-strong) 0 44px, transparent 44px), var(--bg-card);
transition:
border-color var(--motion-fast) var(--ease-standard),
box-shadow var(--motion-fast) var(--ease-standard);
}
.editor:focus-within {
border-color: var(--primary-border-strong);
box-shadow: 0 0 0 3px var(--primary-surface);
}
.editorInvalid {
border-color: var(--danger-border-strong);
box-shadow: 0 0 0 3px var(--danger-surface);
}
.gutter {
min-height: 100%;
margin: 0;
padding: 14px 10px 14px 0;
border-right: 1px solid var(--border-soft);
color: var(--text-tertiary);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 20px;
text-align: right;
user-select: none;
}
.editorField {
min-width: 0;
}
.editorField :global(.MuiInputBase-root) {
align-items: stretch;
padding: 0;
background: transparent;
}
.editorField :global(textarea) {
box-sizing: border-box;
padding: 14px 14px 14px 12px;
color: var(--text-primary);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 13px;
line-height: 20px;
tab-size: 2;
}
.editorField :global(textarea::placeholder) {
color: var(--text-tertiary);
}
.helper {
min-height: 18px;
color: var(--text-tertiary);
font-size: 12px;
}
.helperInvalid {
color: var(--danger);
}
.helperValid {
color: var(--success);
}

View File

@ -0,0 +1,12 @@
import { expect, test } from "vitest";
import { normalizeJsonObjectString } from "@/shared/ui/JsonEditorField.jsx";
test("normalizes valid json object strings", () => {
expect(normalizeJsonObjectString(`{"app_id":307715}`)).toBe(`{"app_id":307715}`);
expect(normalizeJsonObjectString("")).toBe("{}");
});
test("rejects invalid json and non-object json", () => {
expect(() => normalizeJsonObjectString("{")).toThrow("JSON 格式错误");
expect(() => normalizeJsonObjectString("[]")).toThrow("配置 JSON 必须是对象");
});