diff --git a/databi/src/DatabiApp.test.jsx b/databi/src/DatabiApp.test.jsx index 6373b28..969e48c 100644 --- a/databi/src/DatabiApp.test.jsx +++ b/databi/src/DatabiApp.test.jsx @@ -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(); diff --git a/databi/src/social/SocialBiApp.jsx b/databi/src/social/SocialBiApp.jsx index cd8af39..0875c1f 100644 --- a/databi/src/social/SocialBiApp.jsx +++ b/databi/src/social/SocialBiApp.jsx @@ -88,7 +88,7 @@ export function SocialBiApp() { }, []); const openRequirementSection = useCallback((requirementSection) => { - // 下钻必须同时设置明细模式和业务分组,否则 DataTableView 会按默认值落回“运营宽表 / 新用户”。 + // 下钻必须同时设置明细模式和业务分组,否则 DataTableView 会按默认值落回“运营宽表 / 充值”。 setTableState({ mode: "requirements", requirementSection }); setViewKey("table"); }, []); diff --git a/databi/src/social/state.js b/databi/src/social/state.js index b0c8b9b..3dadb04 100644 --- a/databi/src/social/state.js +++ b/databi/src/social/state.js @@ -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); } } diff --git a/databi/src/social/views/DataTableView.jsx b/databi/src/social/views/DataTableView.jsx index cf9004d..8a88dc0 100644 --- a/databi/src/social/views/DataTableView.jsx +++ b/databi/src/social/views/DataTableView.jsx @@ -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 } ]; diff --git a/databi/src/social/views/OverviewView.jsx b/databi/src/social/views/OverviewView.jsx index 8a1f644..e876a6a 100644 --- a/databi/src/social/views/OverviewView.jsx +++ b/databi/src/social/views/OverviewView.jsx @@ -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}  ${formatChartAmount(valueMetric, numeric)}`; }); const header = list[0]?.axisValueLabel || list[0]?.name || ""; - if (list.length > 1) { + if (list.length > 1 && !isRatioMetric) { lines.push(`合计  ${formatChartAmount(valueMetric, total)}`); } return [header, ...lines].filter(Boolean).join("
"); @@ -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 ; @@ -331,20 +338,18 @@ export function OverviewView() {
- {trendLabel}趋势 - 按 App 堆叠 + {trendConfig.label}趋势 + {RATIO_METRIC_KEYS.has(trendMetric) ? "按 App 对比" : "按 App 堆叠"}
- {trendMetric === "game_turnover" ? ( - // 游戏流水先留在概览中展示趋势,只有用户明确点击明细入口时才切换到数据需求视图。 - - ) : null} + {/* 趋势 Tab 与数据需求分组一一映射,按钮始终跟随当前指标进入对应明细。 */} +
{TREND_METRICS.map((item) => ( +
+ {rows.length > 0 ? ( +
+ {rows.map((row, index) => { + const duplicate = + row.providerGameId.trim() !== "" && + rows.some( + (candidate, candidateIndex) => + candidateIndex !== index && + candidate.providerGameId.trim() === row.providerGameId.trim(), + ); + return ( +
+
+ + changeRow(row.rowKey, { providerGameId: event.target.value }) + } + /> + changeRow(row.rowKey, { gameName: event.target.value })} + /> + changeRow(row.rowKey, { gameUrl: event.target.value })} + /> + +
+
+ 素材与游戏参数 +
+ changeRow(row.rowKey, { iconUrl: event.target.value })} + /> + + changeRow(row.rowKey, { coverUrl: event.target.value }) + } + /> + {adapterType === "yomi_v4" ? ( + + changeRow(row.rowKey, { gameLevelUid: event.target.value }) + } + /> + ) : null} +
+
+
+ ); + })} +
+ ) : ( +
当前无游戏目录
+ )} +
+ ); +} + +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, + })), + ); +} diff --git a/src/features/games/components/AdapterConfigFields.module.css b/src/features/games/components/AdapterConfigFields.module.css new file mode 100644 index 0000000..5723611 --- /dev/null +++ b/src/features/games/components/AdapterConfigFields.module.css @@ -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; + } +} diff --git a/src/features/games/components/PlatformFormDialog.jsx b/src/features/games/components/PlatformFormDialog.jsx new file mode 100644 index 0000000..f7f42a9 --- /dev/null +++ b/src/features/games/components/PlatformFormDialog.jsx @@ -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 ( + + + + page.setPlatformForm({ ...form, platformCode: event.target.value })} + /> + page.setPlatformForm({ ...form, platformName: event.target.value })} + /> + + page.setPlatformForm({ + ...form, + status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance", + }) + } + /> + page.setPlatformForm({ ...form, sortOrder: event.target.value })} + /> + + + + + changeAdapter(event.target.value)} + > + {adapterTypeOptions.map(([value, label]) => { + const optionDefinition = adapterDefinition(value); + const fixedCodeMismatch = + mode === "edit" && + optionDefinition.fixedPlatformCode && + optionDefinition.fixedPlatformCode !== form.platformCode.trim(); + return ( + + {label} + + ); + })} + + {definition.apiUrl !== false ? ( + page.setPlatformForm({ ...form, apiBaseUrl: event.target.value })} + /> + ) : null} + page.setPlatformForm({ ...form, callbackSecret })} + /> + {definition.ipWhitelist ? ( + + page.setPlatformForm({ ...form, callbackIpWhitelistText: event.target.value }) + } + /> + ) : null} + + + + page.setPlatformForm({ ...form, adapterConfigJson })} + /> + + + ); +} + +function SecretField({ configured, disabled, label, placeholder, required, value, onChange }) { + const whitespaceOnly = required && Boolean(value) && !value.trim(); + return ( + onChange(event.target.value)} + /> + ); +} diff --git a/src/features/games/components/PlatformFormDialog.module.css b/src/features/games/components/PlatformFormDialog.module.css new file mode 100644 index 0000000..a04bfab --- /dev/null +++ b/src/features/games/components/PlatformFormDialog.module.css @@ -0,0 +1,3 @@ +.ipWhitelist { + grid-column: 1 / -1; +} diff --git a/src/features/games/games.module.css b/src/features/games/games.module.css index ebeb116..ff2b739 100644 --- a/src/features/games/games.module.css +++ b/src/features/games/games.module.css @@ -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; diff --git a/src/features/games/hooks/useGamesPage.js b/src/features/games/hooks/useGamesPage.js index a4419aa..728216f 100644 --- a/src/features/games/hooks/useGamesPage.js +++ b/src/features/games/hooks/useGamesPage.js @@ -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), }; } diff --git a/src/features/games/pages/GameListPage.jsx b/src/features/games/pages/GameListPage.jsx index 68699e1..a6328a6 100644 --- a/src/features/games/pages/GameListPage.jsx +++ b/src/features/games/pages/GameListPage.jsx @@ -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 ( - - - - page.setPlatformForm({ ...form, platformCode: event.target.value })} - /> - page.setPlatformForm({ ...form, platformName: event.target.value })} - /> - - page.setPlatformForm({ - ...form, - status: checked ? "active" : form.status === "disabled" ? "disabled" : "maintenance", - }) - } - /> - page.setPlatformForm({ ...form, sortOrder: event.target.value })} - /> - - - - - {/* 适配器决定服务端启动参数、签名/解密和错误码映射。 */} - page.setPlatformForm({ ...form, adapterType: event.target.value })} - > - {adapterTypeOptions.map(([value, label]) => ( - - {label} - - ))} - - {!isLeaderCC ? ( - page.setPlatformForm({ ...form, apiBaseUrl: event.target.value })} - /> - ) : null} - {/* 后台直接回显当前厂商 key;清空保存时后端保持旧密钥,输入新值才轮换。 */} - page.setPlatformForm({ ...form, callbackSecret: event.target.value })} - /> - {!isLeaderCC ? ( - - page.setPlatformForm({ ...form, callbackIpWhitelistText: event.target.value }) - } - /> - ) : null} - {/* 厂商差异字段放 JSON:如 uid_mode、default_lang、token_ttl_seconds、game_level_uid。 */} - page.setPlatformForm({ ...form, adapterConfigJson })} - /> - - - - ); -} - function PlatformManageDialog({ open, page, onClose }) { const platforms = page.platformData.items || []; return ( @@ -613,9 +499,6 @@ function PlatformManageDialog({ open, page, onClose }) { {platform.platformCode} / {platform.adapterType || "未配置"} {platform.apiBaseUrl ? {platform.apiBaseUrl} : null} - - 密钥:{platform.callbackSecret || "未配置"} -
diff --git a/src/shared/ui/JsonEditorField.jsx b/src/shared/ui/JsonEditorField.jsx index eeb7d65..1396cf5 100644 --- a/src/shared/ui/JsonEditorField.jsx +++ b/src/shared/ui/JsonEditorField.jsx @@ -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 (