游戏配置
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({
|
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();
|
||||||
|
|||||||
@ -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");
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -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} <b>${formatChartAmount(valueMetric, numeric)}</b>`;
|
return `${item.marker}${item.seriesName} <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(`合计 <b>${formatChartAmount(valueMetric, total)}</b>`);
|
lines.push(`合计 <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
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|||||||
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;
|
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;
|
||||||
|
|||||||
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;
|
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;
|
||||||
|
|||||||
@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user