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) => (
+ | {column.label} |
+ ))}
+
+
+
+ {rows.map((row, rowIndex) => (
+
+ {columns.map((column) => (
+ | {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 })}
+ />
+