游戏配置
This commit is contained in:
parent
e04aa7ed2f
commit
00b6af42dd
@ -543,10 +543,31 @@ test("renders social BI data requirements table with section filters and CSV exp
|
||||
expect(fetchSocialBiRequirements).toHaveBeenCalledWith(expect.objectContaining({
|
||||
payerType: "all",
|
||||
regionIds: [9, 11],
|
||||
section: "new_users",
|
||||
section: "revenue",
|
||||
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("注册成功率")).toBeTruthy();
|
||||
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();
|
||||
|
||||
await act(async () => {
|
||||
screen.getByRole("tab", { name: "营收" }).click();
|
||||
screen.getByRole("tab", { name: "充值" }).click();
|
||||
});
|
||||
await flushEffects();
|
||||
expect(screen.getByRole("radiogroup", { name: "用户身份" })).toBeTruthy();
|
||||
|
||||
@ -88,7 +88,7 @@ export function SocialBiApp() {
|
||||
}, []);
|
||||
|
||||
const openRequirementSection = useCallback((requirementSection) => {
|
||||
// 下钻必须同时设置明细模式和业务分组,否则 DataTableView 会按默认值落回“运营宽表 / 新用户”。
|
||||
// 下钻必须同时设置明细模式和业务分组,否则 DataTableView 会按默认值落回“运营宽表 / 充值”。
|
||||
setTableState({ mode: "requirements", requirementSection });
|
||||
setViewKey("table");
|
||||
}, []);
|
||||
|
||||
@ -6,12 +6,12 @@ import { lastDaysRange, thisMonthRange, todayRange } from "../utils/time.js";
|
||||
export const ALL = "all";
|
||||
|
||||
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() {
|
||||
return {
|
||||
mode: "wide",
|
||||
requirementSection: "new_users"
|
||||
requirementSection: "revenue"
|
||||
};
|
||||
}
|
||||
|
||||
@ -200,9 +200,9 @@ export function writeStateToURL(filters, view, table, history = window.history,
|
||||
params.set("view", view);
|
||||
}
|
||||
if (view === "table" && table?.mode === "requirements") {
|
||||
// 目标分组与数据需求模式一起入 URL,保证概览下钻和复制后的链接都能落到同一个游戏数据视图。
|
||||
// 非默认分组与数据需求模式一起入 URL,保证概览下钻和复制后的链接都能落到同一个业务明细视图。
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,17 +47,6 @@ const TABLE_MODES = [
|
||||
];
|
||||
|
||||
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",
|
||||
fallbackColumns: [
|
||||
@ -68,25 +57,21 @@ const REQUIREMENT_SECTIONS = [
|
||||
{ key: "arppu_usd_minor", label: "ARPPU", type: "money_minor" }
|
||||
],
|
||||
key: "revenue",
|
||||
label: "营收",
|
||||
label: "充值",
|
||||
newUserFilter: true,
|
||||
payerFilter: true,
|
||||
roleFilter: true
|
||||
},
|
||||
{
|
||||
csvName: "p2-retention",
|
||||
csvName: "p0-new-users",
|
||||
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: "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: "retention",
|
||||
label: "留存",
|
||||
newUserFilter: true,
|
||||
payerFilter: true,
|
||||
roleFilter: true
|
||||
key: "new_users",
|
||||
label: "新增"
|
||||
},
|
||||
{
|
||||
csvName: "p2-active",
|
||||
@ -98,7 +83,7 @@ const REQUIREMENT_SECTIONS = [
|
||||
{ key: "avg_session_ms", label: "人均在线", type: "duration_ms" }
|
||||
],
|
||||
key: "active",
|
||||
label: "活跃",
|
||||
label: "日活",
|
||||
newUserFilter: true,
|
||||
payerFilter: true,
|
||||
roleFilter: true
|
||||
@ -112,7 +97,7 @@ const REQUIREMENT_SECTIONS = [
|
||||
{ key: "gift_paid_users", label: "付费送礼用户", type: "count" }
|
||||
],
|
||||
key: "gift",
|
||||
label: "送礼",
|
||||
label: "礼物",
|
||||
newUserFilter: true,
|
||||
roleFilter: true
|
||||
},
|
||||
@ -129,6 +114,21 @@ const REQUIREMENT_SECTIONS = [
|
||||
label: "游戏",
|
||||
newUserFilter: 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
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import Button from "@mui/material/Button";
|
||||
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 { SocialAppIdentity, useSocialBi } from "../SocialBiApp.jsx";
|
||||
import { bucketDay } from "../state.js";
|
||||
@ -20,11 +20,12 @@ const HERO_METRICS = [
|
||||
];
|
||||
|
||||
const TREND_METRICS = [
|
||||
{ key: "recharge_usd_minor", label: "充值" },
|
||||
{ key: "new_users", label: "新增" },
|
||||
{ key: "active_users", label: "日活" },
|
||||
{ key: "gift_coin_spent", label: "礼物流水" },
|
||||
{ key: "game_turnover", label: "游戏流水" }
|
||||
{ key: "recharge_usd_minor", label: "充值", requirementSection: "revenue" },
|
||||
{ key: "new_users", label: "新增", requirementSection: "new_users" },
|
||||
{ key: "active_users", label: "日活", requirementSection: "active" },
|
||||
{ key: "gift_coin_spent", label: "礼物", requirementSection: "gift" },
|
||||
{ key: "game_turnover", label: "游戏", requirementSection: "game" },
|
||||
{ key: "d1_retention_rate", label: "留存", requirementSection: "retention" }
|
||||
];
|
||||
|
||||
const COMPARE_METRICS = [
|
||||
@ -42,12 +43,16 @@ const MONEY_METRIC_KEYS = new Set([
|
||||
"mifapay_recharge_usd_minor",
|
||||
"coin_seller_recharge_usd_minor"
|
||||
]);
|
||||
const RATIO_METRIC_KEYS = new Set(["d1_retention_rate"]);
|
||||
|
||||
// 图表值已由 metricChartValue 换算(金额→美元),tooltip 里按指标类型补格式。
|
||||
function formatChartAmount(metricKey, value) {
|
||||
if (isBlank(value)) {
|
||||
return "--";
|
||||
}
|
||||
if (RATIO_METRIC_KEYS.has(metricKey)) {
|
||||
return formatPercentValue(value);
|
||||
}
|
||||
return MONEY_METRIC_KEYS.has(metricKey) ? formatMoney(value) : formatCount(value);
|
||||
}
|
||||
|
||||
@ -65,6 +70,7 @@ function buildBuckets(rows, granularity) {
|
||||
}
|
||||
|
||||
function buildStackedBarOption({ bucketKeys, series, valueMetric }) {
|
||||
const isRatioMetric = RATIO_METRIC_KEYS.has(valueMetric);
|
||||
return {
|
||||
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 },
|
||||
@ -84,7 +90,7 @@ function buildStackedBarOption({ bucketKeys, series, valueMetric }) {
|
||||
return `${item.marker}${item.seriesName} <b>${formatChartAmount(valueMetric, numeric)}</b>`;
|
||||
});
|
||||
const header = list[0]?.axisValueLabel || list[0]?.name || "";
|
||||
if (list.length > 1) {
|
||||
if (list.length > 1 && !isRatioMetric) {
|
||||
lines.push(`合计 <b>${formatChartAmount(valueMetric, total)}</b>`);
|
||||
}
|
||||
return [header, ...lines].filter(Boolean).join("<br/>");
|
||||
@ -100,7 +106,7 @@ function buildStackedBarOption({ bucketKeys, series, valueMetric }) {
|
||||
type: "category"
|
||||
},
|
||||
yAxis: {
|
||||
axisLabel: { color: "#5b7089", fontSize: 11 },
|
||||
axisLabel: { color: "#5b7089", fontSize: 11, ...(isRatioMetric ? { formatter: "{value}%" } : {}) },
|
||||
splitLine: { lineStyle: { color: "#eef3f9" } },
|
||||
type: "value"
|
||||
}
|
||||
@ -230,12 +236,13 @@ export function OverviewView() {
|
||||
}, [appCodes, buckets]);
|
||||
|
||||
const trendOption = useMemo(() => {
|
||||
const shouldStack = !RATIO_METRIC_KEYS.has(trendMetric);
|
||||
const series = appBuckets.apps.map((app) => ({
|
||||
barMaxWidth: 26,
|
||||
data: app.cells.map((cell) => (cell ? metricChartValue(trendMetric, cell[trendMetric]) : null)),
|
||||
itemStyle: { color: appColor(app.app_code, appCodes) },
|
||||
name: app.app_name,
|
||||
stack: "total",
|
||||
...(shouldStack ? { stack: "total" } : {}),
|
||||
type: "bar"
|
||||
}));
|
||||
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 hasRestricted = derived.appTotals.some((row) => row.restricted) || derived.appErrors.some((row) => row.restricted);
|
||||
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) {
|
||||
return <OverviewSkeleton />;
|
||||
@ -331,20 +338,18 @@ export function OverviewView() {
|
||||
|
||||
<section className="sbi-card">
|
||||
<header className="sbi-card-header">
|
||||
<strong>{trendLabel}趋势</strong>
|
||||
<small>按 App 堆叠</small>
|
||||
<strong>{trendConfig.label}趋势</strong>
|
||||
<small>{RATIO_METRIC_KEYS.has(trendMetric) ? "按 App 对比" : "按 App 堆叠"}</small>
|
||||
<div className="sbi-card-toolbar">
|
||||
{trendMetric === "game_turnover" ? (
|
||||
// 游戏流水先留在概览中展示趋势,只有用户明确点击明细入口时才切换到数据需求视图。
|
||||
<Button
|
||||
onClick={() => openRequirementSection("game")}
|
||||
size="small"
|
||||
title="进入数据需求中的游戏数据明细"
|
||||
variant="outlined"
|
||||
>
|
||||
查看明细
|
||||
</Button>
|
||||
) : null}
|
||||
{/* 趋势 Tab 与数据需求分组一一映射,按钮始终跟随当前指标进入对应明细。 */}
|
||||
<Button
|
||||
onClick={() => openRequirementSection(trendConfig.requirementSection)}
|
||||
size="small"
|
||||
title={`进入数据需求中的${trendConfig.label}明细`}
|
||||
variant="outlined"
|
||||
>
|
||||
查看明细
|
||||
</Button>
|
||||
<div className="sbi-seg" role="radiogroup" aria-label="趋势指标">
|
||||
{TREND_METRICS.map((item) => (
|
||||
<button
|
||||
|
||||
@ -29,7 +29,7 @@ export default function LoginPage() {
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
// 登录成功前没有可信 App 上下文;使用通用标题并清掉上一个会话的图标,避免泄露或误导用户所属 App。
|
||||
// 登录前没有可信 App 上下文;使用通用标题并清掉上次认证遗留的图标,避免误导用户所属 App。
|
||||
globalThis.document.title = t("app.title");
|
||||
globalThis.document.head.querySelector('link[data-external-app-favicon="true"]')?.remove();
|
||||
}, [t]);
|
||||
|
||||
670
src/features/games/adapterConfigModel.js
Normal file
670
src/features/games/adapterConfigModel.js
Normal 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", "10(1 位小数)"],
|
||||
["100", "100(2 位小数)"],
|
||||
["1000", "1,000(3 位小数)"],
|
||||
["10000", "10,000(4 位小数)"],
|
||||
["100000", "100,000(5 位小数)"],
|
||||
["1000000", "1,000,000(6 位小数)"],
|
||||
],
|
||||
},
|
||||
{ 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(",")}}`;
|
||||
}
|
||||
@ -14,7 +14,7 @@ export interface GamePlatformDto {
|
||||
callbackSecret?: string;
|
||||
callbackSecretSet: boolean;
|
||||
callbackIpWhitelist: string[];
|
||||
// 厂商扩展配置保留 JSON 字符串,后台前端不绑定具体字段结构。
|
||||
// JSON 是服务端传输格式;配置页会按 adapterType 显示结构化字段,并保留未来新增的未知 key。
|
||||
adapterConfigJson: string;
|
||||
sortOrder: number;
|
||||
createdAtMs?: number;
|
||||
|
||||
320
src/features/games/components/AdapterConfigFields.jsx
Normal file
320
src/features/games/components/AdapterConfigFields.jsx
Normal 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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
162
src/features/games/components/AdapterConfigFields.module.css
Normal file
162
src/features/games/components/AdapterConfigFields.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
210
src/features/games/components/PlatformFormDialog.jsx
Normal file
210
src/features/games/components/PlatformFormDialog.jsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
.ipWhitelist {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@ -50,12 +50,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.secretMeta {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.switchOnlyField {
|
||||
display: inline-flex;
|
||||
min-height: 56px;
|
||||
|
||||
@ -15,8 +15,13 @@ import {
|
||||
import { useGameAbilities } from "@/features/games/permissions.js";
|
||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
|
||||
import { normalizeJsonObjectString } from "@/shared/ui/JsonEditorField.jsx";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import {
|
||||
adapterDefinition,
|
||||
normalizeAdapterCallbackSecret,
|
||||
normalizeAdapterConfigJson,
|
||||
writableBridgeAdapterTypes,
|
||||
} from "@/features/games/adapterConfigModel.js";
|
||||
|
||||
const defaultGameForm = {
|
||||
gameId: "",
|
||||
@ -43,12 +48,14 @@ const defaultPlatformForm = {
|
||||
apiBaseUrl: "",
|
||||
// 新建平台必须选择真实厂商协议,避免把线上配置误落成 demo。
|
||||
adapterType: "yomi_v4",
|
||||
// 后台现在会回显厂商 key,编辑弹窗直接显示当前值;提交空值仍代表“不覆盖旧值”。
|
||||
initialAdapterType: "yomi_v4",
|
||||
// 已保存密钥只回显“已配置”,不会重新进入表单状态;提交空值仍代表“不覆盖旧值”。
|
||||
callbackSecret: "",
|
||||
callbackSecretSet: false,
|
||||
callbackIpWhitelistText: "",
|
||||
// 厂商扩展配置以 JSON 透传,服务端按 adapter_type 自己解析需要的字段。
|
||||
adapterConfigJson: "{}",
|
||||
adapterDrafts: {},
|
||||
sortOrder: 0,
|
||||
};
|
||||
|
||||
@ -57,17 +64,6 @@ const defaultBridgeScriptForm = {
|
||||
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 emptyPlatforms = { items: [] };
|
||||
const defaultPageSize = 50;
|
||||
@ -302,7 +298,7 @@ export function useGamesPage() {
|
||||
event.preventDefault();
|
||||
let payload;
|
||||
try {
|
||||
payload = platformPayload(platformForm);
|
||||
payload = platformPayload(platformForm, Boolean(editingPlatformCode));
|
||||
} catch (err) {
|
||||
showToast(err.message || "适配器配置 JSON 不正确", "error");
|
||||
return;
|
||||
@ -784,11 +780,13 @@ function platformToForm(platform) {
|
||||
status: platform.status || "active",
|
||||
apiBaseUrl: platform.apiBaseUrl || "",
|
||||
adapterType: platform.adapterType || "yomi_v4",
|
||||
// 使用后端返回的当前密钥,方便运营直接核对;用户清空提交时后端会保留旧值。
|
||||
initialAdapterType: platform.adapterType || "yomi_v4",
|
||||
// 运营要求密钥明文可核对;后端有返回时直接进入编辑框,没有返回时仍保留“已配置”状态语义。
|
||||
callbackSecret: platform.callbackSecret || "",
|
||||
callbackSecretSet: Boolean(platform.callbackSecretSet),
|
||||
callbackIpWhitelistText: (platform.callbackIpWhitelist || []).join("\n"),
|
||||
adapterConfigJson: platform.adapterConfigJson || "{}",
|
||||
adapterDrafts: {},
|
||||
sortOrder: Number(platform.sortOrder || 0),
|
||||
};
|
||||
}
|
||||
@ -823,24 +821,29 @@ function normalizeLaunchMode(value) {
|
||||
return "full_screen";
|
||||
}
|
||||
|
||||
function platformPayload(form) {
|
||||
const isLeaderCC = form.adapterType.trim() === "leadercc_v1";
|
||||
function platformPayload(form, editing) {
|
||||
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 {
|
||||
platformCode: form.platformCode.trim(),
|
||||
platformName: form.platformName.trim(),
|
||||
status: form.status.trim() || "active",
|
||||
apiBaseUrl: isLeaderCC ? "" : form.apiBaseUrl.trim(),
|
||||
adapterType: form.adapterType.trim() || "yomi_v4",
|
||||
status,
|
||||
apiBaseUrl: definition.apiUrl === false ? "" : form.apiBaseUrl.trim(),
|
||||
adapterType,
|
||||
// 空密钥提交给后端后会保留旧值;非空才覆盖,用于安全轮换 key/AppSecret。
|
||||
callbackSecret: form.callbackSecret.trim(),
|
||||
callbackSecret,
|
||||
// 支持换行或逗号分隔,方便直接粘贴厂商给的多 IP 白名单。
|
||||
callbackIpWhitelist: isLeaderCC
|
||||
? []
|
||||
: form.callbackIpWhitelistText
|
||||
callbackIpWhitelist: definition.ipWhitelist
|
||||
? form.callbackIpWhitelistText
|
||||
.split(/[\n,]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
adapterConfigJson: normalizeJsonObjectString(form.adapterConfigJson),
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
// 可视字段只 patch 当前 key;提交时再统一类型与历史别名,未知字段、游戏目录和桥接配置原样保留。
|
||||
adapterConfigJson: normalizeAdapterConfigJson(form.adapterConfigJson, adapterType),
|
||||
sortOrder: Number(form.sortOrder || 0),
|
||||
};
|
||||
}
|
||||
|
||||
@ -14,7 +14,6 @@ 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,
|
||||
@ -27,6 +26,7 @@ import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { useGamesPage } from "@/features/games/hooks/useGamesPage.js";
|
||||
import { GameWhitelistDialog } from "@/features/games/components/GameWhitelistDialog.jsx";
|
||||
import { PlatformFormDialog } from "@/features/games/components/PlatformFormDialog.jsx";
|
||||
import styles from "@/features/games/games.module.css";
|
||||
|
||||
const statusOptions = [
|
||||
@ -45,18 +45,6 @@ const launchModeOptions = [
|
||||
["half_screen", "半屏"],
|
||||
["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 = [
|
||||
{
|
||||
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 }) {
|
||||
const platforms = page.platformData.items || [];
|
||||
return (
|
||||
@ -613,9 +499,6 @@ function PlatformManageDialog({ open, page, onClose }) {
|
||||
{platform.platformCode} / {platform.adapterType || "未配置"}
|
||||
</span>
|
||||
{platform.apiBaseUrl ? <span className={styles.meta}>{platform.apiBaseUrl}</span> : null}
|
||||
<span className={`${styles.meta} ${styles.secretMeta}`}>
|
||||
密钥:{platform.callbackSecret || "未配置"}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.platformActions}>
|
||||
<span className={styles.meta}>
|
||||
|
||||
@ -6,6 +6,7 @@ import styles from "@/shared/ui/JsonEditorField.module.css";
|
||||
export function JsonEditorField({
|
||||
className = "",
|
||||
disabled = false,
|
||||
formatter,
|
||||
label,
|
||||
minRows = 6,
|
||||
onChange,
|
||||
@ -28,7 +29,8 @@ export function JsonEditorField({
|
||||
if (!result.valid) {
|
||||
return;
|
||||
}
|
||||
onChange(JSON.stringify(result.value, null, 2));
|
||||
// 厂商适配器可提供保留 int64 token 的格式化器;默认路径仍沿用普通 JSON 格式化。
|
||||
onChange(formatter ? formatter(value) : JSON.stringify(result.value, null, 2));
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user