游戏配置

This commit is contained in:
zhx 2026-07-16 11:48:12 +08:00
parent e04aa7ed2f
commit 00b6af42dd
16 changed files with 1482 additions and 209 deletions

View File

@ -543,10 +543,31 @@ test("renders social BI data requirements table with section filters and CSV exp
expect(fetchSocialBiRequirements).toHaveBeenCalledWith(expect.objectContaining({ expect(fetchSocialBiRequirements).toHaveBeenCalledWith(expect.objectContaining({
payerType: "all", payerType: "all",
regionIds: [9, 11], regionIds: [9, 11],
section: "new_users", section: "revenue",
userRole: "all" userRole: "all"
})); }));
expect(screen.getByRole("tab", { name: "新用户" })).toHaveAttribute("aria-selected", "true"); const requirementTabs = screen.getByRole("tablist", { name: "数据需求分组" });
expect(within(requirementTabs).getAllByRole("tab").map((tab) => tab.textContent)).toEqual([
"充值",
"新增",
"日活",
"礼物",
"游戏",
"留存"
]);
expect(screen.getByRole("tab", { name: "充值" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("radiogroup", { name: "用户身份" })).toBeTruthy();
expect(screen.getByRole("radiogroup", { name: "付费身份" })).toBeTruthy();
expect(screen.getByText("充值用户")).toBeTruthy();
expect(screen.getByText("充值金额")).toBeTruthy();
expect(screen.getAllByText("$123").length).toBeGreaterThan(0);
await act(async () => {
screen.getByRole("tab", { name: "新增" }).click();
});
await flushEffects();
expect(fetchSocialBiRequirements).toHaveBeenLastCalledWith(expect.objectContaining({ section: "new_users" }));
expect(screen.getByRole("tab", { name: "新增" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("下载APP人数")).toBeTruthy(); expect(screen.getByText("下载APP人数")).toBeTruthy();
expect(screen.getByText("注册成功率")).toBeTruthy(); expect(screen.getByText("注册成功率")).toBeTruthy();
expect(screen.getAllByText("12").length).toBeGreaterThan(0); expect(screen.getAllByText("12").length).toBeGreaterThan(0);
@ -555,7 +576,7 @@ test("renders social BI data requirements table with section filters and CSV exp
expect(screen.queryByRole("radiogroup", { name: "付费身份" })).toBeNull(); expect(screen.queryByRole("radiogroup", { name: "付费身份" })).toBeNull();
await act(async () => { await act(async () => {
screen.getByRole("tab", { name: "营收" }).click(); screen.getByRole("tab", { name: "充值" }).click();
}); });
await flushEffects(); await flushEffects();
expect(screen.getByRole("radiogroup", { name: "用户身份" })).toBeTruthy(); expect(screen.getByRole("radiogroup", { name: "用户身份" })).toBeTruthy();

View File

@ -88,7 +88,7 @@ export function SocialBiApp() {
}, []); }, []);
const openRequirementSection = useCallback((requirementSection) => { const openRequirementSection = useCallback((requirementSection) => {
// DataTableView / // DataTableView /
setTableState({ mode: "requirements", requirementSection }); setTableState({ mode: "requirements", requirementSection });
setViewKey("table"); setViewKey("table");
}, []); }, []);

View File

@ -6,12 +6,12 @@ import { lastDaysRange, thisMonthRange, todayRange } from "../utils/time.js";
export const ALL = "all"; export const ALL = "all";
const TABLE_MODES = new Set(["wide", "requirements"]); const TABLE_MODES = new Set(["wide", "requirements"]);
const REQUIREMENT_SECTIONS = new Set(["new_users", "revenue", "retention", "active", "gift", "game"]); const REQUIREMENT_SECTIONS = new Set(["revenue", "new_users", "active", "gift", "game", "retention"]);
export function createDefaultTableState() { export function createDefaultTableState() {
return { return {
mode: "wide", mode: "wide",
requirementSection: "new_users" requirementSection: "revenue"
}; };
} }
@ -200,9 +200,9 @@ export function writeStateToURL(filters, view, table, history = window.history,
params.set("view", view); params.set("view", view);
} }
if (view === "table" && table?.mode === "requirements") { if (view === "table" && table?.mode === "requirements") {
// 目标分组与数据需求模式一起入 URL保证概览下钻和复制后的链接都能落到同一个游戏数据视图。 // 非默认分组与数据需求模式一起入 URL保证概览下钻和复制后的链接都能落到同一个业务明细视图。
params.set("mode", "requirements"); params.set("mode", "requirements");
if (REQUIREMENT_SECTIONS.has(table.requirementSection) && table.requirementSection !== "new_users") { if (REQUIREMENT_SECTIONS.has(table.requirementSection) && table.requirementSection !== "revenue") {
params.set("section", table.requirementSection); params.set("section", table.requirementSection);
} }
} }

View File

@ -47,17 +47,6 @@ const TABLE_MODES = [
]; ];
const REQUIREMENT_SECTIONS = [ const REQUIREMENT_SECTIONS = [
{
csvName: "p0-new-users",
fallbackColumns: [
{ key: "app_first_open_users", label: "下载APP人数", type: "count" },
{ key: "new_users", label: "新增注册", type: "count" },
{ key: "profile_complete_users", label: "完善资料", type: "count" },
{ key: "first_room_join_users", label: "首次进房", type: "count" }
],
key: "new_users",
label: "新用户"
},
{ {
csvName: "p1-revenue", csvName: "p1-revenue",
fallbackColumns: [ fallbackColumns: [
@ -68,25 +57,21 @@ const REQUIREMENT_SECTIONS = [
{ key: "arppu_usd_minor", label: "ARPPU", type: "money_minor" } { key: "arppu_usd_minor", label: "ARPPU", type: "money_minor" }
], ],
key: "revenue", key: "revenue",
label: "营收", label: "充值",
newUserFilter: true, newUserFilter: true,
payerFilter: true, payerFilter: true,
roleFilter: true roleFilter: true
}, },
{ {
csvName: "p2-retention", csvName: "p0-new-users",
fallbackColumns: [ fallbackColumns: [
{ key: "active_d1_active_base_users", label: "活跃用户基数", type: "count" }, { key: "app_first_open_users", label: "下载APP人数", type: "count" },
{ key: "active_d1_active_users", label: "次留人数", type: "count" }, { key: "new_users", label: "新增注册", type: "count" },
{ key: "active_d1_active_rate", label: "用户次留", type: "ratio" }, { key: "profile_complete_users", label: "完善资料", type: "count" },
{ key: "active_d7_active_rate", label: "用户7日留", type: "ratio" }, { key: "first_room_join_users", label: "首次进房", type: "count" }
{ key: "active_d30_active_rate", label: "用户30日留", type: "ratio" }
], ],
key: "retention", key: "new_users",
label: "留存", label: "新增"
newUserFilter: true,
payerFilter: true,
roleFilter: true
}, },
{ {
csvName: "p2-active", csvName: "p2-active",
@ -98,7 +83,7 @@ const REQUIREMENT_SECTIONS = [
{ key: "avg_session_ms", label: "人均在线", type: "duration_ms" } { key: "avg_session_ms", label: "人均在线", type: "duration_ms" }
], ],
key: "active", key: "active",
label: "", label: "活",
newUserFilter: true, newUserFilter: true,
payerFilter: true, payerFilter: true,
roleFilter: true roleFilter: true
@ -112,7 +97,7 @@ const REQUIREMENT_SECTIONS = [
{ key: "gift_paid_users", label: "付费送礼用户", type: "count" } { key: "gift_paid_users", label: "付费送礼用户", type: "count" }
], ],
key: "gift", key: "gift",
label: "礼", label: "",
newUserFilter: true, newUserFilter: true,
roleFilter: true roleFilter: true
}, },
@ -129,6 +114,21 @@ const REQUIREMENT_SECTIONS = [
label: "游戏", label: "游戏",
newUserFilter: true, newUserFilter: true,
roleFilter: true roleFilter: true
},
{
csvName: "p2-retention",
fallbackColumns: [
{ key: "active_d1_active_base_users", label: "活跃用户基数", type: "count" },
{ key: "active_d1_active_users", label: "次留人数", type: "count" },
{ key: "active_d1_active_rate", label: "用户次留", type: "ratio" },
{ key: "active_d7_active_rate", label: "用户7日留", type: "ratio" },
{ key: "active_d30_active_rate", label: "用户30日留", type: "ratio" }
],
key: "retention",
label: "留存",
newUserFilter: true,
payerFilter: true,
roleFilter: true
} }
]; ];

View File

