From f59c374eae00aea2428977e3ebe15399f0560554 Mon Sep 17 00:00:00 2001 From: zhx Date: Sat, 23 May 2026 12:06:47 +0800 Subject: [PATCH] =?UTF-8?q?=E7=99=BE=E9=A1=BA=E6=B8=B8=E6=88=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/games/api.ts | 7 +- src/features/games/hooks/useGamesPage.js | 26 +++- src/features/games/pages/GameListPage.jsx | 57 +++++++-- src/shared/ui/AdminSwitch.jsx | 144 +++++++++++----------- src/shared/ui/JsonEditorField.jsx | 114 +++++++++++++++++ src/shared/ui/JsonEditorField.module.css | 105 ++++++++++++++++ src/shared/ui/JsonEditorField.test.jsx | 12 ++ 7 files changed, 379 insertions(+), 86 deletions(-) create mode 100644 src/shared/ui/JsonEditorField.jsx create mode 100644 src/shared/ui/JsonEditorField.module.css create mode 100644 src/shared/ui/JsonEditorField.test.jsx diff --git a/src/features/games/api.ts b/src/features/games/api.ts index d34d833..90ffab7 100644 --- a/src/features/games/api.ts +++ b/src/features/games/api.ts @@ -185,7 +185,7 @@ function normalizeCatalog(game: Partial): 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"; +} diff --git a/src/features/games/hooks/useGamesPage.js b/src/features/games/hooks/useGamesPage.js index 812d018..ed0f51f 100644 --- a/src/features/games/hooks/useGamesPage.js +++ b/src/features/games/hooks/useGamesPage.js @@ -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), }; } diff --git a/src/features/games/pages/GameListPage.jsx b/src/features/games/pages/GameListPage.jsx index b868328..d15de8a 100644 --- a/src/features/games/pages/GameListPage.jsx +++ b/src/features/games/pages/GameListPage.jsx @@ -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) => (
- {game.launchMode} + {launchModeLabel(game.launchMode)} {game.orientation}
), @@ -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 ( 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 ( ))} + ) : null} {/* 厂商差异字段放 JSON:如 uid_mode、default_lang、token_ttl_seconds、game_level_uid。 */} - page.setPlatformForm({ ...form, adapterConfigJson: event.target.value })} + onChange={(adapterConfigJson) => page.setPlatformForm({ ...form, adapterConfigJson })} /> @@ -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 {}; + } +} diff --git a/src/shared/ui/AdminSwitch.jsx b/src/shared/ui/AdminSwitch.jsx index 03bea8c..600a06e 100644 --- a/src/shared/ui/AdminSwitch.jsx +++ b/src/shared/ui/AdminSwitch.jsx @@ -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 ( - - ); + return ; } diff --git a/src/shared/ui/JsonEditorField.jsx b/src/shared/ui/JsonEditorField.jsx new file mode 100644 index 0000000..eeb7d65 --- /dev/null +++ b/src/shared/ui/JsonEditorField.jsx @@ -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 ( +
+
+
+ {label} + {required ? * : null} +
+
+ + +
+
+
+ + setValidated(true)} + onChange={(event) => { + setValidated(false); + onChange(event.target.value); + }} + /> +
+
+ {helperText} +
+
+ ); +} + +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}` }; + } +} diff --git a/src/shared/ui/JsonEditorField.module.css b/src/shared/ui/JsonEditorField.module.css new file mode 100644 index 0000000..0449d24 --- /dev/null +++ b/src/shared/ui/JsonEditorField.module.css @@ -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); +} diff --git a/src/shared/ui/JsonEditorField.test.jsx b/src/shared/ui/JsonEditorField.test.jsx new file mode 100644 index 0000000..eca8f64 --- /dev/null +++ b/src/shared/ui/JsonEditorField.test.jsx @@ -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 必须是对象"); +});