From 300d53a0cda0ebc68ac7d309e9ff5dea7edaa47c Mon Sep 17 00:00:00 2001 From: zhx Date: Fri, 12 Jun 2026 03:14:57 +0800 Subject: [PATCH] databi --- .env.example | 2 - .env.production | 2 +- Dockerfile | 2 +- databi/src/DatabiApp.jsx | 126 +++++++-- databi/src/DatabiApp.test.jsx | 25 +- databi/src/api.js | 21 ++ databi/src/components/DatabiHeader.jsx | 10 + databi/src/components/SelfGameScreen.jsx | 313 ++++++++++++++++++++++ databi/src/styles/layout.css | 87 ++++++ databi/src/styles/tables.css | 60 +++++ src/features/games/api.ts | 2 + src/features/games/pages/SelfGamePage.jsx | 47 +++- 12 files changed, 658 insertions(+), 39 deletions(-) create mode 100644 databi/src/components/SelfGameScreen.jsx diff --git a/.env.example b/.env.example index 98f0519..14ea4ad 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1 @@ VITE_API_BASE_URL=/api -# Production GAAP API endpoint: -# VITE_API_BASE_URL=https://api-acc.global-interaction.com/api diff --git a/.env.production b/.env.production index e222210..14ea4ad 100644 --- a/.env.production +++ b/.env.production @@ -1 +1 @@ -VITE_API_BASE_URL=https://api-acc.global-interaction.com/api +VITE_API_BASE_URL=/api diff --git a/Dockerfile b/Dockerfile index 997a8cf..74a86df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ RUN pnpm install --frozen-lockfile COPY . . -ARG VITE_API_BASE_URL=https://api-acc.global-interaction.com/api +ARG VITE_API_BASE_URL=/api ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} RUN pnpm build diff --git a/databi/src/DatabiApp.jsx b/databi/src/DatabiApp.jsx index a0df9ce..37495f7 100644 --- a/databi/src/DatabiApp.jsx +++ b/databi/src/DatabiApp.jsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { fetchFilterOptions, fetchStatisticsOverview, getCurrentAppCode, setCurrentAppCode } from "./api.js"; +import { fetchFilterOptions, fetchSelfGameStatisticsOverview, fetchStatisticsOverview, getCurrentAppCode, setCurrentAppCode } from "./api.js"; import { CountryPerformanceTable } from "./components/CountryPerformanceTable.jsx"; import { DatabiHeader } from "./components/DatabiHeader.jsx"; import { FunnelPanel } from "./components/FunnelPanel.jsx"; @@ -8,6 +8,7 @@ import { LuckyGiftPoolModal } from "./components/LuckyGiftPoolModal.jsx"; import { MetricCard } from "./components/MetricCard.jsx"; import { MetricTrendModal } from "./components/MetricTrendModal.jsx"; import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx"; +import { SelfGameScreen } from "./components/SelfGameScreen.jsx"; import { createDashboardModel } from "./data/createDashboardModel.js"; import { lastDaysRange, rangeEndMs, rangeStartMs, readStoredTimeZone, sameRange, thisMonthRange, TIME_ZONE_OPTIONS, todayRange, writeStoredTimeZone } from "./utils/time.js"; @@ -25,15 +26,22 @@ export function DatabiApp() { const [countryId, setCountryId] = useState(0); const [regionId, setRegionId] = useState("all"); const [appCode, setAppCode] = useState(() => getCurrentAppCode()); + const [activeScreen, setActiveScreen] = useState("overview"); + const [gameId, setGameId] = useState("all"); const [filterOptions, setFilterOptions] = useState(initialFilterOptions); const [filtersLoading, setFiltersLoading] = useState(false); const [overview, setOverview] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); + const [selfGameOverview, setSelfGameOverview] = useState(null); + const [selfGameLoading, setSelfGameLoading] = useState(false); + const [selfGameError, setSelfGameError] = useState(""); const [poolModalOpen, setPoolModalOpen] = useState(false); const [trendModalItem, setTrendModalItem] = useState(null); const overviewRequestIdRef = useRef(0); + const selfGameRequestIdRef = useRef(0); const overviewRef = useRef(null); + const selfGameOverviewRef = useRef(null); const scopedCountries = useMemo(() => { return filterOptions.countryOptions.filter((country) => Number(country.id) > 0 && countryBelongsToRegion(country, regionId)); }, [filterOptions.countryOptions, regionId]); @@ -78,6 +86,10 @@ export function DatabiApp() { overviewRef.current = overview; }, [overview]); + useEffect(() => { + selfGameOverviewRef.current = selfGameOverview; + }, [selfGameOverview]); + const loadOverview = useCallback(async ({ preserveData = false } = {}) => { const requestId = overviewRequestIdRef.current + 1; overviewRequestIdRef.current = requestId; @@ -118,19 +130,63 @@ export function DatabiApp() { } }, [appCode, countryId, range, regionId, scopedCountries, timeZone]); + const loadSelfGameOverview = useCallback(async ({ preserveData = false } = {}) => { + const requestId = selfGameRequestIdRef.current + 1; + selfGameRequestIdRef.current = requestId; + const keepCurrentOverview = preserveData && selfGameOverviewRef.current; + setSelfGameLoading(true); + setSelfGameError(""); + if (!keepCurrentOverview) { + setSelfGameOverview(null); + } + try { + const data = await fetchSelfGameStatisticsOverview({ + appCode, + countryId, + endMs: rangeEndMs(range, timeZone), + gameId, + regionId, + startMs: rangeStartMs(range, timeZone) + }); + if (requestId === selfGameRequestIdRef.current) { + setSelfGameOverview(data || {}); + } + } catch (err) { + if (requestId === selfGameRequestIdRef.current) { + if (!keepCurrentOverview) { + setSelfGameOverview(null); + } + setSelfGameError(err.message || "请求失败"); + } + } finally { + if (requestId === selfGameRequestIdRef.current) { + setSelfGameLoading(false); + } + } + }, [appCode, countryId, gameId, range, regionId, timeZone]); + useEffect(() => { void loadFilterOptions(); }, [loadFilterOptions]); useEffect(() => { + if (activeScreen === "selfGames") { + void loadSelfGameOverview(); + const timer = window.setInterval(() => void loadSelfGameOverview({ preserveData: true }), 60_000); + return () => window.clearInterval(timer); + } void loadOverview(); const timer = window.setInterval(() => void loadOverview({ preserveData: true }), 60_000); return () => window.clearInterval(timer); - }, [loadOverview]); + }, [activeScreen, loadOverview, loadSelfGameOverview]); const handleRefresh = useCallback(() => { + if (activeScreen === "selfGames") { + void loadSelfGameOverview({ preserveData: true }); + return; + } void loadOverview({ preserveData: true }); - }, [loadOverview]); + }, [activeScreen, loadOverview, loadSelfGameOverview]); const model = useMemo(() => createDashboardModel(overview, { appCode, countryId, range, timeZone }), [appCode, countryId, overview, range, timeZone]); const showSkeleton = loading && !overview; @@ -140,20 +196,22 @@ export function DatabiApp() { return (
-
+
-
- {model.kpis.map((item) => ( - setTrendModalItem(item)} /> - ))} -
+ {activeScreen === "selfGames" ? ( + + ) : ( + <> +
+ {model.kpis.map((item) => ( + setTrendModalItem(item)} /> + ))} +
-
- {model.businessKpis.map((item) => ( - - ))} -
+
+ {model.businessKpis.map((item) => ( + + ))} +
-
- - - -
+
+ + + +
+ + )}
- - {poolModalOpen ? setPoolModalOpen(false)} /> : null} - {trendModalItem ? setTrendModalItem(null)} /> : null} + {activeScreen === "overview" ? ( + <> + + {poolModalOpen ? setPoolModalOpen(false)} /> : null} + {trendModalItem ? setTrendModalItem(null)} /> : null} + + ) : null}
); } diff --git a/databi/src/DatabiApp.test.jsx b/databi/src/DatabiApp.test.jsx index 0854504..40e4fe0 100644 --- a/databi/src/DatabiApp.test.jsx +++ b/databi/src/DatabiApp.test.jsx @@ -1,10 +1,11 @@ import { act, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { DatabiApp } from "./DatabiApp.jsx"; -import { fetchFilterOptions, fetchStatisticsOverview } from "./api.js"; +import { fetchFilterOptions, fetchSelfGameStatisticsOverview, fetchStatisticsOverview } from "./api.js"; vi.mock("./api.js", () => ({ fetchFilterOptions: vi.fn(), + fetchSelfGameStatisticsOverview: vi.fn(), fetchStatisticsOverview: vi.fn(), getCurrentAppCode: vi.fn(() => "lalu"), setCurrentAppCode: vi.fn((value) => String(value || "lalu").toLowerCase()) @@ -22,6 +23,7 @@ beforeEach(() => { countryOptions: [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }], regionOptions: [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }] }); + fetchSelfGameStatisticsOverview.mockResolvedValue({}); }); afterEach(() => { @@ -51,6 +53,27 @@ test("keeps current data visible during scheduled refresh", async () => { expect(document.querySelector(".metric-card.is-loading")).toBeNull(); }); +test("loads self game statistics from the big screen switch", async () => { + fetchStatisticsOverview.mockResolvedValue({ active_users: 10, paid_users: 2, recharge_usd_minor: 100, updated_at_ms: 1 }); + fetchSelfGameStatisticsOverview.mockResolvedValue({ + events: [{ event_name: "page_open", event_count: 8, user_count: 5 }], + match_totals: { created_matches: 3, matched_matches: 2, settled_matches: 1 }, + retention: { cohort_users: 5, day1_rate: 0.2, day7_rate: 0.1 } + }); + + render(); + + await flushEffects(); + await act(async () => { + screen.getByRole("button", { name: "自研游戏" }).click(); + }); + await flushEffects(); + + expect(fetchSelfGameStatisticsOverview).toHaveBeenCalledWith(expect.objectContaining({ appCode: "lalu", gameId: "all" })); + expect(screen.getByText("Dice / Rock H5 聚合统计")).toBeTruthy(); + expect(screen.getByText("对局数")).toBeTruthy(); +}); + async function flushEffects() { await act(async () => { for (let index = 0; index < 10; index += 1) { diff --git a/databi/src/api.js b/databi/src/api.js index e40b24d..eddcb9e 100644 --- a/databi/src/api.js +++ b/databi/src/api.js @@ -40,6 +40,27 @@ export async function fetchStatisticsOverview({ appCode, countryId, endMs, regio return fetchDatabiData("/v1/statistics/overview", { appCode, query }); } +export async function fetchSelfGameStatisticsOverview({ appCode, countryId, endMs, gameId, regionId, startMs }) { + const query = { app_code: normalizeAppCode(appCode) || "lalu" }; + if (startMs) { + query.start_ms = String(startMs); + } + if (endMs) { + query.end_ms = String(endMs); + } + if (countryId) { + query.country_id = String(countryId); + } + if (regionId && regionId !== "all") { + query.region_id = String(regionId); + } + if (gameId && gameId !== "all") { + query.game_id = String(gameId); + } + + return fetchDatabiData("/v1/statistics/self-games/overview", { appCode, query }); +} + export async function fetchFilterOptions({ appCode } = {}) { const [apps, regions, countries] = await Promise.all([ fetchDatabiData(apiEndpointPath(API_OPERATIONS.listApps), { appCode }), diff --git a/databi/src/components/DatabiHeader.jsx b/databi/src/components/DatabiHeader.jsx index cb06a5d..553abc4 100644 --- a/databi/src/components/DatabiHeader.jsx +++ b/databi/src/components/DatabiHeader.jsx @@ -19,11 +19,13 @@ const minutes = Array.from({ length: 60 }, (_, index) => pad2(index)); const seconds = minutes; export function DatabiHeader({ + activeScreen = "overview", appCode, appOptions = [], countryId, countryOptions = defaultCountryOptions, filtersLoading = false, + onScreenChange, onAppChange, onCountryChange, onRangeChange, @@ -79,6 +81,14 @@ export function DatabiHeader({

App Data

+
+
+
+ 自研游戏 + Dice / Rock H5 聚合统计 +
+
+ {gameOptions.map((item) => ( + + ))} +
+
+ +
+ {model.kpis.map((item) => ( + + ))} +
+ +
+ + + +
+ +
+ + + +
+ + ); +} + +function buildSelfGameModel(overview) { + const events = eventIndex(overview?.events); + const totals = overview?.match_totals || {}; + const retention = overview?.retention || {}; + const totalStake = numberValue(totals.user_stake_coin) + numberValue(totals.robot_stake_coin); + const settlementEvent = events.get("settlement_show"); + const matchClickEvent = events.get("match_click"); + const matchSuccessEvent = events.get("match_success"); + const pageOpenEvent = events.get("page_open"); + + return { + kpis: [ + metric("H5 PV / UV", `${formatNumber(pageOpenEvent.event_count)} / ${formatNumber(pageOpenEvent.user_count)}`, "页面打开人数和次数"), + metric("点击匹配 UV", formatNumber(matchClickEvent.user_count), `${formatNumber(matchClickEvent.event_count)} 次点击`), + metric("匹配成功 UV", formatNumber(matchSuccessEvent.user_count), `${formatPercent(overview?.conversion?.match_click_to_success_rate)} 点击后成功`), + metric("结算完成 UV", formatNumber(settlementEvent.user_count), `${formatPercent(overview?.conversion?.page_to_settlement_rate)} 页面到结算`), + metric("对局数", formatNumber(totals.created_matches), `成功 ${formatNumber(totals.matched_matches)} / 结算 ${formatNumber(totals.settled_matches)}`), + metric("取消 / 失败", `${formatNumber(totals.canceled_matches)} / ${formatNumber(totals.failed_matches)}`, `${formatPercent(totals.cancel_rate)} / ${formatPercent(totals.fail_rate)}`), + metric("参与人次", formatNumber(numberValue(totals.human_participants) + numberValue(totals.robot_participants)), `真人 ${formatNumber(totals.human_participants)} / 机器人 ${formatNumber(totals.robot_participants)}`), + metric("下注金币", formatCoin(totalStake), `真人 ${formatCoin(totals.user_stake_coin)} / 机器人 ${formatCoin(totals.robot_stake_coin)}`), + metric("派彩金币", formatCoin(totals.payout_coin), `退款 ${formatCoin(totals.refund_coin)}`), + metric("平台抽水", formatCoin(totals.platform_profit_coin), formatPercent(totals.platform_profit_rate)), + metric("奖池变化", formatCoin(totals.pool_delta_coin), `强制结果 ${formatNumber(totals.forced_player_lose_matches)} 次`), + metric("次日 / 7日留存", `${formatPercent(retention.day1_rate)} / ${formatPercent(retention.day7_rate)}`, `样本 ${formatNumber(retention.cohort_users)} 人`) + ], + definitions: overview?.metric_definitions || [], + funnel: overview?.funnel || [], + participants: overview?.participant_distribution || [], + pools: overview?.pool_stats || [], + risks: overview?.user_risk || [], + stakes: overview?.stake_breakdown || [] + }; +} + +function eventIndex(items) { + const index = new Map(); + (Array.isArray(items) ? items : []).forEach((item) => { + const name = item.event_name || ""; + const current = index.get(name) || emptyEvent(); + index.set(name, { + event_count: numberValue(current.event_count) + numberValue(item.event_count), + user_count: numberValue(current.user_count) + numberValue(item.user_count) + }); + }); + return { + get(name) { + return index.get(name) || emptyEvent(); + } + }; +} + +function emptyEvent() { + return { event_count: 0, user_count: 0 }; +} + +function metric(label, value, caption) { + return { caption, label, value }; +} + +function FunnelTable({ funnel, loading }) { + return ( + + eventLabels[row.event_name] || row.event_name || "--" }, + { key: "user_count", label: "UV", render: (row) => formatNumber(row.user_count) }, + { key: "event_count", label: "次数", render: (row) => formatNumber(row.event_count) } + ]} + /> + + ); +} + +function StakeTable({ items, loading }) { + return ( + + formatCoin(row.stake_coin) }, + { key: "settled_matches", label: "结算局", render: (row) => formatNumber(row.settled_matches) }, + { key: "stake", label: "下注", render: (row) => formatCoin(numberValue(row.user_stake_coin) + numberValue(row.robot_stake_coin)) }, + { key: "profit", label: "盈利", render: (row) => formatCoin(row.platform_profit_coin) } + ]} + /> + + ); +} + +function PoolTable({ items, loading }) { + return ( + + directionLabel(row.direction) }, + { key: "reason", label: "原因", render: (row) => row.reason || "--" }, + { key: "flow", label: "入 / 出", render: (row) => `${formatCoin(row.in_coin)} / ${formatCoin(row.out_coin)}` }, + { key: "balance_after_coin", label: "余额", render: (row) => formatCoin(row.balance_after_coin) } + ]} + /> + + ); +} + +function ParticipantTable({ items, loading }) { + return ( + + participantLabel(row.participant_type) }, + { key: "play", label: "手势 / 点数", render: (row) => playLabel(row) }, + { key: "result", label: "结果", render: (row) => resultLabel(row.result) }, + { key: "count", label: "人次", render: (row) => formatNumber(row.participant_count) }, + { key: "win_rate", label: "胜率", render: (row) => formatPercent(row.win_rate) } + ]} + /> + + ); +} + +function RiskTable({ items, loading }) { + return ( + + row.user_id || "--" }, + { key: "match_count", label: "局数", render: (row) => formatNumber(row.match_count) }, + { key: "win_rate", label: "胜率", render: (row) => formatPercent(row.win_rate) }, + { key: "net_win_coin", label: "净赢", render: (row) => formatCoin(row.net_win_coin) }, + { key: "cancel_rate", label: "取消率", render: (row) => formatPercent(row.cancel_rate) } + ]} + /> + + ); +} + +function MetricDefinitionList({ items, loading }) { + return ( + + {loading ?
加载中...
: null} + {!loading && !items.length ?
暂无口径说明
: null} + {!loading && items.length ? ( +
+ {items.map((item) => ( +
+ {item.metric} + {item.definition} +
+ ))} +
+ ) : null} +
+ ); +} + +function DataTable({ columns, emptyText, loading, rows }) { + if (loading) { + return
加载中...
; + } + if (!rows.length) { + return
{emptyText}
; + } + return ( +
+ + + + {columns.map((column) => ( + + ))} + + + + {rows.map((row, rowIndex) => ( + + {columns.map((column) => ( + + ))} + + ))} + +
{column.label}
{column.render(row)}
+
+ ); +} + +function rowKey(row, rowIndex) { + return [row.game_id, row.user_id, row.stake_coin, row.event_name, row.reason, row.participant_type, row.result, row.rps_gesture, row.dice_point, rowIndex] + .filter((item) => item !== undefined && item !== null && item !== "") + .join(":"); +} + +function directionLabel(value) { + if (value === "in") { + return "入池"; + } + if (value === "out") { + return "出池"; + } + return value || "--"; +} + +function participantLabel(value) { + if (value === "robot") { + return "机器人"; + } + if (value === "human") { + return "真人"; + } + return value || "--"; +} + +function resultLabel(value) { + if (value === "win") { + return "胜"; + } + if (value === "lose") { + return "负"; + } + if (value === "draw") { + return "平"; + } + return value || "--"; +} + +function playLabel(row) { + if (row.rps_gesture) { + return row.rps_gesture; + } + if (numberValue(row.dice_point) > 0) { + return `${row.dice_point} 点`; + } + return "--"; +} diff --git a/databi/src/styles/layout.css b/databi/src/styles/layout.css index 1942516..89c46af 100644 --- a/databi/src/styles/layout.css +++ b/databi/src/styles/layout.css @@ -14,6 +14,10 @@ min-height: 0; } +.databi-screen--self-game { + grid-template-rows: 72px minmax(0, 1fr); +} + .databi-header { display: flex; align-items: center; @@ -72,6 +76,40 @@ letter-spacing: 0; } +.screen-switch, +.game-switch { + display: inline-flex; + flex: 0 0 auto; + gap: 3px; + padding: 3px; + border: 1px solid rgba(61, 141, 190, 0.42); + border-radius: 8px; + background: rgba(7, 28, 47, 0.72); +} + +.screen-switch button, +.game-switch button { + height: 32px; + padding: 0 14px; + border: 0; + border-radius: 6px; + background: transparent; + color: #9eb7ce; + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 760; +} + +.screen-switch button:hover, +.game-switch button:hover, +.screen-switch button.is-active, +.game-switch button.is-active { + background: rgba(37, 216, 245, 0.18); + color: #e9f6ff; + box-shadow: inset 0 0 0 1px rgba(37, 216, 245, 0.38); +} + .header-meta { display: flex; gap: 12px; @@ -785,3 +823,52 @@ display: flex; flex-direction: column; } + +.self-game-screen { + display: grid; + grid-template-rows: 44px 220px repeat(2, minmax(236px, auto)); + gap: 16px; + min-height: 0; +} + +.self-game-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-width: 0; +} + +.self-game-title { + display: grid; + min-width: 0; + gap: 3px; +} + +.self-game-title strong { + color: #f3f9ff; + font-size: 20px; + font-weight: 780; +} + +.self-game-title span { + color: #7f9bb7; + font-size: 12px; +} + +.self-game-kpi-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 12px; +} + +.self-game-panel-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + min-height: 0; +} + +.self-game-panel-grid .databi-panel { + min-height: 236px; +} diff --git a/databi/src/styles/tables.css b/databi/src/styles/tables.css index 639cc7e..a621638 100644 --- a/databi/src/styles/tables.css +++ b/databi/src/styles/tables.css @@ -133,3 +133,63 @@ color: #7f9bb7; font-size: 13px; } + +.self-game-table-wrap { + overflow-x: auto; +} + +.self-game-table { + width: 100%; + min-width: 520px; + border-collapse: collapse; +} + +.self-game-table th, +.self-game-table td { + height: 30px; + padding: 0 10px; + border-bottom: 1px solid rgba(108, 157, 196, 0.22); + color: #d8ebff; + font-size: 12px; + text-align: right; + white-space: nowrap; +} + +.self-game-table th { + background: rgba(128, 171, 211, 0.08); + color: #9eb7ce; + font-weight: 720; +} + +.self-game-table th:first-child, +.self-game-table td:first-child, +.self-game-table th:nth-child(2), +.self-game-table td:nth-child(2) { + text-align: left; +} + +.definition-list { + display: grid; + gap: 8px; +} + +.definition-row { + display: grid; + gap: 4px; + padding: 9px 10px; + border: 1px solid rgba(81, 154, 203, 0.2); + border-radius: 8px; + background: rgba(6, 27, 46, 0.72); +} + +.definition-row strong { + color: #e9f6ff; + font-size: 13px; + font-weight: 760; +} + +.definition-row span { + color: #9eb7ce; + font-size: 12px; + line-height: 1.45; +} diff --git a/src/features/games/api.ts b/src/features/games/api.ts index bd705df..7b94c57 100644 --- a/src/features/games/api.ts +++ b/src/features/games/api.ts @@ -91,6 +91,7 @@ export interface DiceConfigDto { maxPlayers: number; robotEnabled: boolean; robotMatchWaitMs: number; + robotUserWinRatePercent: number; poolBalanceCoin: number; createdAtMs?: number; updatedAtMs?: number; @@ -494,6 +495,7 @@ function normalizeDiceConfig(config: Partial): DiceConfigDto { maxPlayers: numberValue(config.maxPlayers || 2), robotEnabled: config.robotEnabled !== false, robotMatchWaitMs: numberValue(config.robotMatchWaitMs || 3000), + robotUserWinRatePercent: numberValue(config.robotUserWinRatePercent ?? 50), poolBalanceCoin: numberValue(config.poolBalanceCoin), createdAtMs: numberValue(config.createdAtMs), updatedAtMs: numberValue(config.updatedAtMs), diff --git a/src/features/games/pages/SelfGamePage.jsx b/src/features/games/pages/SelfGamePage.jsx index c0c97cd..59030ee 100644 --- a/src/features/games/pages/SelfGamePage.jsx +++ b/src/features/games/pages/SelfGamePage.jsx @@ -15,7 +15,12 @@ import { AdminListToolbar, AdminRowActions, } from "@/shared/ui/AdminListLayout.jsx"; -import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection, AdminFormSwitchField } from "@/shared/ui/AdminFormDialog.jsx"; +import { + AdminFormDialog, + AdminFormFieldGrid, + AdminFormSection, + AdminFormSwitchField, +} from "@/shared/ui/AdminFormDialog.jsx"; import { DataTable } from "@/shared/ui/DataTable.jsx"; import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx"; import { useToast } from "@/shared/ui/ToastProvider.jsx"; @@ -30,6 +35,7 @@ const defaultConfigForm = { maxPlayers: 2, robotEnabled: true, robotMatchWaitMs: 3000, + robotUserWinRatePercent: 50, }; const defaultPoolForm = { @@ -100,8 +106,9 @@ export function SelfGamePage() { { key: "robot", label: "机器人", - width: "minmax(150px, .8fr)", - render: (game) => `${game.robotEnabled ? "启用" : "关闭"} · ${game.robotMatchWaitMs}ms`, + width: "minmax(190px, .9fr)", + render: (game) => + `${game.robotEnabled ? "启用" : "关闭"} · ${game.robotMatchWaitMs}ms · 胜率 ${game.robotUserWinRatePercent ?? 50}%`, }, { key: "updated", @@ -219,7 +226,14 @@ export function SelfGamePage() { function ConfigDialog({ form, loading, open, setForm, onClose, onSubmit }) { return ( - + setForm({ ...form, robotMatchWaitMs: event.target.value })} /> + setForm({ ...form, robotUserWinRatePercent: event.target.value })} + /> +