databi
This commit is contained in:
parent
764da8affc
commit
300d53a0cd
@ -1,3 +1 @@
|
|||||||
VITE_API_BASE_URL=/api
|
VITE_API_BASE_URL=/api
|
||||||
# Production GAAP API endpoint:
|
|
||||||
# VITE_API_BASE_URL=https://api-acc.global-interaction.com/api
|
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
VITE_API_BASE_URL=https://api-acc.global-interaction.com/api
|
VITE_API_BASE_URL=/api
|
||||||
|
|||||||
@ -9,7 +9,7 @@ RUN pnpm install --frozen-lockfile
|
|||||||
|
|
||||||
COPY . .
|
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}
|
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||||
|
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
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 { CountryPerformanceTable } from "./components/CountryPerformanceTable.jsx";
|
||||||
import { DatabiHeader } from "./components/DatabiHeader.jsx";
|
import { DatabiHeader } from "./components/DatabiHeader.jsx";
|
||||||
import { FunnelPanel } from "./components/FunnelPanel.jsx";
|
import { FunnelPanel } from "./components/FunnelPanel.jsx";
|
||||||
@ -8,6 +8,7 @@ import { LuckyGiftPoolModal } from "./components/LuckyGiftPoolModal.jsx";
|
|||||||
import { MetricCard } from "./components/MetricCard.jsx";
|
import { MetricCard } from "./components/MetricCard.jsx";
|
||||||
import { MetricTrendModal } from "./components/MetricTrendModal.jsx";
|
import { MetricTrendModal } from "./components/MetricTrendModal.jsx";
|
||||||
import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
|
import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
|
||||||
|
import { SelfGameScreen } from "./components/SelfGameScreen.jsx";
|
||||||
import { createDashboardModel } from "./data/createDashboardModel.js";
|
import { createDashboardModel } from "./data/createDashboardModel.js";
|
||||||
import { lastDaysRange, rangeEndMs, rangeStartMs, readStoredTimeZone, sameRange, thisMonthRange, TIME_ZONE_OPTIONS, todayRange, writeStoredTimeZone } from "./utils/time.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 [countryId, setCountryId] = useState(0);
|
||||||
const [regionId, setRegionId] = useState("all");
|
const [regionId, setRegionId] = useState("all");
|
||||||
const [appCode, setAppCode] = useState(() => getCurrentAppCode());
|
const [appCode, setAppCode] = useState(() => getCurrentAppCode());
|
||||||
|
const [activeScreen, setActiveScreen] = useState("overview");
|
||||||
|
const [gameId, setGameId] = useState("all");
|
||||||
const [filterOptions, setFilterOptions] = useState(initialFilterOptions);
|
const [filterOptions, setFilterOptions] = useState(initialFilterOptions);
|
||||||
const [filtersLoading, setFiltersLoading] = useState(false);
|
const [filtersLoading, setFiltersLoading] = useState(false);
|
||||||
const [overview, setOverview] = useState(null);
|
const [overview, setOverview] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [selfGameOverview, setSelfGameOverview] = useState(null);
|
||||||
|
const [selfGameLoading, setSelfGameLoading] = useState(false);
|
||||||
|
const [selfGameError, setSelfGameError] = useState("");
|
||||||
const [poolModalOpen, setPoolModalOpen] = useState(false);
|
const [poolModalOpen, setPoolModalOpen] = useState(false);
|
||||||
const [trendModalItem, setTrendModalItem] = useState(null);
|
const [trendModalItem, setTrendModalItem] = useState(null);
|
||||||
const overviewRequestIdRef = useRef(0);
|
const overviewRequestIdRef = useRef(0);
|
||||||
|
const selfGameRequestIdRef = useRef(0);
|
||||||
const overviewRef = useRef(null);
|
const overviewRef = useRef(null);
|
||||||
|
const selfGameOverviewRef = useRef(null);
|
||||||
const scopedCountries = useMemo(() => {
|
const scopedCountries = useMemo(() => {
|
||||||
return filterOptions.countryOptions.filter((country) => Number(country.id) > 0 && countryBelongsToRegion(country, regionId));
|
return filterOptions.countryOptions.filter((country) => Number(country.id) > 0 && countryBelongsToRegion(country, regionId));
|
||||||
}, [filterOptions.countryOptions, regionId]);
|
}, [filterOptions.countryOptions, regionId]);
|
||||||
@ -78,6 +86,10 @@ export function DatabiApp() {
|
|||||||
overviewRef.current = overview;
|
overviewRef.current = overview;
|
||||||
}, [overview]);
|
}, [overview]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selfGameOverviewRef.current = selfGameOverview;
|
||||||
|
}, [selfGameOverview]);
|
||||||
|
|
||||||
const loadOverview = useCallback(async ({ preserveData = false } = {}) => {
|
const loadOverview = useCallback(async ({ preserveData = false } = {}) => {
|
||||||
const requestId = overviewRequestIdRef.current + 1;
|
const requestId = overviewRequestIdRef.current + 1;
|
||||||
overviewRequestIdRef.current = requestId;
|
overviewRequestIdRef.current = requestId;
|
||||||
@ -118,19 +130,63 @@ export function DatabiApp() {
|
|||||||
}
|
}
|
||||||
}, [appCode, countryId, range, regionId, scopedCountries, timeZone]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
void loadFilterOptions();
|
void loadFilterOptions();
|
||||||
}, [loadFilterOptions]);
|
}, [loadFilterOptions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (activeScreen === "selfGames") {
|
||||||
|
void loadSelfGameOverview();
|
||||||
|
const timer = window.setInterval(() => void loadSelfGameOverview({ preserveData: true }), 60_000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}
|
||||||
void loadOverview();
|
void loadOverview();
|
||||||
const timer = window.setInterval(() => void loadOverview({ preserveData: true }), 60_000);
|
const timer = window.setInterval(() => void loadOverview({ preserveData: true }), 60_000);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [loadOverview]);
|
}, [activeScreen, loadOverview, loadSelfGameOverview]);
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
|
if (activeScreen === "selfGames") {
|
||||||
|
void loadSelfGameOverview({ preserveData: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
void loadOverview({ preserveData: true });
|
void loadOverview({ preserveData: true });
|
||||||
}, [loadOverview]);
|
}, [activeScreen, loadOverview, loadSelfGameOverview]);
|
||||||
|
|
||||||
const model = useMemo(() => createDashboardModel(overview, { appCode, countryId, range, timeZone }), [appCode, countryId, overview, range, timeZone]);
|
const model = useMemo(() => createDashboardModel(overview, { appCode, countryId, range, timeZone }), [appCode, countryId, overview, range, timeZone]);
|
||||||
const showSkeleton = loading && !overview;
|
const showSkeleton = loading && !overview;
|
||||||
@ -140,20 +196,22 @@ export function DatabiApp() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="databi-shell">
|
<main className="databi-shell">
|
||||||
<section className="databi-screen">
|
<section className={activeScreen === "selfGames" ? "databi-screen databi-screen--self-game" : "databi-screen"}>
|
||||||
<DatabiHeader
|
<DatabiHeader
|
||||||
|
activeScreen={activeScreen}
|
||||||
appCode={appCode}
|
appCode={appCode}
|
||||||
appOptions={filterOptions.appOptions}
|
appOptions={filterOptions.appOptions}
|
||||||
countryOptions={filterOptions.countryOptions}
|
countryOptions={filterOptions.countryOptions}
|
||||||
countryId={countryId}
|
countryId={countryId}
|
||||||
error={error}
|
error={activeScreen === "selfGames" ? selfGameError : error}
|
||||||
filtersLoading={filtersLoading}
|
filtersLoading={filtersLoading}
|
||||||
loading={loading}
|
loading={activeScreen === "selfGames" ? selfGameLoading : loading}
|
||||||
onAppChange={handleAppChange}
|
onAppChange={handleAppChange}
|
||||||
onCountryChange={setCountryId}
|
onCountryChange={setCountryId}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onRangeChange={setRange}
|
onRangeChange={setRange}
|
||||||
onRegionChange={handleRegionChange}
|
onRegionChange={handleRegionChange}
|
||||||
|
onScreenChange={setActiveScreen}
|
||||||
onTimeZoneChange={handleTimeZoneChange}
|
onTimeZoneChange={handleTimeZoneChange}
|
||||||
range={range}
|
range={range}
|
||||||
regionId={regionId}
|
regionId={regionId}
|
||||||
@ -163,33 +221,43 @@ export function DatabiApp() {
|
|||||||
updatedAt={model.updatedAt}
|
updatedAt={model.updatedAt}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section className="metric-grid" aria-label="核心指标">
|
{activeScreen === "selfGames" ? (
|
||||||
{model.kpis.map((item) => (
|
<SelfGameScreen gameId={gameId} loading={selfGameLoading && !selfGameOverview} onGameChange={setGameId} overview={selfGameOverview} />
|
||||||
<MetricCard key={item.label} item={item} loading={showSkeleton} onClick={() => setTrendModalItem(item)} />
|
) : (
|
||||||
))}
|
<>
|
||||||
</section>
|
<section className="metric-grid" aria-label="核心指标">
|
||||||
|
{model.kpis.map((item) => (
|
||||||
|
<MetricCard key={item.label} item={item} loading={showSkeleton} onClick={() => setTrendModalItem(item)} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="business-metric-grid" aria-label="业务指标">
|
<section className="business-metric-grid" aria-label="业务指标">
|
||||||
{model.businessKpis.map((item) => (
|
{model.businessKpis.map((item) => (
|
||||||
<MetricCard
|
<MetricCard
|
||||||
key={item.label}
|
key={item.label}
|
||||||
item={item}
|
item={item}
|
||||||
loading={showSkeleton}
|
loading={showSkeleton}
|
||||||
onClick={item.detailKey === "luckyGiftPools" ? openLuckyGiftPools : undefined}
|
onClick={item.detailKey === "luckyGiftPools" ? openLuckyGiftPools : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="databi-grid databi-grid--top">
|
<section className="databi-grid databi-grid--top">
|
||||||
<GlobalOverviewPanel countryBreakdown={model.countryBreakdown} loading={showSkeleton} topCountries={model.topCountries} />
|
<GlobalOverviewPanel countryBreakdown={model.countryBreakdown} loading={showSkeleton} topCountries={model.topCountries} />
|
||||||
<RevenueTrendPanel loading={showSkeleton} revenueSeries={model.revenueSeries} />
|
<RevenueTrendPanel loading={showSkeleton} revenueSeries={model.revenueSeries} />
|
||||||
<FunnelPanel funnel={model.funnel} loading={showSkeleton} sideMetrics={model.sideMetrics} />
|
<FunnelPanel funnel={model.funnel} loading={showSkeleton} sideMetrics={model.sideMetrics} />
|
||||||
</section>
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<CountryPerformanceTable gameRanking={model.gameRanking} giftRanking={model.giftRanking} items={model.countryBreakdown} loading={showSkeleton} />
|
{activeScreen === "overview" ? (
|
||||||
{poolModalOpen ? <LuckyGiftPoolModal items={model.luckyGiftPools} onClose={() => setPoolModalOpen(false)} /> : null}
|
<>
|
||||||
{trendModalItem ? <MetricTrendModal item={trendModalItem} onClose={() => setTrendModalItem(null)} /> : null}
|
<CountryPerformanceTable gameRanking={model.gameRanking} giftRanking={model.giftRanking} items={model.countryBreakdown} loading={showSkeleton} />
|
||||||
|
{poolModalOpen ? <LuckyGiftPoolModal items={model.luckyGiftPools} onClose={() => setPoolModalOpen(false)} /> : null}
|
||||||
|
{trendModalItem ? <MetricTrendModal item={trendModalItem} onClose={() => setTrendModalItem(null)} /> : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import { act, render, screen } from "@testing-library/react";
|
import { act, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
import { DatabiApp } from "./DatabiApp.jsx";
|
import { DatabiApp } from "./DatabiApp.jsx";
|
||||||
import { fetchFilterOptions, fetchStatisticsOverview } from "./api.js";
|
import { fetchFilterOptions, fetchSelfGameStatisticsOverview, fetchStatisticsOverview } from "./api.js";
|
||||||
|
|
||||||
vi.mock("./api.js", () => ({
|
vi.mock("./api.js", () => ({
|
||||||
fetchFilterOptions: vi.fn(),
|
fetchFilterOptions: vi.fn(),
|
||||||
|
fetchSelfGameStatisticsOverview: vi.fn(),
|
||||||
fetchStatisticsOverview: vi.fn(),
|
fetchStatisticsOverview: vi.fn(),
|
||||||
getCurrentAppCode: vi.fn(() => "lalu"),
|
getCurrentAppCode: vi.fn(() => "lalu"),
|
||||||
setCurrentAppCode: vi.fn((value) => String(value || "lalu").toLowerCase())
|
setCurrentAppCode: vi.fn((value) => String(value || "lalu").toLowerCase())
|
||||||
@ -22,6 +23,7 @@ beforeEach(() => {
|
|||||||
countryOptions: [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }],
|
countryOptions: [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }],
|
||||||
regionOptions: [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }]
|
regionOptions: [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }]
|
||||||
});
|
});
|
||||||
|
fetchSelfGameStatisticsOverview.mockResolvedValue({});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -51,6 +53,27 @@ test("keeps current data visible during scheduled refresh", async () => {
|
|||||||
expect(document.querySelector(".metric-card.is-loading")).toBeNull();
|
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(<DatabiApp />);
|
||||||
|
|
||||||
|
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() {
|
async function flushEffects() {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
for (let index = 0; index < 10; index += 1) {
|
for (let index = 0; index < 10; index += 1) {
|
||||||
|
|||||||
@ -40,6 +40,27 @@ export async function fetchStatisticsOverview({ appCode, countryId, endMs, regio
|
|||||||
return fetchDatabiData("/v1/statistics/overview", { appCode, query });
|
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 } = {}) {
|
export async function fetchFilterOptions({ appCode } = {}) {
|
||||||
const [apps, regions, countries] = await Promise.all([
|
const [apps, regions, countries] = await Promise.all([
|
||||||
fetchDatabiData(apiEndpointPath(API_OPERATIONS.listApps), { appCode }),
|
fetchDatabiData(apiEndpointPath(API_OPERATIONS.listApps), { appCode }),
|
||||||
|
|||||||
@ -19,11 +19,13 @@ const minutes = Array.from({ length: 60 }, (_, index) => pad2(index));
|
|||||||
const seconds = minutes;
|
const seconds = minutes;
|
||||||
|
|
||||||
export function DatabiHeader({
|
export function DatabiHeader({
|
||||||
|
activeScreen = "overview",
|
||||||
appCode,
|
appCode,
|
||||||
appOptions = [],
|
appOptions = [],
|
||||||
countryId,
|
countryId,
|
||||||
countryOptions = defaultCountryOptions,
|
countryOptions = defaultCountryOptions,
|
||||||
filtersLoading = false,
|
filtersLoading = false,
|
||||||
|
onScreenChange,
|
||||||
onAppChange,
|
onAppChange,
|
||||||
onCountryChange,
|
onCountryChange,
|
||||||
onRangeChange,
|
onRangeChange,
|
||||||
@ -79,6 +81,14 @@ export function DatabiHeader({
|
|||||||
<h1>App Data</h1>
|
<h1>App Data</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<nav className="screen-switch" aria-label="数据大屏切换">
|
||||||
|
<button className={activeScreen === "overview" ? "is-active" : ""} onClick={() => onScreenChange?.("overview")} type="button">
|
||||||
|
运营总览
|
||||||
|
</button>
|
||||||
|
<button className={activeScreen === "selfGames" ? "is-active" : ""} onClick={() => onScreenChange?.("selfGames")} type="button">
|
||||||
|
自研游戏
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
<div className="header-controls">
|
<div className="header-controls">
|
||||||
<FilterMenu
|
<FilterMenu
|
||||||
className="filter-control--timezone"
|
className="filter-control--timezone"
|
||||||
|
|||||||
313
databi/src/components/SelfGameScreen.jsx
Normal file
313
databi/src/components/SelfGameScreen.jsx
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
import { MetricCard } from "./MetricCard.jsx";
|
||||||
|
import { Panel } from "./Panel.jsx";
|
||||||
|
import { formatCoin, formatNumber, formatPercent } from "../utils/format.js";
|
||||||
|
import { numberValue } from "../utils/number.js";
|
||||||
|
|
||||||
|
const gameOptions = [
|
||||||
|
{ label: "全部", value: "all" },
|
||||||
|
{ label: "Dice", value: "dice" },
|
||||||
|
{ label: "Rock", value: "rock" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const eventLabels = {
|
||||||
|
page_open: "页面打开",
|
||||||
|
config_load_success: "配置成功",
|
||||||
|
config_load_failed: "配置失败",
|
||||||
|
stake_selected: "选择档位",
|
||||||
|
match_click: "点击匹配",
|
||||||
|
match_success: "匹配成功",
|
||||||
|
animation_start: "进入开奖",
|
||||||
|
settlement_show: "结算展示",
|
||||||
|
play_again_click: "再来一局",
|
||||||
|
exit_click: "退出"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SelfGameScreen({ gameId = "all", loading = false, onGameChange, overview }) {
|
||||||
|
const model = buildSelfGameModel(overview);
|
||||||
|
const showSkeleton = loading && !overview;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="self-game-screen" aria-label="自研游戏统计">
|
||||||
|
<div className="self-game-toolbar">
|
||||||
|
<div className="self-game-title">
|
||||||
|
<strong>自研游戏</strong>
|
||||||
|
<span>Dice / Rock H5 聚合统计</span>
|
||||||
|
</div>
|
||||||
|
<div className="game-switch" role="tablist" aria-label="游戏筛选">
|
||||||
|
{gameOptions.map((item) => (
|
||||||
|
<button className={gameId === item.value ? "is-active" : ""} key={item.value} onClick={() => onGameChange?.(item.value)} type="button">
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="self-game-kpi-grid" aria-label="自研游戏核心指标">
|
||||||
|
{model.kpis.map((item) => (
|
||||||
|
<MetricCard key={item.label} item={item} loading={showSkeleton} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="self-game-panel-grid self-game-panel-grid--top">
|
||||||
|
<FunnelTable funnel={model.funnel} loading={showSkeleton} />
|
||||||
|
<StakeTable items={model.stakes} loading={showSkeleton} />
|
||||||
|
<PoolTable items={model.pools} loading={showSkeleton} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="self-game-panel-grid">
|
||||||
|
<ParticipantTable items={model.participants} loading={showSkeleton} />
|
||||||
|
<RiskTable items={model.risks} loading={showSkeleton} />
|
||||||
|
<MetricDefinitionList items={model.definitions} loading={showSkeleton} />
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Panel className="panel--self-game" title="转化漏斗">
|
||||||
|
<DataTable
|
||||||
|
emptyText="暂无漏斗数据"
|
||||||
|
loading={loading}
|
||||||
|
rows={funnel}
|
||||||
|
columns={[
|
||||||
|
{ key: "event_name", label: "节点", render: (row) => 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) }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StakeTable({ items, loading }) {
|
||||||
|
return (
|
||||||
|
<Panel className="panel--self-game" title="下注档位">
|
||||||
|
<DataTable
|
||||||
|
emptyText="暂无档位数据"
|
||||||
|
loading={loading}
|
||||||
|
rows={items}
|
||||||
|
columns={[
|
||||||
|
{ key: "stake_coin", label: "档位", render: (row) => 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) }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PoolTable({ items, loading }) {
|
||||||
|
return (
|
||||||
|
<Panel className="panel--self-game" title="奖池流水">
|
||||||
|
<DataTable
|
||||||
|
emptyText="暂无奖池流水"
|
||||||
|
loading={loading}
|
||||||
|
rows={items}
|
||||||
|
columns={[
|
||||||
|
{ key: "direction", label: "方向", render: (row) => 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) }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ParticipantTable({ items, loading }) {
|
||||||
|
return (
|
||||||
|
<Panel className="panel--self-game" title="玩法分布">
|
||||||
|
<DataTable
|
||||||
|
emptyText="暂无玩法数据"
|
||||||
|
loading={loading}
|
||||||
|
rows={items}
|
||||||
|
columns={[
|
||||||
|
{ key: "type", label: "对象", render: (row) => 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) }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RiskTable({ items, loading }) {
|
||||||
|
return (
|
||||||
|
<Panel className="panel--self-game" title="高风险用户">
|
||||||
|
<DataTable
|
||||||
|
emptyText="暂无风险用户"
|
||||||
|
loading={loading}
|
||||||
|
rows={items}
|
||||||
|
columns={[
|
||||||
|
{ key: "user_id", label: "用户", render: (row) => 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) }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricDefinitionList({ items, loading }) {
|
||||||
|
return (
|
||||||
|
<Panel className="panel--self-game" title="口径">
|
||||||
|
{loading ? <div className="panel-empty">加载中...</div> : null}
|
||||||
|
{!loading && !items.length ? <div className="panel-empty">暂无口径说明</div> : null}
|
||||||
|
{!loading && items.length ? (
|
||||||
|
<div className="definition-list">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div className="definition-row" key={item.metric}>
|
||||||
|
<strong>{item.metric}</strong>
|
||||||
|
<span>{item.definition}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataTable({ columns, emptyText, loading, rows }) {
|
||||||
|
if (loading) {
|
||||||
|
return <div className="panel-empty">加载中...</div>;
|
||||||
|
}
|
||||||
|
if (!rows.length) {
|
||||||
|
return <div className="panel-empty">{emptyText}</div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="self-game-table-wrap">
|
||||||
|
<table className="self-game-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<th key={column.key}>{column.label}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, rowIndex) => (
|
||||||
|
<tr key={rowKey(row, rowIndex)}>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<td key={column.key}>{column.render(row)}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 "--";
|
||||||
|
}
|
||||||
@ -14,6 +14,10 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.databi-screen--self-game {
|
||||||
|
grid-template-rows: 72px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.databi-header {
|
.databi-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -72,6 +76,40 @@
|
|||||||
letter-spacing: 0;
|
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 {
|
.header-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@ -785,3 +823,52 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -133,3 +133,63 @@
|
|||||||
color: #7f9bb7;
|
color: #7f9bb7;
|
||||||
font-size: 13px;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -91,6 +91,7 @@ export interface DiceConfigDto {
|
|||||||
maxPlayers: number;
|
maxPlayers: number;
|
||||||
robotEnabled: boolean;
|
robotEnabled: boolean;
|
||||||
robotMatchWaitMs: number;
|
robotMatchWaitMs: number;
|
||||||
|
robotUserWinRatePercent: number;
|
||||||
poolBalanceCoin: number;
|
poolBalanceCoin: number;
|
||||||
createdAtMs?: number;
|
createdAtMs?: number;
|
||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
@ -494,6 +495,7 @@ function normalizeDiceConfig(config: Partial<DiceConfigDto>): DiceConfigDto {
|
|||||||
maxPlayers: numberValue(config.maxPlayers || 2),
|
maxPlayers: numberValue(config.maxPlayers || 2),
|
||||||
robotEnabled: config.robotEnabled !== false,
|
robotEnabled: config.robotEnabled !== false,
|
||||||
robotMatchWaitMs: numberValue(config.robotMatchWaitMs || 3000),
|
robotMatchWaitMs: numberValue(config.robotMatchWaitMs || 3000),
|
||||||
|
robotUserWinRatePercent: numberValue(config.robotUserWinRatePercent ?? 50),
|
||||||
poolBalanceCoin: numberValue(config.poolBalanceCoin),
|
poolBalanceCoin: numberValue(config.poolBalanceCoin),
|
||||||
createdAtMs: numberValue(config.createdAtMs),
|
createdAtMs: numberValue(config.createdAtMs),
|
||||||
updatedAtMs: numberValue(config.updatedAtMs),
|
updatedAtMs: numberValue(config.updatedAtMs),
|
||||||
|
|||||||
@ -15,7 +15,12 @@ import {
|
|||||||
AdminListToolbar,
|
AdminListToolbar,
|
||||||
AdminRowActions,
|
AdminRowActions,
|
||||||
} from "@/shared/ui/AdminListLayout.jsx";
|
} 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 { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx";
|
import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
@ -30,6 +35,7 @@ const defaultConfigForm = {
|
|||||||
maxPlayers: 2,
|
maxPlayers: 2,
|
||||||
robotEnabled: true,
|
robotEnabled: true,
|
||||||
robotMatchWaitMs: 3000,
|
robotMatchWaitMs: 3000,
|
||||||
|
robotUserWinRatePercent: 50,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultPoolForm = {
|
const defaultPoolForm = {
|
||||||
@ -100,8 +106,9 @@ export function SelfGamePage() {
|
|||||||
{
|
{
|
||||||
key: "robot",
|
key: "robot",
|
||||||
label: "机器人",
|
label: "机器人",
|
||||||
width: "minmax(150px, .8fr)",
|
width: "minmax(190px, .9fr)",
|
||||||
render: (game) => `${game.robotEnabled ? "启用" : "关闭"} · ${game.robotMatchWaitMs}ms`,
|
render: (game) =>
|
||||||
|
`${game.robotEnabled ? "启用" : "关闭"} · ${game.robotMatchWaitMs}ms · 胜率 ${game.robotUserWinRatePercent ?? 50}%`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "updated",
|
key: "updated",
|
||||||
@ -219,7 +226,14 @@ export function SelfGamePage() {
|
|||||||
|
|
||||||
function ConfigDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
function ConfigDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
||||||
return (
|
return (
|
||||||
<AdminFormDialog loading={loading} open={open} submitLabel="保存配置" title="骰子配置" onClose={onClose} onSubmit={onSubmit}>
|
<AdminFormDialog
|
||||||
|
loading={loading}
|
||||||
|
open={open}
|
||||||
|
submitLabel="保存配置"
|
||||||
|
title="骰子配置"
|
||||||
|
onClose={onClose}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
>
|
||||||
<AdminFormSection title="下注与费率">
|
<AdminFormSection title="下注与费率">
|
||||||
<AdminFormFieldGrid>
|
<AdminFormFieldGrid>
|
||||||
<TextField
|
<TextField
|
||||||
@ -270,6 +284,14 @@ function ConfigDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
|||||||
value={form.robotMatchWaitMs}
|
value={form.robotMatchWaitMs}
|
||||||
onChange={(event) => setForm({ ...form, robotMatchWaitMs: event.target.value })}
|
onChange={(event) => setForm({ ...form, robotMatchWaitMs: event.target.value })}
|
||||||
/>
|
/>
|
||||||
|
<TextField
|
||||||
|
helperText="50 为正常规则"
|
||||||
|
label="机器人胜率 %"
|
||||||
|
type="number"
|
||||||
|
value={form.robotUserWinRatePercent}
|
||||||
|
slotProps={{ htmlInput: { max: 100, min: 0 } }}
|
||||||
|
onChange={(event) => setForm({ ...form, robotUserWinRatePercent: event.target.value })}
|
||||||
|
/>
|
||||||
<AdminFormSwitchField
|
<AdminFormSwitchField
|
||||||
checked={form.robotEnabled}
|
checked={form.robotEnabled}
|
||||||
label="机器人补位"
|
label="机器人补位"
|
||||||
@ -283,7 +305,14 @@ function ConfigDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
|||||||
|
|
||||||
function PoolDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
function PoolDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
||||||
return (
|
return (
|
||||||
<AdminFormDialog loading={loading} open={open} submitLabel="确认调整" title="调整奖池" onClose={onClose} onSubmit={onSubmit}>
|
<AdminFormDialog
|
||||||
|
loading={loading}
|
||||||
|
open={open}
|
||||||
|
submitLabel="确认调整"
|
||||||
|
title="调整奖池"
|
||||||
|
onClose={onClose}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
>
|
||||||
<AdminFormFieldGrid>
|
<AdminFormFieldGrid>
|
||||||
<TextField
|
<TextField
|
||||||
label="方向"
|
label="方向"
|
||||||
@ -320,6 +349,7 @@ function configToForm(game) {
|
|||||||
maxPlayers: game.maxPlayers,
|
maxPlayers: game.maxPlayers,
|
||||||
robotEnabled: game.robotEnabled,
|
robotEnabled: game.robotEnabled,
|
||||||
robotMatchWaitMs: game.robotMatchWaitMs,
|
robotMatchWaitMs: game.robotMatchWaitMs,
|
||||||
|
robotUserWinRatePercent: game.robotUserWinRatePercent ?? 50,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -334,9 +364,16 @@ function configPayload(form, gameId) {
|
|||||||
maxPlayers: Number(form.maxPlayers || 2),
|
maxPlayers: Number(form.maxPlayers || 2),
|
||||||
robotEnabled: Boolean(form.robotEnabled),
|
robotEnabled: Boolean(form.robotEnabled),
|
||||||
robotMatchWaitMs: Number(form.robotMatchWaitMs || 3000),
|
robotMatchWaitMs: Number(form.robotMatchWaitMs || 3000),
|
||||||
|
robotUserWinRatePercent: clampPercent(form.robotUserWinRatePercent),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clampPercent(value) {
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!Number.isFinite(numeric)) return 50;
|
||||||
|
return Math.min(100, Math.max(0, Math.trunc(numeric)));
|
||||||
|
}
|
||||||
|
|
||||||
function parseStakeOptions(text) {
|
function parseStakeOptions(text) {
|
||||||
return String(text || "")
|
return String(text || "")
|
||||||
.split(/[,,\s]+/)
|
.split(/[,,\s]+/)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user