@ -4,7 +4,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
import { EChart } from "../../charts/EChart.jsx"; import { EChart } from "../../charts/EChart.jsx";
import { deltaDirection, formatCount, formatDeltaRate, formatMoney, isBlank } from "../format.js"; import { deltaDirection, formatCount, formatDeltaRate, formatMoney, formatPercentValue, isBlank } from "../format.js";
import { aggregateMetricMaps, appColor, formatMetric, metricChartValue, metricLabel, metricTooltip } from "../metrics.js"; import { aggregateMetricMaps, appColor, formatMetric, metricChartValue, metricLabel, metricTooltip } from "../metrics.js";
import { SocialAppIdentity, useSocialBi } from "../SocialBiApp.jsx"; import { SocialAppIdentity, useSocialBi } from "../SocialBiApp.jsx";
import { bucketDay } from "../state.js"; import { bucketDay } from "../state.js";
@ -20,11 +20,12 @@ const HERO_METRICS = [
]; ];
const TREND_METRICS = [ const TREND_METRICS = [
{ key: "recharge_usd_minor", label: "充值" }, { key: "recharge_usd_minor", label: "充值", requirementSection: "revenue" },
{ key: "new_users", label: "新增" }, { key: "new_users", label: "新增", requirementSection: "new_users" },
{ key: "active_users", label: "日活" }, { key: "active_users", label: "日活", requirementSection: "active" },
{ key: "gift_coin_spent", label: "礼物流水" }, { key: "gift_coin_spent", label: "礼物", requirementSection: "gift" },
{ key: "game_turnover", label: "游戏流水" } { key: "game_turnover", label: "游戏", requirementSection: "game" },
{ key: "d1_retention_rate", label: "留存", requirementSection: "retention" }
]; ];
const COMPARE_METRICS = [ const COMPARE_METRICS = [
@ -42,12 +43,16 @@ const MONEY_METRIC_KEYS = new Set([
"mifapay_recharge_usd_minor", "mifapay_recharge_usd_minor",
"coin_seller_recharge_usd_minor" "coin_seller_recharge_usd_minor"
]); ]);
const RATIO_METRIC_KEYS = new Set(["d1_retention_rate"]);
// metricChartValue tooltip // metricChartValue tooltip
function formatChartAmount(metricKey, value) { function formatChartAmount(metricKey, value) {
if (isBlank(value)) { if (isBlank(value)) {
return "--"; return "--";
} }
if (RATIO_METRIC_KEYS.has(metricKey)) {
return formatPercentValue(value);
}
return MONEY_METRIC_KEYS.has(metricKey) ? formatMoney(value) : formatCount(value); return MONEY_METRIC_KEYS.has(metricKey) ? formatMoney(value) : formatCount(value);
} }
@ -65,6 +70,7 @@ function buildBuckets(rows, granularity) {
} }
function buildStackedBarOption({ bucketKeys, series, valueMetric }) { function buildStackedBarOption({ bucketKeys, series, valueMetric }) {
const isRatioMetric = RATIO_METRIC_KEYS.has(valueMetric);
return { return {
grid: { bottom: 4, containLabel: true, left: 8, right: 8, top: 28 }, grid: { bottom: 4, containLabel: true, left: 8, right: 8, top: 28 },
legend: { itemHeight: 10, itemWidth: 10, left: 0, textStyle: { color: "#5b7089", fontSize: 12 }, top: 0 }, legend: { itemHeight: 10, itemWidth: 10, left: 0, textStyle: { color: "#5b7089", fontSize: 12 }, top: 0 },
@ -84,7 +90,7 @@ function buildStackedBarOption({ bucketKeys, series, valueMetric }) {
return `${item.marker}${item.seriesName}&nbsp;&nbsp;<b>${formatChartAmount(valueMetric, numeric)}</b>`; return `${item.marker}${item.seriesName}&nbsp;&nbsp;<b>${formatChartAmount(valueMetric, numeric)}</b>`;
}); });
const header = list[0]?.axisValueLabel || list[0]?.name || ""; const header = list[0]?.axisValueLabel || list[0]?.name || "";
if (list.length > 1) { if (list.length > 1 && !isRatioMetric) {
lines.push(`合计&nbsp;&nbsp;<b>${formatChartAmount(valueMetric, total)}</b>`); lines.push(`合计&nbsp;&nbsp;<b>${formatChartAmount(valueMetric, total)}</b>`);
} }
return [header, ...lines].filter(Boolean).join("<br/>"); return [header, ...lines].filter(Boolean).join("<br/>");
@ -100,7 +106,7 @@ function buildStackedBarOption({ bucketKeys, series, valueMetric }) {
type: "category" type: "category"
}, },
yAxis: { yAxis: {
axisLabel: { color: "#5b7089", fontSize: 11 }, axisLabel: { color: "#5b7089", fontSize: 11, ...(isRatioMetric ? { formatter: "{value}%" } : {}) },
splitLine: { lineStyle: { color: "#eef3f9" } }, splitLine: { lineStyle: { color: "#eef3f9" } },
type: "value" type: "value"
} }
@ -230,12 +236,13 @@ export function OverviewView() {
}, [appCodes, buckets]); }, [appCodes, buckets]);
const trendOption = useMemo(() => { const trendOption = useMemo(() => {
const shouldStack = !RATIO_METRIC_KEYS.has(trendMetric);
const series = appBuckets.apps.map((app) => ({ const series = appBuckets.apps.map((app) => ({
barMaxWidth: 26, barMaxWidth: 26,
data: app.cells.map((cell) => (cell ? metricChartValue(trendMetric, cell[trendMetric]) : null)), data: app.cells.map((cell) => (cell ? metricChartValue(trendMetric, cell[trendMetric]) : null)),
itemStyle: { color: appColor(app.app_code, appCodes) }, itemStyle: { color: appColor(app.app_code, appCodes) },
name: app.app_name, name: app.app_name,
stack: "total", ...(shouldStack ? { stack: "total" } : {}),
type: "bar" type: "bar"
})); }));
return buildStackedBarOption({ bucketKeys: appBuckets.keys, series, valueMetric: trendMetric }); return buildStackedBarOption({ bucketKeys: appBuckets.keys, series, valueMetric: trendMetric });
@ -298,7 +305,7 @@ export function OverviewView() {
const soloApp = !derived.regionScoped && derived.appTotals.length === 1 ? derived.appTotals[0] : null; const soloApp = !derived.regionScoped && derived.appTotals.length === 1 ? derived.appTotals[0] : null;
const hasRestricted = derived.appTotals.some((row) => row.restricted) || derived.appErrors.some((row) => row.restricted); const hasRestricted = derived.appTotals.some((row) => row.restricted) || derived.appErrors.some((row) => row.restricted);
const hasTrend = appBuckets.keys.length > 0 && appBuckets.apps.length > 0; const hasTrend = appBuckets.keys.length > 0 && appBuckets.apps.length > 0;
const trendLabel = TREND_METRICS.find((item) => item.key === trendMetric)?.label || ""; const trendConfig = TREND_METRICS.find((item) => item.key === trendMetric) || TREND_METRICS[0];
if (isLoading && !derived.appTotals.length && !derived.regionTotals.length) { if (isLoading && !derived.appTotals.length && !derived.regionTotals.length) {
return <OverviewSkeleton />; return <OverviewSkeleton />;
@ -331,20 +338,18 @@ export function OverviewView() {
<section className="sbi-card"> <section className="sbi-card">
<header className="sbi-card-header"> <header className="sbi-card-header">
<strong>{trendLabel}趋势</strong> <strong>{trendConfig.label}趋势</strong>
<small> App 堆叠</small> <small>{RATIO_METRIC_KEYS.has(trendMetric) ? "按 App 对比" : "按 App 堆叠"}</small>
<div className="sbi-card-toolbar"> <div className="sbi-card-toolbar">
{trendMetric === "game_turnover" ? ( {/* 趋势 Tab 与数据需求分组一一映射,按钮始终跟随当前指标进入对应明细。 */}
//
<Button <Button
onClick={() => openRequirementSection("game")} onClick={() => openRequirementSection(trendConfig.requirementSection)}
size="small" size="small"
title="进入数据需求中的游戏数据明细" title={`进入数据需求中的${trendConfig.label}明细`}
variant="outlined" variant="outlined"
> >
查看明细 查看明细
</Button> </Button>
) : null}
<div className="sbi-seg" role="radiogroup" aria-label="趋势指标"> <div className="sbi-seg" role="radiogroup" aria-label="趋势指标">
{TREND_METRICS.map((item) => ( {TREND_METRICS.map((item) => (
<button <button

View File

@ -29,7 +29,7 @@ export default function LoginPage() {
const location = useLocation(); const location = useLocation();
useEffect(() => { useEffect(() => {
// App 使 App // App 使 App
globalThis.document.title = t("app.title"); globalThis.document.title = t("app.title");
globalThis.document.head.querySelector('link[data-external-app-favicon="true"]')?.remove(); globalThis.document.head.querySelector('link[data-external-app-favicon="true"]')?.remove();
}, [t]); }, [t]);

View File

@ -0,0 +1,670 @@
const commonFields = [
{
key: "uid_mode",
label: "用户标识",
type: "select",
options: [
["", "按协议默认"],
["user_id", "内部用户 ID"],
["display_user_id", "用户短号 / 展示 ID"],
],
},
{ key: "default_lang", label: "默认语言", placeholder: "按协议默认", type: "string" },
{
key: "token_ttl_seconds",
label: "Token 有效期",
min: 1,
placeholder: "86400",
type: "integer",
unit: "秒",
},
];
const catalogKeys = ["game_urls", "game_names", "game_icons", "game_covers", "game_level_uids"];
const globalConfigKeys = [
"bridge_script_url",
"bridge_script_version",
"bridge_script_sha256",
"bridgeScriptUrl",
"bridgeScriptVersion",
"bridgeScriptSha256",
"bridge_script_hash",
];
// 单一注册表同时驱动选择器、字段、顶层接入语义和序列化类型,避免页面与提交逻辑各维护一份厂商分支。
export const adapterDefinitions = {
yomi_v4: {
label: "小游 Yomi V4",
apiUrlLabel: "Auth / Gateway URL",
apiUrlRequired: true,
callbackSecretLabel: "Yomi AppSecret",
ipWhitelist: true,
catalog: true,
catalogPrimary: "name",
fields: [
{ key: "game_level_uid", label: "默认场次 ID", min: 0, type: "integer" },
{ key: "plat_payload", label: "平台透传参数", type: "string" },
],
},
leadercc_v1: {
label: "灵仙 LeaderCC V1",
apiUrl: false,
callbackSecretLabel: "灵仙签名 Key",
ipWhitelist: false,
catalog: false,
fields: [],
},
zeeone_v1: {
label: "ZeeOne V1",
apiUrlLabel: "默认 H5 URL",
callbackSecretLabel: "ZeeOne 签名 Secret",
ipWhitelist: false,
catalog: true,
fields: [
{ key: "merchant_id", label: "商户 ID", min: 1, required: true, type: "integer" },
{ key: "platform_id", label: "平台 ID", min: 1, required: true, type: "integer" },
{ key: "app_id", label: "回调 App ID", placeholder: "默认 M{商户ID}_P{平台ID}", type: "string" },
{ key: "game_mode", label: "游戏模式", type: "string" },
{ key: "extra", label: "启动透传参数", type: "string" },
{
className: "wide",
key: "launch_url_template",
label: "启动 URL 模板",
placeholder: "https://game.example/{provider_game_id}",
type: "string",
},
],
},
baishun_v1: {
label: "百顺 BAISHUN V1",
apiUrlLabel: "厂商 API / 默认 H5 URL",
callbackSecretLabel: "百顺 AppKey",
ipWhitelist: true,
catalog: true,
fields: [
{ key: "app_id", label: "App ID", min: 1, required: true, type: "integer" },
{ key: "app_channel", label: "App Channel", required: true, type: "string" },
{ key: "game_list_url", label: "游戏列表 URL", type: "string" },
{ key: "game_list_type", label: "游戏列表类型", min: 1, placeholder: "1", type: "integer" },
{ key: "provider_api_base_url", label: "厂商 API Base URL", type: "string" },
{ key: "game_mode", label: "游戏模式", placeholder: "3", type: "string" },
{ key: "scene_mode", label: "场景模式", min: 0, type: "integer" },
{ key: "currency_icon", label: "货币图标 URL", type: "string" },
{ key: "gsp", label: "GSP", min: 0, type: "integer" },
{
className: "wide",
key: "launch_url_template",
label: "启动 URL 模板",
placeholder: "https://game.example/{provider_game_id}",
type: "string",
},
],
advancedFields: [
{ key: "user_type", label: "用户类型", min: 0, type: "integer" },
{ key: "release_cond", label: "放行条件", min: 0, type: "integer" },
{
key: "is_old_user",
label: "历史用户",
type: "selectBoolean",
options: [
["", "不传"],
["true", "是"],
["false", "否"],
],
},
{ key: "extend", label: "扩展参数", type: "string" },
],
},
vivagames_v1: {
label: "VIVAGAMES V1",
apiUrlLabel: "默认 H5 URL",
callbackSecretLabel: "VIVAGAMES AppKey",
ipWhitelist: true,
catalog: true,
fields: [
{ key: "app_id", label: "App ID", min: 1, required: true, type: "integer" },
{ key: "app_secret", label: "App Secret", type: "string" },
{ key: "coin_type", label: "币种类型", min: 0, type: "integer" },
{ key: "amount_rate", label: "金额倍率", min: 1, placeholder: "1", type: "integer" },
{
key: "bgm_switch",
label: "背景音乐",
type: "selectNumber",
options: [
["", "按协议默认"],
["1", "开启"],
["0", "关闭"],
],
},
{ key: "gsp", label: "GSP", min: 1, placeholder: "101", type: "integer" },
{ key: "currency_icon", label: "货币图标 URL", type: "string" },
{ key: "metadata", label: "启动 Metadata", type: "string" },
{
className: "wide",
key: "launch_url_template",
label: "启动 URL 模板",
placeholder: "https://game.example/{provider_game_id}",
type: "string",
},
],
},
reyou_v1: {
label: "热游 Reyou V1",
apiUrlLabel: "默认 H5 URL",
callbackSecretLabel: "热游签名 Key",
ipWhitelist: true,
catalog: true,
fields: [
{
className: "wide",
key: "launch_url_template",
label: "启动 URL 模板",
placeholder: "https://game.example/{provider_game_id}",
type: "string",
},
],
},
zgame_v1: {
label: "ZGame V1",
apiUrlLabel: "默认 H5 URL",
callbackSecretLabel: "ZGame 签名 Secret",
fixedPlatformCode: "zgame",
fixedPlatformName: "ZGame",
ipWhitelist: true,
catalog: true,
fields: [
{
className: "wide",
key: "callback_base",
label: "公网回调 Base URL",
placeholder: "https://api.example.com/api/v1/zgame/callback",
type: "string",
},
{ key: "merchant_payload", label: "商户透传参数", type: "string" },
{ key: "level", label: "默认等级", min: 0, type: "integer" },
{ key: "gender", label: "默认性别", min: 0, type: "integer" },
{
className: "wide",
key: "launch_url_template",
label: "启动 URL 模板",
placeholder: "https://game.example/{provider_game_id}",
type: "string",
},
],
},
amg_v1: {
label: "AMG V1",
apiUrlLabel: "默认 H5 URL",
callbackSecretLabel: "AMG PKCS#8 私钥Base64",
callbackSecretPlaceholder: "粘贴厂商提供的单行 Base64 私钥",
fixedPlatformCode: "amg",
fixedPlatformName: "AMG",
ipWhitelist: true,
catalog: true,
numericGameID: true,
fields: [
{ key: "app_key", label: "AMG App Key", required: true, type: "string" },
{
key: "coin_scale",
label: "金币精度",
type: "selectNumber",
options: [
["", "默认 1"],
["1", "1整数金币"],
["10", "101 位小数)"],
["100", "1002 位小数)"],
["1000", "1,0003 位小数)"],
["10000", "10,0004 位小数)"],
["100000", "100,0005 位小数)"],
["1000000", "1,000,0006 位小数)"],
],
},
{ key: "ext", label: "启动扩展参数", type: "string" },
{ key: "loading", label: "Loading 参数", type: "string" },
{
className: "wide",
key: "launch_url_template",
label: "启动 URL 模板",
placeholder: "https://game.example/{provider_game_id}",
type: "string",
},
],
advancedFields: [
{ key: "mini", label: "Mini 参数", min: 0, type: "integer" },
{ key: "app_audio", label: "App Audio 参数", min: 0, type: "integer" },
{ key: "debug", label: "Debug 参数", min: 0, type: "integer" },
{ key: "use_lang", label: "Use Lang 参数", min: 0, type: "integer" },
],
},
};
export const adapterTypeOptions = Object.entries(adapterDefinitions).map(([value, definition]) => [
value,
definition.label,
]);
export const writableBridgeAdapterTypes = new Set(Object.keys(adapterDefinitions));
const aliasesByKey = {
app_id: ["appId"],
app_channel: ["appChannel"],
platform_id: ["platform"],
callback_base: ["callbackBase"],
provider_api_base_url: ["providerApiBaseUrl"],
game_list_url: ["gameListUrl"],
game_list_type: ["gameListType"],
};
export function adapterDefinition(adapterType) {
return adapterDefinitions[adapterType] || adapterDefinitions.yomi_v4;
}
export function adapterCommonFields() {
return commonFields;
}
export function adapterSecretState(form, editing = false) {
const adapterType = String(form.adapterType || "").trim() || "yomi_v4";
const adapterChanged =
editing && Boolean(form.initialAdapterType) && String(form.initialAdapterType).trim() !== adapterType;
const configuredSecretUsable = editing && !adapterChanged && Boolean(form.callbackSecretSet);
return {
adapterChanged,
configuredSecretUsable,
required:
adapterChanged || ((String(form.status || "").trim() || "active") === "active" && !configuredSecretUsable),
};
}
export function normalizeAdapterCallbackSecret(form, editing = false) {
const callbackSecret = String(form.callbackSecret || "").trim();
if (adapterSecretState(form, editing).required && !callbackSecret) {
const definition = adapterDefinition(String(form.adapterType || "").trim());
throw new Error(`请填写${definition.callbackSecretLabel || "回调密钥"}`);
}
return callbackSecret;
}
export function readAdapterConfig(raw) {
const value = String(raw || "").trim();
if (!value) {
return {};
}
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}
export function adapterConfigFieldValue(raw, field) {
const entries = parseObjectEntries(raw);
if (!entries) {
return "";
}
const rawValue = findFieldRawValue(entries, field, aliasesByKey[field.key] || []);
const value = decodeRawValue(rawValue);
if (value === undefined || value === null) {
return "";
}
if (field.type === "selectBoolean") {
return value === true || value === "true" ? "true" : value === false || value === "false" ? "false" : "";
}
return String(value);
}
export function updateAdapterConfigField(raw, field, inputValue) {
const entries = parseObjectEntries(raw);
if (!entries) {
// 非法原始 JSON 必须由高级编辑器先修正;此时拒绝结构化 patch避免把无法解析的历史配置覆盖成新对象。
return String(raw || "{}");
}
const rawValue =
inputValue === "" || inputValue === null || inputValue === undefined
? undefined
: encodeFieldRawValue(field, inputValue);
const next = replaceObjectEntry(entries, field.key, aliasesByKey[field.key] || [], rawValue);
return serializeObjectEntries(next, true);
}
export function normalizeAdapterConfigJson(raw, adapterType) {
let entries = parseObjectEntries(raw);
if (!entries) {
throw new Error("适配器高级配置必须是 JSON 对象");
}
const activeFields = new Map(
[
...commonFields,
...(adapterDefinition(adapterType).fields || []),
...(adapterDefinition(adapterType).advancedFields || []),
].map((field) => [field.key, field]),
);
for (const field of activeFields.values()) {
const aliases = aliasesByKey[field.key] || [];
const rawValue = findFieldRawValue(entries, field, aliases);
if (rawValue === undefined) {
continue;
}
const value = decodeRawValue(rawValue);
// 只迁移当前厂商实际识别的历史别名;其他 key 可能属于未来协议,必须按原始 JSON token 保留。
entries = replaceObjectEntry(
entries,
field.key,
aliases,
value === "" || value === null ? undefined : encodeFieldRawValue(field, value, rawValue),
);
}
return serializeObjectEntries(entries);
}
export function migrateAdapterConfigJson(raw, previousAdapterType, nextAdapterType) {
let entries = parseObjectEntries(raw);
if (!entries || previousAdapterType === nextAdapterType) {
return String(raw || "{}");
}
const preservedKeys = new Set([...commonFields.map((field) => field.key), ...catalogKeys, ...globalConfigKeys]);
const previous = adapterDefinition(previousAdapterType);
for (const field of [...(previous.fields || []), ...(previous.advancedFields || [])]) {
if (!preservedKeys.has(field.key)) {
entries = replaceObjectEntry(entries, field.key, aliasesByKey[field.key] || [], undefined);
}
}
return serializeObjectEntries(entries, true);
}
export function adapterCatalogRows(raw) {
const entries = parseObjectEntries(raw);
if (!entries) {
return [];
}
const maps = Object.fromEntries(catalogKeys.map((key) => [key, readRawObjectMap(entries, key)]));
const ids = [];
const seen = new Set();
for (const key of catalogKeys) {
for (const id of Object.keys(maps[key])) {
const normalized = String(id || "").trim();
if (normalized && !seen.has(normalized)) {
seen.add(normalized);
ids.push(normalized);
}
}
}
return ids.map((providerGameId) => ({
providerGameId,
gameName: stringValue(maps.game_names[providerGameId]),
gameUrl: stringValue(maps.game_urls[providerGameId]),
iconUrl: stringValue(maps.game_icons[providerGameId]),
coverUrl: stringValue(maps.game_covers[providerGameId]),
gameLevelUid: numberInputValue(maps.game_level_uids[providerGameId]),
}));
}
export function updateAdapterCatalog(raw, rows) {
let entries = parseObjectEntries(raw);
if (!entries) {
return String(raw || "{}");
}
const providerGameIDs = (rows || []).map((row) => String(row.providerGameId || "").trim()).filter(Boolean);
if (new Set(providerGameIDs).size !== providerGameIDs.length) {
// JSON map 无法表达重复 key拒绝改写底层配置让表单保留两行并通过原生校验提示用户修正。
return String(raw || "{}");
}
const gameURLs = new Map();
const gameNames = new Map();
const gameIcons = new Map();
const gameCovers = new Map();
const gameLevelUIDs = new Map();
for (const row of rows || []) {
const providerGameID = String(row.providerGameId || "").trim();
if (!providerGameID) {
continue;
}
assignTrimmed(gameURLs, providerGameID, row.gameUrl);
assignTrimmed(gameNames, providerGameID, row.gameName);
assignTrimmed(gameIcons, providerGameID, row.iconUrl);
assignTrimmed(gameCovers, providerGameID, row.coverUrl);
const levelUID = positiveIntegerRawValue(row.gameLevelUid);
if (levelUID) {
gameLevelUIDs.set(providerGameID, levelUID);
}
}
entries = replaceObjectEntry(entries, "game_urls", [], serializeStringMap(gameURLs));
entries = replaceObjectEntry(entries, "game_names", [], serializeStringMap(gameNames));
entries = replaceObjectEntry(entries, "game_icons", [], serializeStringMap(gameIcons));
entries = replaceObjectEntry(entries, "game_covers", [], serializeStringMap(gameCovers));
entries = replaceObjectEntry(entries, "game_level_uids", [], serializeRawMap(gameLevelUIDs));
return serializeObjectEntries(entries, true);
}
// 结构化编辑只重写目标顶层字段。未知字段的 raw JSON token尤其 int64必须原样保留不能经过 Number。
function encodeFieldRawValue(field, value, fallbackRawValue) {
if (field.type === "integer" || field.type === "selectNumber") {
return integerRawValue(value) || fallbackRawValue || JSON.stringify(String(value).trim());
}
if (field.type === "selectBoolean") {
return value === true || value === "true" ? "true" : "false";
}
return JSON.stringify(String(value).trim());
}
function parseObjectEntries(raw) {
const source = String(raw || "").trim() || "{}";
try {
const parsed = JSON.parse(source);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
} catch {
return null;
}
const entries = [];
let index = skipWhitespace(source, 1);
while (index < source.length && source[index] !== "}") {
if (source[index] !== '"') {
return null;
}
const keyEnd = scanJSONString(source, index);
const key = JSON.parse(source.slice(index, keyEnd));
index = skipWhitespace(source, keyEnd);
if (source[index] !== ":") {
return null;
}
const valueStart = skipWhitespace(source, index + 1);
const valueEnd = scanJSONValue(source, valueStart);
entries.push({ key, rawValue: source.slice(valueStart, valueEnd) });
index = skipWhitespace(source, valueEnd);
if (source[index] === ",") {
index = skipWhitespace(source, index + 1);
} else if (source[index] !== "}") {
return null;
}
}
return entries;
}
function scanJSONString(source, start) {
let index = start + 1;
while (index < source.length) {
if (source[index] === "\\") {
index += 2;
} else if (source[index] === '"') {
return index + 1;
} else {
index += 1;
}
}
return source.length;
}
function scanJSONValue(source, start) {
if (source[start] === '"') {
return scanJSONString(source, start);
}
if (source[start] === "{" || source[start] === "[") {
const stack = [source[start]];
let index = start + 1;
while (index < source.length && stack.length > 0) {
if (source[index] === '"') {
index = scanJSONString(source, index);
continue;
}
if (source[index] === "{" || source[index] === "[") {
stack.push(source[index]);
} else if (source[index] === "}" || source[index] === "]") {
stack.pop();
}
index += 1;
}
return index;
}
let index = start;
while (index < source.length && !/[\s,}\]]/.test(source[index])) {
index += 1;
}
return index;
}
function skipWhitespace(source, start) {
let index = start;
while (index < source.length && /\s/.test(source[index])) {
index += 1;
}
return index;
}
function findEntryRawValue(entries, key, aliases) {
for (const candidate of [key, ...aliases]) {
for (let index = entries.length - 1; index >= 0; index -= 1) {
if (entries[index].key === candidate) {
return entries[index].rawValue;
}
}
}
return undefined;
}
function findFieldRawValue(entries, field, aliases) {
const canonical = findEntryRawValue(entries, field.key, []);
if (
aliases.length === 0 ||
(canonical !== undefined && !fieldValueFallsBackToAlias(field, decodeRawValue(canonical)))
) {
return canonical;
}
const alias = findEntryRawValue(entries, aliases[0], aliases.slice(1));
return alias === undefined ? canonical : alias;
}
function fieldValueFallsBackToAlias(field, value) {
if (value === undefined || value === null) {
return true;
}
if (field.type === "integer" || field.type === "selectNumber") {
const normalized = integerRawValue(value);
return normalized === "0";
}
return typeof value === "string" && value.trim() === "";
}
function decodeRawValue(rawValue) {
if (rawValue === undefined) {
return undefined;
}
const value = String(rawValue).trim();
if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(value)) {
return value;
}
return JSON.parse(value);
}
function replaceObjectEntry(entries, key, aliases, rawValue) {
const replacedKeys = new Set([key, ...aliases]);
const firstIndex = entries.findIndex((entry) => replacedKeys.has(entry.key));
const next = entries.filter((entry) => !replacedKeys.has(entry.key));
if (rawValue === undefined || rawValue === null || rawValue === "") {
return next;
}
next.splice(firstIndex < 0 ? next.length : Math.min(firstIndex, next.length), 0, { key, rawValue });
return next;
}
function serializeObjectEntries(entries, pretty = false) {
if (!pretty) {
return `{${entries.map((entry) => `${JSON.stringify(entry.key)}:${entry.rawValue}`).join(",")}}`;
}
if (entries.length === 0) {
return "{}";
}
const body = entries
.map((entry) => ` ${JSON.stringify(entry.key)}: ${String(entry.rawValue).replace(/\n/g, "\n ")}`)
.join(",\n");
return `{\n${body}\n}`;
}
export function formatAdapterConfigJson(raw) {
const entries = parseObjectEntries(raw);
if (!entries) {
throw new Error("适配器高级配置必须是 JSON 对象");
}
return serializeObjectEntries(entries, true);
}
function integerRawValue(value) {
const text = String(value).trim();
if (!/^-?(?:0|[1-9]\d*)$/.test(text)) {
return "";
}
try {
return BigInt(text).toString();
} catch {
return "";
}
}
function positiveIntegerRawValue(value) {
const normalized = integerRawValue(value);
if (!normalized) {
return "";
}
return BigInt(normalized) > 0n ? normalized : "";
}
function readRawObjectMap(entries, key) {
const nestedEntries = parseObjectEntries(findEntryRawValue(entries, key, []) || "{}");
const value = Object.create(null);
for (const entry of nestedEntries || []) {
value[entry.key] = decodeRawValue(entry.rawValue);
}
return value;
}
function stringValue(value) {
return value === undefined || value === null ? "" : String(value);
}
function numberInputValue(value) {
return positiveIntegerRawValue(value);
}
function assignTrimmed(target, key, value) {
const normalized = String(value || "").trim();
if (normalized) {
target.set(key, normalized);
}
}
function serializeStringMap(value) {
if (value.size === 0) {
return undefined;
}
return `{${[...value].map(([key, item]) => `${JSON.stringify(key)}:${JSON.stringify(item)}`).join(",")}}`;
}
function serializeRawMap(value) {
if (value.size === 0) {
return undefined;
}
return `{${[...value].map(([key, item]) => `${JSON.stringify(key)}:${item}`).join(",")}}`;
}

View File

@ -14,7 +14,7 @@ export interface GamePlatformDto {
callbackSecret?: string; callbackSecret?: string;
callbackSecretSet: boolean; callbackSecretSet: boolean;
callbackIpWhitelist: string[]; callbackIpWhitelist: string[];
// 厂商扩展配置保留 JSON 字符串,后台前端不绑定具体字段结构 // JSON 是服务端传输格式;配置页会按 adapterType 显示结构化字段,并保留未来新增的未知 key
adapterConfigJson: string; adapterConfigJson: string;
sortOrder: number; sortOrder: number;
createdAtMs?: number; createdAtMs?: number;

View File

@ -0,0 +1,320 @@
import AddOutlined from "@mui/icons-material/AddOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import InputAdornment from "@mui/material/InputAdornment";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { useEffect, useMemo, useRef, useState } from "react";
import {
adapterCatalogRows,
adapterCommonFields,
adapterConfigFieldValue,
adapterDefinition,
formatAdapterConfigJson,
readAdapterConfig,
updateAdapterCatalog,
updateAdapterConfigField,
} from "@/features/games/adapterConfigModel.js";
import { Button } from "@/shared/ui/Button.jsx";
import { JsonEditorField } from "@/shared/ui/JsonEditorField.jsx";
import styles from "@/features/games/components/AdapterConfigFields.module.css";
export function AdapterConfigFields({ adapterType, disabled = false, value, onChange }) {
const definition = adapterDefinition(adapterType);
const configValid = Boolean(readAdapterConfig(value));
const visualFieldsDisabled = disabled || !configValid;
return (
<div className={styles.root}>
{!configValid ? <div className={styles.invalid}>请先在高级配置中修正 JSON 格式</div> : null}
<ConfigFieldGroup
disabled={visualFieldsDisabled}
fields={adapterCommonFields()}
title="会话参数"
value={value}
onChange={onChange}
/>
{(definition.fields || []).length > 0 ? (
<ConfigFieldGroup
disabled={visualFieldsDisabled}
fields={definition.fields}
title="厂商参数"
value={value}
onChange={onChange}
/>
) : null}
{definition.catalog ? (
<GameCatalogEditor
adapterType={adapterType}
disabled={visualFieldsDisabled}
value={value}
onChange={onChange}
/>
) : null}
{(definition.advancedFields || []).length > 0 ? (
<details className={styles.details}>
<summary>可选厂商参数</summary>
<ConfigFieldGrid
disabled={visualFieldsDisabled}
fields={definition.advancedFields}
value={value}
onChange={onChange}
/>
</details>
) : null}
<details className={styles.details} open={configValid ? undefined : true}>
<summary>高级配置 · 完整 JSON</summary>
<div className={styles.advancedEditor}>
<JsonEditorField
disabled={disabled}
formatter={formatAdapterConfigJson}
label="完整配置 JSON"
minRows={5}
value={value}
onChange={onChange}
/>
</div>
</details>
</div>
);
}
function ConfigFieldGroup({ disabled, fields, title, value, onChange }) {
return (
<section className={styles.group}>
<div className={styles.groupTitle}>{title}</div>
<ConfigFieldGrid disabled={disabled} fields={fields} value={value} onChange={onChange} />
</section>
);
}
function ConfigFieldGrid({ disabled, fields, value, onChange }) {
return (
<div className={styles.fieldGrid}>
{fields.map((field) => (
<AdapterField
disabled={disabled}
field={field}
key={field.key}
value={adapterConfigFieldValue(value, field)}
onChange={(inputValue) => onChange(updateAdapterConfigField(value, field, inputValue))}
/>
))}
</div>
);
}
function AdapterField({ disabled, field, value, onChange }) {
const className = field.className === "wide" ? styles.wideField : undefined;
const commonProps = {
className,
disabled,
label: field.label,
placeholder: field.placeholder,
required: field.required,
value,
onChange: (event) => onChange(event.target.value),
};
if (field.type === "select" || field.type === "selectNumber" || field.type === "selectBoolean") {
return (
<TextField {...commonProps} select>
{(field.options || []).map(([optionValue, optionLabel]) => (
<MenuItem key={`${field.key}-${optionValue}`} value={optionValue}>
{optionLabel}
</MenuItem>
))}
</TextField>
);
}
return (
<TextField
{...commonProps}
slotProps={{
htmlInput: {
min: field.min,
step: field.type === "integer" ? 1 : undefined,
},
input: field.unit
? { endAdornment: <InputAdornment position="end">{field.unit}</InputAdornment> }
: undefined,
}}
type={field.type === "integer" ? "number" : "text"}
/>
);
}
function GameCatalogEditor({ adapterType, disabled, value, onChange }) {
const definition = adapterDefinition(adapterType);
const externalRows = useMemo(() => adapterCatalogRows(value), [value]);
const externalSignature = useMemo(() => catalogSignature(externalRows), [externalRows]);
const lastEmittedSignature = useRef(externalSignature);
const adapterTypeRef = useRef(adapterType);
const rowSequence = useRef(externalRows.length);
const [rows, setRows] = useState(() => withRowKeys(externalRows, "initial"));
useEffect(() => {
const adapterChanged = adapterTypeRef.current !== adapterType;
if (adapterChanged || externalSignature !== lastEmittedSignature.current) {
rowSequence.current += externalRows.length;
setRows(withRowKeys(externalRows, `sync-${rowSequence.current}`));
}
adapterTypeRef.current = adapterType;
lastEmittedSignature.current = externalSignature;
}, [adapterType, externalRows, externalSignature]);
const emitRows = (nextRows) => {
setRows(nextRows);
const nextValue = updateAdapterCatalog(value, nextRows);
lastEmittedSignature.current = catalogSignature(adapterCatalogRows(nextValue));
onChange(nextValue);
};
const addRow = () => {
rowSequence.current += 1;
setRows((current) => [
...current,
{
rowKey: `draft-${rowSequence.current}`,
providerGameId: "",
gameName: "",
gameUrl: "",
iconUrl: "",
coverUrl: "",
gameLevelUid: "",
},
]);
};
const changeRow = (rowKey, patch) => {
emitRows(rows.map((row) => (row.rowKey === rowKey ? { ...row, ...patch } : row)));
};
const removeRow = (rowKey) => {
emitRows(rows.filter((row) => row.rowKey !== rowKey));
};
return (
<section className={styles.catalog}>
<div className={styles.catalogHeader}>
<div className={styles.groupTitle}>游戏目录 · {rows.length}</div>
<Button disabled={disabled} onClick={addRow}>
<AddOutlined fontSize="small" />
添加游戏
</Button>
</div>
{rows.length > 0 ? (
<div className={styles.catalogRows}>
{rows.map((row, index) => {
const duplicate =
row.providerGameId.trim() !== "" &&
rows.some(
(candidate, candidateIndex) =>
candidateIndex !== index &&
candidate.providerGameId.trim() === row.providerGameId.trim(),
);
return (
<div className={styles.catalogRow} key={row.rowKey}>
<div className={styles.catalogMain}>
<TextField
disabled={disabled}
error={duplicate}
helperText={duplicate ? "游戏 ID 重复" : undefined}
label={definition.numericGameID ? "游戏 ID正整数" : "游戏 ID"}
required
slotProps={{
// ID JSON pattern
htmlInput: {
inputMode: definition.numericGameID ? "numeric" : undefined,
pattern: duplicate
? "a^"
: definition.numericGameID
? "[1-9][0-9]*"
: undefined,
},
}}
value={row.providerGameId}
onChange={(event) =>
changeRow(row.rowKey, { providerGameId: event.target.value })
}
/>
<TextField
disabled={disabled}
label="游戏名称"
required={definition.catalogPrimary === "name"}
value={row.gameName}
onChange={(event) => changeRow(row.rowKey, { gameName: event.target.value })}
/>
<TextField
disabled={disabled}
label="H5 URL"
required={definition.catalogPrimary !== "name"}
value={row.gameUrl}
onChange={(event) => changeRow(row.rowKey, { gameUrl: event.target.value })}
/>
<Button
aria-label={`删除第 ${index + 1} 个游戏`}
disabled={disabled}
onClick={() => removeRow(row.rowKey)}
>
<DeleteOutlineOutlined fontSize="small" />
</Button>
</div>
<details className={styles.rowDetails}>
<summary>素材与游戏参数</summary>
<div className={styles.rowExtra}>
<TextField
disabled={disabled}
label="图标 URL"
value={row.iconUrl}
onChange={(event) => changeRow(row.rowKey, { iconUrl: event.target.value })}
/>
<TextField
disabled={disabled}
label="封面 URL"
value={row.coverUrl}
onChange={(event) =>
changeRow(row.rowKey, { coverUrl: event.target.value })
}
/>
{adapterType === "yomi_v4" ? (
<TextField
disabled={disabled}
label="场次 ID"
slotProps={{ htmlInput: { min: 1, step: 1 } }}
type="number"
value={row.gameLevelUid}
onChange={(event) =>
changeRow(row.rowKey, { gameLevelUid: event.target.value })
}
/>
) : null}
</div>
</details>
</div>
);
})}
</div>
) : (
<div className={styles.empty}>当前无游戏目录</div>
)}
</section>
);
}
function withRowKeys(rows, prefix) {
return rows.map((row, index) => ({ ...row, rowKey: `${prefix}-${index}` }));
}
function catalogSignature(rows) {
return JSON.stringify(
(rows || []).map(({ providerGameId, gameName, gameUrl, iconUrl, coverUrl, gameLevelUid }) => ({
providerGameId,
gameName,
gameUrl,
iconUrl,
coverUrl,
gameLevelUid,
})),
);
}

View File

@ -0,0 +1,162 @@
.root {
display: grid;
min-width: 0;
gap: var(--space-4);
}
.group,
.catalog {
display: grid;
min-width: 0;
gap: var(--space-3);
}
.groupTitle {
color: var(--text-secondary);
font-size: 12px;
font-weight: 760;
line-height: 18px;
}
.fieldGrid {
display: grid;
min-width: 0;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
}
.fieldGrid > :global(.MuiTextField-root),
.fieldGrid > :global(.MuiFormControl-root) {
width: 100%;
}
.wideField {
grid-column: span 2;
}
.invalid {
padding: 9px 12px;
border: 1px solid color-mix(in srgb, var(--danger) 30%, transparent);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--danger) 7%, transparent);
color: var(--danger);
font-size: 12px;
font-weight: 650;
}
.catalogHeader {
display: flex;
min-height: 32px;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.catalogRows {
display: grid;
gap: var(--space-2);
}
.catalogRow {
display: grid;
min-width: 0;
gap: var(--space-2);
padding: var(--space-3);
border: 1px solid var(--border-soft);
border-radius: var(--radius-sm);
background: var(--bg-card-strong);
}
.catalogMain {
display: grid;
min-width: 0;
grid-template-columns: minmax(130px, 0.55fr) minmax(150px, 0.7fr) minmax(260px, 1.5fr) auto;
align-items: start;
gap: var(--space-2);
}
.catalogMain > :global(.MuiTextField-root) {
width: 100%;
}
.rowDetails,
.details {
min-width: 0;
overflow: hidden;
border: 1px solid var(--border-soft);
border-radius: var(--radius-sm);
background: var(--bg-card-strong);
}
.rowDetails {
border: 0;
background: transparent;
}
.rowDetails > summary,
.details > summary {
padding: 9px 11px;
color: var(--text-tertiary);
cursor: pointer;
font-size: 12px;
font-weight: 700;
user-select: none;
}
.rowDetails > summary {
padding: 2px 0;
}
.details[open] > summary {
border-bottom: 1px solid var(--border-soft);
}
.rowExtra {
display: grid;
min-width: 0;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-2);
padding-top: var(--space-2);
}
.advancedEditor,
.details > .fieldGrid {
padding: var(--space-3);
}
.empty {
display: grid;
min-height: 48px;
place-items: center;
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
color: var(--text-tertiary);
font-size: 12px;
}
@media (max-width: 900px) {
.fieldGrid,
.rowExtra {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.catalogMain {
grid-template-columns: minmax(120px, 0.6fr) minmax(150px, 0.8fr) minmax(220px, 1.4fr) auto;
}
}
@media (max-width: 720px) {
.fieldGrid,
.rowExtra,
.catalogMain {
grid-template-columns: 1fr;
}
.wideField {
grid-column: auto;
}
.catalogMain > :global(button) {
justify-self: end;
}
}

View File

@ -0,0 +1,210 @@
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { AdapterConfigFields } from "@/features/games/components/AdapterConfigFields.jsx";
import {
adapterDefinition,
adapterSecretState,
adapterTypeOptions,
migrateAdapterConfigJson,
readAdapterConfig,
} from "@/features/games/adapterConfigModel.js";
import {
AdminFormDialog,
AdminFormFieldGrid,
AdminFormSection,
AdminFormSwitchField,
} from "@/shared/ui/AdminFormDialog.jsx";
import styles from "@/features/games/components/PlatformFormDialog.module.css";
export function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) {
const definition = adapterDefinition(form.adapterType);
const configValid = Boolean(readAdapterConfig(form.adapterConfigJson));
const disabled = !page.abilities.canUpdate;
const fixedAdapterType = adapterTypeOptions.find(
([value]) => adapterDefinition(value).fixedPlatformCode === form.platformCode.trim(),
)?.[0];
const { configuredSecretUsable, required: secretRequired } = adapterSecretState(form, mode === "edit");
const changeAdapter = (adapterType) => {
const previousDefinition = adapterDefinition(form.adapterType);
const nextDefinition = adapterDefinition(adapterType);
const adapterDrafts = {
...(form.adapterDrafts || {}),
[form.adapterType]: {
adapterConfigJson: form.adapterConfigJson,
apiBaseUrl: form.apiBaseUrl,
callbackIpWhitelistText: form.callbackIpWhitelistText,
},
};
const savedDraft = adapterDrafts[adapterType];
const next = {
...form,
adapterType,
adapterDrafts,
//
callbackSecret: "",
// 稿
adapterConfigJson:
savedDraft?.adapterConfigJson ??
migrateAdapterConfigJson(form.adapterConfigJson, form.adapterType, adapterType),
apiBaseUrl: savedDraft?.apiBaseUrl ?? "",
callbackIpWhitelistText: savedDraft?.callbackIpWhitelistText ?? "",
};
if (mode === "create" && nextDefinition.fixedPlatformCode) {
const canReplaceName =
!form.platformName.trim() || form.platformName.trim() === previousDefinition.fixedPlatformName;
// ZGame/AMG platformCode
next.platformCode = nextDefinition.fixedPlatformCode;
if (canReplaceName) {
next.platformName = nextDefinition.fixedPlatformName;
}
} else if (
mode === "create" &&
previousDefinition.fixedPlatformCode &&
form.platformCode.trim() === previousDefinition.fixedPlatformCode
) {
next.platformCode = "";
if (form.platformName.trim() === previousDefinition.fixedPlatformName) {
next.platformName = "";
}
}
page.setPlatformForm(next);
};
return (
<AdminFormDialog
disabled={disabled}
loading={loading}
open={open}
size="large"
submitDisabled={disabled || loading || !configValid}
submitLabel={mode === "edit" ? "保存平台" : "创建平台"}
title={mode === "edit" ? "编辑平台" : "添加平台"}
onClose={onClose}
onSubmit={onSubmit}
>
<AdminFormSection title="平台信息">
<AdminFormFieldGrid columns="repeat(4, minmax(0, 1fr))">
<TextField
disabled={disabled || mode === "edit" || Boolean(definition.fixedPlatformCode)}
helperText={definition.fixedPlatformCode ? "协议固定 Code" : undefined}
label="平台 Code"
required
value={form.platformCode}
onChange={(event) => page.setPlatformForm({ ...form, platformCode: event.target.value })}
/>
<TextField
disabled={disabled}
label="平台名称"
required
value={form.platformName}
onChange={(event) => page.setPlatformForm({ ...form, platformName: event.target.value })}
/>
<AdminFormSwitchField
checked={form.status === "active"}
checkedLabel="启用"
disabled={disabled}
label="游戏平台启用状态"
uncheckedLabel="维护"
onChange={(checked) =>
page.setPlatformForm({
...form,
status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance",
})
}
/>
<TextField
disabled={disabled}
label="排序"
type="number"
value={form.sortOrder}
onChange={(event) => page.setPlatformForm({ ...form, sortOrder: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="接入与回调">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
disabled={disabled || (mode === "edit" && Boolean(fixedAdapterType))}
helperText={mode === "edit" && fixedAdapterType ? "协议固定适配器" : undefined}
label="适配器"
select
value={form.adapterType}
onChange={(event) => changeAdapter(event.target.value)}
>
{adapterTypeOptions.map(([value, label]) => {
const optionDefinition = adapterDefinition(value);
const fixedCodeMismatch =
mode === "edit" &&
optionDefinition.fixedPlatformCode &&
optionDefinition.fixedPlatformCode !== form.platformCode.trim();
return (
<MenuItem disabled={Boolean(fixedCodeMismatch)} key={value} value={value}>
{label}
</MenuItem>
);
})}
</TextField>
{definition.apiUrl !== false ? (
<TextField
disabled={disabled}
label={definition.apiUrlLabel || "默认 H5 / API URL"}
required={definition.apiUrlRequired}
value={form.apiBaseUrl}
onChange={(event) => page.setPlatformForm({ ...form, apiBaseUrl: event.target.value })}
/>
) : null}
<SecretField
configured={configuredSecretUsable}
disabled={disabled}
label={definition.callbackSecretLabel || "回调密钥"}
placeholder={definition.callbackSecretPlaceholder}
required={secretRequired}
value={form.callbackSecret}
onChange={(callbackSecret) => page.setPlatformForm({ ...form, callbackSecret })}
/>
{definition.ipWhitelist ? (
<TextField
className={styles.ipWhitelist}
disabled={disabled}
label="回调 IP 白名单(每行一个 IP / CIDR"
maxRows={3}
minRows={1}
multiline
value={form.callbackIpWhitelistText}
onChange={(event) =>
page.setPlatformForm({ ...form, callbackIpWhitelistText: event.target.value })
}
/>
) : null}
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title={`${definition.label} 参数`}>
<AdapterConfigFields
adapterType={form.adapterType}
disabled={disabled}
value={form.adapterConfigJson}
onChange={(adapterConfigJson) => page.setPlatformForm({ ...form, adapterConfigJson })}
/>
</AdminFormSection>
</AdminFormDialog>
);
}
function SecretField({ configured, disabled, label, placeholder, required, value, onChange }) {
const whitespaceOnly = required && Boolean(value) && !value.trim();
return (
<TextField
autoComplete="off"
disabled={disabled}
error={whitespaceOnly}
helperText={whitespaceOnly ? "密钥不能只包含空格" : configured && !value ? "已配置,留空不修改" : undefined}
label={label}
placeholder={placeholder}
required={required}
type="text"
value={value}
onChange={(event) => onChange(event.target.value)}
/>
);
}

View File

@ -0,0 +1,3 @@
.ipWhitelist {
grid-column: 1 / -1;
}

View File

@ -50,12 +50,6 @@
white-space: nowrap; white-space: nowrap;
} }
.secretMeta {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
overflow-wrap: anywhere;
white-space: normal;
}
.switchOnlyField { .switchOnlyField {
display: inline-flex; display: inline-flex;
min-height: 56px; min-height: 56px;

View File

@ -15,8 +15,13 @@ import {
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 { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
import { normalizeJsonObjectString } from "@/shared/ui/JsonEditorField.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx";
import {
adapterDefinition,
normalizeAdapterCallbackSecret,
normalizeAdapterConfigJson,
writableBridgeAdapterTypes,
} from "@/features/games/adapterConfigModel.js";
const defaultGameForm = { const defaultGameForm = {
gameId: "", gameId: "",
@ -43,12 +48,14 @@ const defaultPlatformForm = {
apiBaseUrl: "", apiBaseUrl: "",
// 新建平台必须选择真实厂商协议,避免把线上配置误落成 demo。 // 新建平台必须选择真实厂商协议,避免把线上配置误落成 demo。
adapterType: "yomi_v4", adapterType: "yomi_v4",
// 后台现在会回显厂商 key编辑弹窗直接显示当前值提交空值仍代表“不覆盖旧值”。 initialAdapterType: "yomi_v4",
// 已保存密钥只回显“已配置”,不会重新进入表单状态;提交空值仍代表“不覆盖旧值”。
callbackSecret: "", callbackSecret: "",
callbackSecretSet: false, callbackSecretSet: false,
callbackIpWhitelistText: "", callbackIpWhitelistText: "",
// 厂商扩展配置以 JSON 透传,服务端按 adapter_type 自己解析需要的字段。 // 厂商扩展配置以 JSON 透传,服务端按 adapter_type 自己解析需要的字段。
adapterConfigJson: "{}", adapterConfigJson: "{}",
adapterDrafts: {},
sortOrder: 0, sortOrder: 0,
}; };
@ -57,17 +64,6 @@ const defaultBridgeScriptForm = {
bridgeScriptVersion: "", bridgeScriptVersion: "",
}; };
const writableBridgeAdapterTypes = new Set([
"yomi_v4",
"leadercc_v1",
"zeeone_v1",
"baishun_v1",
"vivagames_v1",
"reyou_v1",
"zgame_v1",
"amg_v1",
]);
const emptyCatalog = { items: [], pageSize: 50 }; const emptyCatalog = { items: [], pageSize: 50 };
const emptyPlatforms = { items: [] }; const emptyPlatforms = { items: [] };
const defaultPageSize = 50; const defaultPageSize = 50;
@ -302,7 +298,7 @@ export function useGamesPage() {
event.preventDefault(); event.preventDefault();
let payload; let payload;
try { try {
payload = platformPayload(platformForm); payload = platformPayload(platformForm, Boolean(editingPlatformCode));
} catch (err) { } catch (err) {
showToast(err.message || "适配器配置 JSON 不正确", "error"); showToast(err.message || "适配器配置 JSON 不正确", "error");
return; return;
@ -784,11 +780,13 @@ function platformToForm(platform) {
status: platform.status || "active", status: platform.status || "active",
apiBaseUrl: platform.apiBaseUrl || "", apiBaseUrl: platform.apiBaseUrl || "",
adapterType: platform.adapterType || "yomi_v4", adapterType: platform.adapterType || "yomi_v4",
// 使用后端返回的当前密钥,方便运营直接核对;用户清空提交时后端会保留旧值。 initialAdapterType: platform.adapterType || "yomi_v4",
// 运营要求密钥明文可核对;后端有返回时直接进入编辑框,没有返回时仍保留“已配置”状态语义。
callbackSecret: platform.callbackSecret || "", callbackSecret: platform.callbackSecret || "",
callbackSecretSet: Boolean(platform.callbackSecretSet), callbackSecretSet: Boolean(platform.callbackSecretSet),
callbackIpWhitelistText: (platform.callbackIpWhitelist || []).join("\n"), callbackIpWhitelistText: (platform.callbackIpWhitelist || []).join("\n"),
adapterConfigJson: platform.adapterConfigJson || "{}", adapterConfigJson: platform.adapterConfigJson || "{}",
adapterDrafts: {},
sortOrder: Number(platform.sortOrder || 0), sortOrder: Number(platform.sortOrder || 0),
}; };
} }
@ -823,24 +821,29 @@ function normalizeLaunchMode(value) {
return "full_screen"; return "full_screen";
} }
function platformPayload(form) { function platformPayload(form, editing) {
const isLeaderCC = form.adapterType.trim() === "leadercc_v1"; const adapterType = form.adapterType.trim() || "yomi_v4";
const definition = adapterDefinition(adapterType);
const status = form.status.trim() || "active";
const callbackSecret = normalizeAdapterCallbackSecret(form, editing);
// required 属性只能拦截空字符串,纯空格提交后会被 trim 成空并触发后端“保留旧密钥”;提交层必须再次校验。
return { return {
platformCode: form.platformCode.trim(), platformCode: form.platformCode.trim(),
platformName: form.platformName.trim(), platformName: form.platformName.trim(),
status: form.status.trim() || "active", status,
apiBaseUrl: isLeaderCC ? "" : form.apiBaseUrl.trim(), apiBaseUrl: definition.apiUrl === false ? "" : form.apiBaseUrl.trim(),
adapterType: form.adapterType.trim() || "yomi_v4", adapterType,
// 空密钥提交给后端后会保留旧值;非空才覆盖,用于安全轮换 key/AppSecret。 // 空密钥提交给后端后会保留旧值;非空才覆盖,用于安全轮换 key/AppSecret。
callbackSecret: form.callbackSecret.trim(), callbackSecret,
// 支持换行或逗号分隔,方便直接粘贴厂商给的多 IP 白名单。 // 支持换行或逗号分隔,方便直接粘贴厂商给的多 IP 白名单。
callbackIpWhitelist: isLeaderCC callbackIpWhitelist: definition.ipWhitelist
? [] ? form.callbackIpWhitelistText
: form.callbackIpWhitelistText
.split(/[\n,]/) .split(/[\n,]/)
.map((item) => item.trim()) .map((item) => item.trim())
.filter(Boolean), .filter(Boolean)
adapterConfigJson: normalizeJsonObjectString(form.adapterConfigJson), : [],
// 可视字段只 patch 当前 key提交时再统一类型与历史别名未知字段、游戏目录和桥接配置原样保留。
adapterConfigJson: normalizeAdapterConfigJson(form.adapterConfigJson, adapterType),
sortOrder: Number(form.sortOrder || 0), sortOrder: Number(form.sortOrder || 0),
}; };
} }

View File

@ -14,7 +14,6 @@ import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/
import { Button } from "@/shared/ui/Button.jsx"; import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx"; import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx";
import { JsonEditorField } from "@/shared/ui/JsonEditorField.jsx";
import { import {
AdminActionIconButton, AdminActionIconButton,
AdminListBody, AdminListBody,
@ -27,6 +26,7 @@ import { UploadField } from "@/shared/ui/UploadField.jsx";
import { formatMillis } from "@/shared/utils/time.js"; import { formatMillis } from "@/shared/utils/time.js";
import { useGamesPage } from "@/features/games/hooks/useGamesPage.js"; import { useGamesPage } from "@/features/games/hooks/useGamesPage.js";
import { GameWhitelistDialog } from "@/features/games/components/GameWhitelistDialog.jsx"; import { GameWhitelistDialog } from "@/features/games/components/GameWhitelistDialog.jsx";
import { PlatformFormDialog } from "@/features/games/components/PlatformFormDialog.jsx";
import styles from "@/features/games/games.module.css"; import styles from "@/features/games/games.module.css";
const statusOptions = [ const statusOptions = [
@ -45,18 +45,6 @@ const launchModeOptions = [
["half_screen", "半屏"], ["half_screen", "半屏"],
["three_quarter_screen", "3/4屏"], ["three_quarter_screen", "3/4屏"],
]; ];
// game-service adapter_type
const adapterTypeOptions = [
["yomi_v4", "小游 Yomi V4"],
["leadercc_v1", "灵仙 LeaderCC V1"],
["zeeone_v1", "ZeeOne V1"],
["baishun_v1", "百顺 BAISHUN V1"],
["vivagames_v1", "VIVAGAMES V1"],
["reyou_v1", "热游 Reyou V1"],
["zgame_v1", "ZGame V1"],
["amg_v1", "AMG V1"],
];
const baseColumns = [ const baseColumns = [
{ {
key: "game", key: "game",
@ -487,108 +475,6 @@ function BridgeScriptDialog({ form, loading, open, page, onClose, onSubmit }) {
); );
} }
function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) {
const isLeaderCC = form.adapterType === "leadercc_v1";
return (
<AdminFormDialog
disabled={!page.abilities.canUpdate}
loading={loading}
open={open}
submitLabel={mode === "edit" ? "保存平台" : "创建平台"}
title={mode === "edit" ? "编辑平台" : "添加平台"}
onClose={onClose}
onSubmit={onSubmit}
>
<AdminFormSection title="平台信息">
<AdminFormFieldGrid>
<TextField
disabled={mode === "edit"}
label="平台 Code"
required
value={form.platformCode}
onChange={(event) => page.setPlatformForm({ ...form, platformCode: event.target.value })}
/>
<TextField
label="平台名称"
required
value={form.platformName}
onChange={(event) => page.setPlatformForm({ ...form, platformName: event.target.value })}
/>
<SwitchOnlyField
checked={form.status === "active"}
checkedLabel="启用"
disabled={!page.abilities.canUpdate}
label="游戏平台启用状态"
uncheckedLabel="维护"
onChange={(checked) =>
page.setPlatformForm({
...form,
status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance",
})
}
/>
<TextField
label="排序"
type="number"
value={form.sortOrder}
onChange={(event) => page.setPlatformForm({ ...form, sortOrder: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="接入配置">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
{/* 适配器决定服务端启动参数、签名/解密和错误码映射。 */}
<TextField
label="适配器"
select
value={form.adapterType}
onChange={(event) => page.setPlatformForm({ ...form, adapterType: event.target.value })}
>
{adapterTypeOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
{!isLeaderCC ? (
<TextField
label="H5/Auth/API URL"
required={form.adapterType === "yomi_v4" || form.adapterType === "baishun_v1"}
value={form.apiBaseUrl}
onChange={(event) => page.setPlatformForm({ ...form, apiBaseUrl: event.target.value })}
/>
) : null}
{/* 后台直接回显当前厂商 key清空保存时后端保持旧密钥输入新值才轮换。 */}
<TextField
label="回调密钥"
value={form.callbackSecret}
onChange={(event) => page.setPlatformForm({ ...form, callbackSecret: event.target.value })}
/>
{!isLeaderCC ? (
<TextField
label="IP 白名单"
minRows={3}
multiline
value={form.callbackIpWhitelistText}
onChange={(event) =>
page.setPlatformForm({ ...form, callbackIpWhitelistText: event.target.value })
}
/>
) : null}
{/* 厂商差异字段放 JSON如 uid_mode、default_lang、token_ttl_seconds、game_level_uid。 */}
<JsonEditorField
className={styles.fullField}
label="适配器配置 JSON"
minRows={5}
value={form.adapterConfigJson}
onChange={(adapterConfigJson) => page.setPlatformForm({ ...form, adapterConfigJson })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
</AdminFormDialog>
);
}
function PlatformManageDialog({ open, page, onClose }) { function PlatformManageDialog({ open, page, onClose }) {
const platforms = page.platformData.items || []; const platforms = page.platformData.items || [];
return ( return (
@ -613,9 +499,6 @@ function PlatformManageDialog({ open, page, onClose }) {
{platform.platformCode} / {platform.adapterType || "未配置"} {platform.platformCode} / {platform.adapterType || "未配置"}
</span> </span>
{platform.apiBaseUrl ? <span className={styles.meta}>{platform.apiBaseUrl}</span> : null} {platform.apiBaseUrl ? <span className={styles.meta}>{platform.apiBaseUrl}</span> : null}
<span className={`${styles.meta} ${styles.secretMeta}`}>
密钥{platform.callbackSecret || "未配置"}
</span>
</div> </div>
<div className={styles.platformActions}> <div className={styles.platformActions}>
<span className={styles.meta}> <span className={styles.meta}>

View File

@ -6,6 +6,7 @@ import styles from "@/shared/ui/JsonEditorField.module.css";
export function JsonEditorField({ export function JsonEditorField({
className = "", className = "",
disabled = false, disabled = false,
formatter,
label, label,
minRows = 6, minRows = 6,
onChange, onChange,
@ -28,7 +29,8 @@ export function JsonEditorField({
if (!result.valid) { if (!result.valid) {
return; return;
} }
onChange(JSON.stringify(result.value, null, 2)); // int64 token 沿 JSON
onChange(formatter ? formatter(value) : JSON.stringify(result.value, null, 2));
}; };
return ( return (