Compare commits
21 Commits
103b70f5e5
...
64cfa472b8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64cfa472b8 | ||
|
|
1447de8563 | ||
|
|
aa7ce50e4f | ||
|
|
79da033dc9 | ||
|
|
001eb0d4dc | ||
|
|
1beca72cb1 | ||
|
|
3900866df0 | ||
|
|
282c9f1734 | ||
|
|
3983e355fb | ||
|
|
ee4da04c61 | ||
|
|
a8614398a4 | ||
|
|
fd8c5bbd78 | ||
|
|
480b6e0825 | ||
|
|
2ebacae7c2 | ||
|
|
ac5d3a53d9 | ||
|
|
5400f1dd57 | ||
|
|
300d53a0cd | ||
|
|
764da8affc | ||
|
|
087e379910 | ||
|
|
91498b7557 | ||
|
|
4b16f93930 |
1
.env.production
Normal file
1
.env.production
Normal file
@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=/api
|
||||
@ -4892,10 +4892,10 @@
|
||||
"type": "string"
|
||||
},
|
||||
"ownerUserId": {
|
||||
"type": "integer"
|
||||
"type": "string"
|
||||
},
|
||||
"parentBdUserId": {
|
||||
"type": "integer"
|
||||
"type": "string"
|
||||
},
|
||||
"regionId": {
|
||||
"type": "integer"
|
||||
@ -4919,7 +4919,7 @@
|
||||
"type": "integer"
|
||||
},
|
||||
"parentLeaderUserId": {
|
||||
"type": "integer"
|
||||
"type": "string"
|
||||
},
|
||||
"regionId": {
|
||||
"type": "integer"
|
||||
@ -4934,7 +4934,7 @@
|
||||
"type": "integer"
|
||||
},
|
||||
"userId": {
|
||||
"type": "integer"
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -4991,7 +4991,7 @@
|
||||
"type": "integer"
|
||||
},
|
||||
"userId": {
|
||||
"type": "integer"
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -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 (
|
||||
<main className="databi-shell">
|
||||
<section className="databi-screen">
|
||||
<section className={activeScreen === "selfGames" ? "databi-screen databi-screen--self-game" : "databi-screen"}>
|
||||
<DatabiHeader
|
||||
activeScreen={activeScreen}
|
||||
appCode={appCode}
|
||||
appOptions={filterOptions.appOptions}
|
||||
countryOptions={filterOptions.countryOptions}
|
||||
countryId={countryId}
|
||||
error={error}
|
||||
error={activeScreen === "selfGames" ? selfGameError : error}
|
||||
filtersLoading={filtersLoading}
|
||||
loading={loading}
|
||||
loading={activeScreen === "selfGames" ? selfGameLoading : loading}
|
||||
onAppChange={handleAppChange}
|
||||
onCountryChange={setCountryId}
|
||||
onRefresh={handleRefresh}
|
||||
onRangeChange={setRange}
|
||||
onRegionChange={handleRegionChange}
|
||||
onScreenChange={setActiveScreen}
|
||||
onTimeZoneChange={handleTimeZoneChange}
|
||||
range={range}
|
||||
regionId={regionId}
|
||||
@ -163,33 +221,43 @@ export function DatabiApp() {
|
||||
updatedAt={model.updatedAt}
|
||||
/>
|
||||
|
||||
<section className="metric-grid" aria-label="核心指标">
|
||||
{model.kpis.map((item) => (
|
||||
<MetricCard key={item.label} item={item} loading={showSkeleton} onClick={() => setTrendModalItem(item)} />
|
||||
))}
|
||||
</section>
|
||||
{activeScreen === "selfGames" ? (
|
||||
<SelfGameScreen gameId={gameId} loading={selfGameLoading && !selfGameOverview} onGameChange={setGameId} overview={selfGameOverview} />
|
||||
) : (
|
||||
<>
|
||||
<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="业务指标">
|
||||
{model.businessKpis.map((item) => (
|
||||
<MetricCard
|
||||
key={item.label}
|
||||
item={item}
|
||||
loading={showSkeleton}
|
||||
onClick={item.detailKey === "luckyGiftPools" ? openLuckyGiftPools : undefined}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
<section className="business-metric-grid" aria-label="业务指标">
|
||||
{model.businessKpis.map((item) => (
|
||||
<MetricCard
|
||||
key={item.label}
|
||||
item={item}
|
||||
loading={showSkeleton}
|
||||
onClick={item.detailKey === "luckyGiftPools" ? openLuckyGiftPools : undefined}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="databi-grid databi-grid--top">
|
||||
<GlobalOverviewPanel countryBreakdown={model.countryBreakdown} loading={showSkeleton} topCountries={model.topCountries} />
|
||||
<RevenueTrendPanel loading={showSkeleton} revenueSeries={model.revenueSeries} />
|
||||
<FunnelPanel funnel={model.funnel} loading={showSkeleton} sideMetrics={model.sideMetrics} />
|
||||
</section>
|
||||
<section className="databi-grid databi-grid--top">
|
||||
<GlobalOverviewPanel countryBreakdown={model.countryBreakdown} loading={showSkeleton} topCountries={model.topCountries} />
|
||||
<RevenueTrendPanel loading={showSkeleton} revenueSeries={model.revenueSeries} />
|
||||
<FunnelPanel funnel={model.funnel} loading={showSkeleton} sideMetrics={model.sideMetrics} />
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<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}
|
||||
{activeScreen === "overview" ? (
|
||||
<>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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(<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() {
|
||||
await act(async () => {
|
||||
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 });
|
||||
}
|
||||
|
||||
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 }),
|
||||
|
||||
@ -19,11 +19,15 @@ const minutes = Array.from({ length: 60 }, (_, index) => pad2(index));
|
||||
const seconds = minutes;
|
||||
|
||||
export function DatabiHeader({
|
||||
activeScreen = "overview",
|
||||
appCode,
|
||||
appOptions = [],
|
||||
countryId,
|
||||
countryOptions = defaultCountryOptions,
|
||||
error = "",
|
||||
filtersLoading = false,
|
||||
loading = false,
|
||||
onScreenChange,
|
||||
onAppChange,
|
||||
onCountryChange,
|
||||
onRangeChange,
|
||||
@ -79,6 +83,14 @@ export function DatabiHeader({
|
||||
<h1>App Data</h1>
|
||||
</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">
|
||||
<FilterMenu
|
||||
className="filter-control--timezone"
|
||||
@ -133,7 +145,10 @@ export function DatabiHeader({
|
||||
value={countryId}
|
||||
valueLabel={countryLabel}
|
||||
/>
|
||||
<span className="live-dot">实时</span>
|
||||
<span className={error ? "live-dot live-dot--warn" : "live-dot"} title={error || ""}>
|
||||
{error ? "异常" : loading ? "刷新中" : "实时"}
|
||||
</span>
|
||||
{error ? <span className="header-error">{error}</span> : null}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
@ -143,11 +158,11 @@ function FilterMenu({ className = "", icon, isOpen, label, loading = false, onSe
|
||||
return (
|
||||
<div className={["filter-control", className].filter(Boolean).join(" ")}>
|
||||
<button aria-expanded={isOpen} className="filter-trigger" disabled={loading} onClick={onToggle} type="button">
|
||||
{icon ? <span className="filter-icon">{icon}</span> : null}
|
||||
<span className="filter-copy">
|
||||
<small>{label}</small>
|
||||
<b>{valueLabel}</b>
|
||||
<span className="filter-label">
|
||||
{icon ? <span className="filter-icon">{icon}</span> : null}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
<span className="filter-value">{valueLabel}</span>
|
||||
<KeyboardArrowDownOutlined className="filter-chevron" fontSize="small" />
|
||||
</button>
|
||||
{isOpen ? (
|
||||
@ -186,11 +201,11 @@ function SearchableFilterMenu({ icon, isOpen, label, loading = false, onSelect,
|
||||
return (
|
||||
<div className="filter-control filter-control--country">
|
||||
<button aria-expanded={isOpen} className="filter-trigger" disabled={loading} onClick={onToggle} type="button">
|
||||
{icon ? <span className="filter-icon">{icon}</span> : null}
|
||||
<span className="filter-copy">
|
||||
<small>{label}</small>
|
||||
<b>{valueLabel}</b>
|
||||
<span className="filter-label">
|
||||
{icon ? <span className="filter-icon">{icon}</span> : null}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
<span className="filter-value">{valueLabel}</span>
|
||||
<KeyboardArrowDownOutlined className="filter-chevron" fontSize="small" />
|
||||
</button>
|
||||
{isOpen ? (
|
||||
@ -286,14 +301,14 @@ function RangeMenu({ activeValue, isOpen, onSelect, onToggle, range, timeZone })
|
||||
return (
|
||||
<div className="filter-control filter-control--range">
|
||||
<button aria-expanded={isOpen} aria-haspopup="listbox" className="date-range-trigger" onClick={onToggle} type="button">
|
||||
<span className="date-range-icon"><CalendarMonthOutlined fontSize="small" /></span>
|
||||
<span className="date-range-copy">
|
||||
<span className="date-range-label">{activeOption?.label || "时间范围"}</span>
|
||||
<span className="date-range-values">
|
||||
<b>{range.start}</b>
|
||||
<i>~</i>
|
||||
<b>{range.end}</b>
|
||||
</span>
|
||||
<span className="date-range-label">
|
||||
<CalendarMonthOutlined fontSize="inherit" />
|
||||
<span>{activeOption?.label || "时间范围"}</span>
|
||||
</span>
|
||||
<span className="date-range-values">
|
||||
<b>{range.start}</b>
|
||||
<i>~</i>
|
||||
<b>{range.end}</b>
|
||||
</span>
|
||||
<KeyboardArrowDownOutlined className="date-range-chevron" fontSize="small" />
|
||||
</button>
|
||||
|
||||
@ -4,7 +4,7 @@ import { Panel } from "./Panel.jsx";
|
||||
|
||||
export function FunnelPanel({ funnel, loading = false, sideMetrics }) {
|
||||
return (
|
||||
<Panel title="ARPU 转化漏斗">
|
||||
<Panel title="付费转化漏斗">
|
||||
<div className={["funnel-layout", loading ? "skeleton-panel" : ""].filter(Boolean).join(" ")} aria-busy={loading || undefined}>
|
||||
<div className="funnel-stack">
|
||||
{loading
|
||||
|
||||
438
databi/src/components/SelfGameScreen.jsx
Normal file
438
databi/src/components/SelfGameScreen.jsx
Normal file
@ -0,0 +1,438 @@
|
||||
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: "配置失败",
|
||||
gesture_select: "选择手势",
|
||||
match_api: "匹配接口",
|
||||
match_cancel: "取消匹配",
|
||||
stake_selected: "选择档位",
|
||||
stake_select: "选择档位",
|
||||
match_click: "点击匹配",
|
||||
match_success: "匹配成功",
|
||||
animation_start: "进入开奖",
|
||||
preload_complete: "预加载完成",
|
||||
reveal_enter: "进入开奖",
|
||||
roll_api: "开奖接口",
|
||||
settlement_show: "结算展示",
|
||||
play_again: "再来一局",
|
||||
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} />
|
||||
<WinLossTable summary={model.winLoss} loading={showSkeleton} />
|
||||
<RiskTable items={model.risks} loading={showSkeleton} />
|
||||
</section>
|
||||
|
||||
<section className="self-game-panel-grid self-game-panel-grid--single">
|
||||
<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 participants = Array.isArray(overview?.participant_distribution) ? overview.participant_distribution : [];
|
||||
const pools = Array.isArray(overview?.pool_stats) ? overview.pool_stats : [];
|
||||
const userResult = realUserResultCounts(participants, totals);
|
||||
const userNetWin = numberValue(totals.payout_coin) + numberValue(totals.refund_coin) - numberValue(totals.user_stake_coin);
|
||||
const poolBalance = latestPoolBalance(pools);
|
||||
const settlementEvent = events.get("settlement_show");
|
||||
const matchClickEvent = events.get("match_click");
|
||||
const matchSuccessEvent = events.get("match_success");
|
||||
const pageOpenEvent = events.get("page_open");
|
||||
const winLoss = {
|
||||
countNote: userResult.countNote,
|
||||
drawCount: userResult.draw,
|
||||
loserCount: userResult.lose,
|
||||
payoutCoin: numberValue(totals.payout_coin),
|
||||
refundCoin: numberValue(totals.refund_coin),
|
||||
userNetWin,
|
||||
userStakeCoin: numberValue(totals.user_stake_coin),
|
||||
winnerCount: userResult.win
|
||||
};
|
||||
|
||||
return {
|
||||
kpis: [
|
||||
metric("游戏 PV / UV", `${formatNumber(pageOpenEvent.event_count)} / ${formatNumber(pageOpenEvent.user_count)}`, "进入 dice、rock H5 的次数和人数"),
|
||||
metric("开局人数", `点击 ${formatNumber(matchClickEvent.user_count)} / 成功 ${formatNumber(matchSuccessEvent.user_count)}`, `${formatNumber(matchClickEvent.event_count)} 次点击`),
|
||||
metric("创建局", formatNumber(totals.created_matches), "服务端创建的对局数"),
|
||||
metric("匹配成功局", formatNumber(totals.matched_matches), `${formatPercent(overview?.conversion?.match_click_to_success_rate)} 点击后成功`),
|
||||
metric("结算完成 UV", formatNumber(settlementEvent.user_count), `${formatPercent(overview?.conversion?.page_to_settlement_rate)} 页面到结算`),
|
||||
metric("已结算局", 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), `余额 ${formatCoin(poolBalance)} / 强制 ${formatNumber(totals.forced_player_lose_matches)} 次`),
|
||||
metric("用户输赢", `${formatNumber(winLoss.winnerCount)} / ${formatNumber(winLoss.loserCount)} / ${formatNumber(winLoss.drawCount)}`, `真实用户净输赢 ${formatCoin(winLoss.userNetWin)}`),
|
||||
metric("次日 / 7日留存", `${formatPercent(retention.day1_rate)} / ${formatPercent(retention.day7_rate)}`, `样本 ${formatNumber(retention.cohort_users)} 人`)
|
||||
],
|
||||
definitions: overview?.metric_definitions || [],
|
||||
funnel: overview?.funnel || [],
|
||||
participants,
|
||||
pools,
|
||||
risks: overview?.user_risk || [],
|
||||
stakes: overview?.stake_breakdown || [],
|
||||
winLoss
|
||||
};
|
||||
}
|
||||
|
||||
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}
|
||||
wide
|
||||
columns={[
|
||||
{ key: "stake_coin", label: "档位", render: (row) => formatCoin(row.stake_coin) },
|
||||
{ key: "created_matches", label: "创建局", render: (row) => formatNumber(row.created_matches) },
|
||||
{ key: "matched_matches", label: "匹配局", render: (row) => formatNumber(row.matched_matches) },
|
||||
{ key: "settled_matches", label: "结算局", render: (row) => formatNumber(row.settled_matches) },
|
||||
{ key: "user_stake_coin", label: "真人下注", render: (row) => formatCoin(row.user_stake_coin) },
|
||||
{ key: "robot_stake_coin", label: "机器人下注", render: (row) => formatCoin(row.robot_stake_coin) },
|
||||
{ key: "stake", label: "总下注", render: (row) => formatCoin(numberValue(row.user_stake_coin) + numberValue(row.robot_stake_coin)) },
|
||||
{ key: "payout_coin", label: "派奖", render: (row) => formatCoin(row.payout_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}
|
||||
wide
|
||||
columns={[
|
||||
{ key: "stake_coin", label: "档位", render: (row) => formatCoin(row.stake_coin) },
|
||||
{ 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: "net_win_coin", label: "净输赢", render: (row) => formatCoin(row.net_win_coin) },
|
||||
{ key: "win_rate", label: "胜率", render: (row) => formatPercent(row.win_rate) }
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function WinLossTable({ loading, summary }) {
|
||||
const rows = [
|
||||
{ metric: "真实用户赢家数", value: summary.winnerCount, note: summary.countNote },
|
||||
{ metric: "真实用户输家数", value: summary.loserCount, note: summary.countNote },
|
||||
{ metric: "真实用户平局数", value: summary.drawCount, note: summary.countNote },
|
||||
{ metric: "真实用户下注", value: summary.userStakeCoin, note: "match_totals.user_stake_coin", type: "coin" },
|
||||
{ metric: "真实用户派奖", value: summary.payoutCoin, note: "match_totals.payout_coin", type: "coin" },
|
||||
{ metric: "真实用户退款", value: summary.refundCoin, note: "match_totals.refund_coin", type: "coin" },
|
||||
{ metric: "真实用户净输赢", value: summary.userNetWin, note: "派奖 + 退款 - 真实用户下注", type: "coin" }
|
||||
];
|
||||
return (
|
||||
<Panel className="panel--self-game" title="用户输赢">
|
||||
<DataTable
|
||||
emptyText="暂无输赢数据"
|
||||
loading={loading}
|
||||
rows={rows}
|
||||
columns={[
|
||||
{ key: "metric", label: "指标", render: (row) => row.metric },
|
||||
{ key: "value", label: "数值", render: (row) => (row.type === "coin" ? formatCoin(row.value) : formatNumber(row.value)) },
|
||||
{ key: "note", label: "口径", render: (row) => row.note }
|
||||
]}
|
||||
/>
|
||||
</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) => <RiskUserCell row={row} /> },
|
||||
{ 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 RiskUserCell({ row }) {
|
||||
const nickname = row.nickname || row.username || "";
|
||||
const shortID = row.short_id || row.display_user_id || "";
|
||||
const userID = row.user_id ? String(row.user_id) : "";
|
||||
const avatar = row.avatar || "";
|
||||
const primary = nickname || shortID || userID || "--";
|
||||
const secondary = shortID ? `ID ${shortID}` : userID ? `UID ${userID}` : "";
|
||||
return (
|
||||
<div className="risk-user-cell">
|
||||
{avatar ? <img alt="" className="risk-user-avatar" src={avatar} /> : <span className="risk-user-avatar risk-user-avatar--empty">{primary.slice(0, 1).toUpperCase()}</span>}
|
||||
<span className="risk-user-copy">
|
||||
<strong>{primary}</strong>
|
||||
{secondary ? <small>{secondary}</small> : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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, wide = false }) {
|
||||
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", wide ? "self-game-table--wide" : ""].filter(Boolean).join(" ")}>
|
||||
<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.metric, 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 realUserResultCounts(items, totals) {
|
||||
const result = { countNote: "按 participant_distribution 中真实用户结果统计", draw: 0, lose: 0, win: 0 };
|
||||
let userRows = 0;
|
||||
items.forEach((item) => {
|
||||
if (!isRealUserParticipant(item.participant_type)) {
|
||||
return;
|
||||
}
|
||||
userRows += 1;
|
||||
const count = numberValue(item.participant_count);
|
||||
if (item.result === "win") {
|
||||
result.win += count;
|
||||
} else if (item.result === "lose") {
|
||||
result.lose += count;
|
||||
} else if (item.result === "draw") {
|
||||
result.draw += count;
|
||||
}
|
||||
});
|
||||
if (userRows > 0) {
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
countNote: "缺少真实用户分布时回退 match_totals 参与人结果",
|
||||
draw: numberValue(totals.draw_count),
|
||||
lose: numberValue(totals.loser_count),
|
||||
win: numberValue(totals.winner_count)
|
||||
};
|
||||
}
|
||||
|
||||
function isRealUserParticipant(value) {
|
||||
return value === "user" || value === "human";
|
||||
}
|
||||
|
||||
function latestPoolBalance(items) {
|
||||
if (!items.length) {
|
||||
return 0;
|
||||
}
|
||||
return items.reduce((balance, item) => {
|
||||
const next = numberValue(item.balance_after_coin);
|
||||
return next !== 0 ? next : balance;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function directionLabel(value) {
|
||||
if (value === "in") {
|
||||
return "入池";
|
||||
}
|
||||
if (value === "out") {
|
||||
return "出池";
|
||||
}
|
||||
return value || "--";
|
||||
}
|
||||
|
||||
function participantLabel(value) {
|
||||
if (value === "robot") {
|
||||
return "机器人";
|
||||
}
|
||||
if (isRealUserParticipant(value)) {
|
||||
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 "--";
|
||||
}
|
||||
@ -106,7 +106,7 @@ export function createDashboardModel(overview, { appCode, countryId, range, time
|
||||
],
|
||||
sideMetrics: [
|
||||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "arpu_delta_rate", "arpuDeltaRate")), deltaTone: deltaTone(source, "arpu_delta_rate", "arpuDeltaRate"), label: "ARPU", value: formatMoney(arpu) },
|
||||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "payer_rate_delta_pp", "payerRateDeltaPp"), "pp"), label: "付费转化率", value: formatPercent(ratio(paidUsers, newUsers)) },
|
||||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "payer_rate_delta_pp", "payerRateDeltaPp"), "pp"), label: "付费转化率", value: formatPercent(ratio(paidUsers, activeUsers)) },
|
||||
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "arppu_delta_rate", "arppuDeltaRate")), label: "ARPPU", value: formatMoney(arppu) }
|
||||
],
|
||||
topCountries: countryBreakdown.slice(0, 4),
|
||||
@ -223,8 +223,11 @@ function normalizeCountryRow(item, index, totalRecharge) {
|
||||
const country = stripCountryFlagPrefix(item.country || item.country_name || code || (countryId > 0 ? `国家 ${countryId || index + 1}` : "未知国家"));
|
||||
const meta = resolveCountryMeta({ code, name: country });
|
||||
const recharge = effectiveRechargeUSDMinor(item);
|
||||
const activeUsers = numberValue(item.active_users);
|
||||
const paidUsers = numberValue(item.paid_users);
|
||||
const rechargeUsers = numberValue(item.recharge_users ?? item.rechargeUsers ?? item.paid_users ?? item.paidUsers);
|
||||
return {
|
||||
active_users: numberValue(item.active_users),
|
||||
active_users: activeUsers,
|
||||
arpu_usd_minor: numberValue(item.arpu_usd_minor ?? item.arpuUsdMinor),
|
||||
arppu_usd_minor: numberValue(item.arppu_usd_minor),
|
||||
avg_recharge_usd_minor: numberValue(item.avg_recharge_usd_minor ?? item.avgRechargeUsdMinor),
|
||||
@ -234,11 +237,13 @@ function normalizeCountryRow(item, index, totalRecharge) {
|
||||
flag: countryFlag(code || meta?.code),
|
||||
geo_point: Array.isArray(item.geo_point) ? item.geo_point : meta?.point,
|
||||
new_user_recharge_usd_minor: numberValue(item.new_user_recharge_usd_minor ?? item.newUserRechargeUsdMinor),
|
||||
paid_users: numberValue(item.paid_users),
|
||||
payer_rate: ratio(item.paid_users, item.active_users),
|
||||
paid_users: paidUsers,
|
||||
payer_rate: ratio(paidUsers, activeUsers),
|
||||
recharge_usd_minor: recharge,
|
||||
recharge_conversion_rate: numberValue(item.recharge_conversion_rate ?? item.rechargeConversionRate),
|
||||
recharge_users: numberValue(item.recharge_users ?? item.rechargeUsers ?? item.paid_users ?? item.paidUsers),
|
||||
recharge_conversion_rate: item.recharge_conversion_rate !== undefined || item.rechargeConversionRate !== undefined
|
||||
? numberValue(item.recharge_conversion_rate ?? item.rechargeConversionRate)
|
||||
: ratio(rechargeUsers, activeUsers),
|
||||
recharge_users: rechargeUsers,
|
||||
share: recharge / totalRecharge,
|
||||
trend_rate: numberValue(item.trend_rate ?? item.trendRate),
|
||||
visitors: numberValue(item.visitors ?? item.new_users ?? item.newUsers)
|
||||
|
||||
@ -67,6 +67,30 @@ test("exposes new user KPI and removes visitor stage from funnel", () => {
|
||||
expect(model.funnel.map((item) => item.name)).toEqual(["活跃用户", "付费用户", "充值用户"]);
|
||||
});
|
||||
|
||||
test("uses active users as payer and recharge conversion denominator", () => {
|
||||
const model = createDashboardModel({
|
||||
active_users: 80,
|
||||
country_breakdown: [
|
||||
{
|
||||
active_users: 20,
|
||||
country: "巴西",
|
||||
paid_users: 8,
|
||||
recharge_users: 6,
|
||||
recharge_usd_minor: 500
|
||||
}
|
||||
],
|
||||
new_users: 4,
|
||||
paid_users: 8,
|
||||
recharge_users: 6
|
||||
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } });
|
||||
|
||||
expect(model.sideMetrics.find((item) => item.label === "付费转化率")?.value).toBe("10.00%");
|
||||
expect(model.countryBreakdown[0]).toEqual(expect.objectContaining({
|
||||
payer_rate: 0.4,
|
||||
recharge_conversion_rate: 0.3
|
||||
}));
|
||||
});
|
||||
|
||||
function metricValue(model, label) {
|
||||
return [...model.kpis, ...model.businessKpis].find((item) => item.label === label)?.value;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -93,13 +131,14 @@
|
||||
}
|
||||
|
||||
.filter-trigger {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-width: 138px;
|
||||
height: 44px;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 12px;
|
||||
height: 34px;
|
||||
grid-template-columns: minmax(0, 1fr) 18px;
|
||||
align-items: end;
|
||||
gap: 0;
|
||||
padding: 7px 28px 3px 12px;
|
||||
border: 1px solid rgba(64, 148, 197, 0.48);
|
||||
border-radius: 8px;
|
||||
background: rgba(7, 29, 48, 0.9);
|
||||
@ -129,6 +168,13 @@
|
||||
box-shadow: 0 0 0 3px rgba(39, 228, 245, 0.08);
|
||||
}
|
||||
|
||||
.filter-trigger:hover .filter-label,
|
||||
.filter-trigger[aria-expanded="true"] .filter-label,
|
||||
.date-range-trigger:hover .date-range-label,
|
||||
.date-range-trigger[aria-expanded="true"] .date-range-label {
|
||||
color: #27e4f5;
|
||||
}
|
||||
|
||||
.filter-trigger:disabled {
|
||||
cursor: progress;
|
||||
opacity: 0.68;
|
||||
@ -139,10 +185,24 @@
|
||||
color: #cbd9e9;
|
||||
}
|
||||
|
||||
.filter-icon {
|
||||
display: grid;
|
||||
min-width: 20px;
|
||||
place-items: center;
|
||||
.filter-label {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 10px;
|
||||
display: inline-flex;
|
||||
max-width: calc(100% - 20px);
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
overflow: hidden;
|
||||
padding: 0 5px;
|
||||
border-radius: 5px;
|
||||
background: rgba(2, 15, 31, 0.98);
|
||||
color: #7f9bb7;
|
||||
font-size: 10px;
|
||||
font-weight: 760;
|
||||
line-height: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.control-glyph {
|
||||
@ -152,38 +212,46 @@
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.filter-copy {
|
||||
display: grid;
|
||||
.filter-icon {
|
||||
display: inline-grid;
|
||||
flex: 0 0 auto;
|
||||
min-width: 16px;
|
||||
place-items: center;
|
||||
color: #cbd9e9;
|
||||
}
|
||||
|
||||
.filter-label .filter-icon {
|
||||
color: #27e4f5;
|
||||
}
|
||||
|
||||
.filter-value {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-copy small {
|
||||
overflow: hidden;
|
||||
color: #7f9bb7;
|
||||
font-size: 10px;
|
||||
line-height: 1.1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-copy b {
|
||||
overflow: hidden;
|
||||
color: #e6f3ff;
|
||||
font-size: 13px;
|
||||
font-weight: 720;
|
||||
line-height: 1.2;
|
||||
line-height: 18px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-chevron {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 8px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.date-range-trigger {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 336px;
|
||||
height: 44px;
|
||||
grid-template-columns: 24px minmax(0, 1fr) 20px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 12px;
|
||||
width: 318px;
|
||||
height: 34px;
|
||||
grid-template-columns: minmax(0, 1fr) 18px;
|
||||
align-items: end;
|
||||
gap: 0;
|
||||
padding: 7px 28px 3px 12px;
|
||||
border: 1px solid rgba(64, 148, 197, 0.48);
|
||||
border-radius: 8px;
|
||||
background: rgba(7, 29, 48, 0.9);
|
||||
@ -200,49 +268,46 @@
|
||||
box-shadow: 0 0 0 3px rgba(39, 228, 245, 0.08);
|
||||
}
|
||||
|
||||
.date-range-icon {
|
||||
display: grid;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
place-items: center;
|
||||
color: #cbd9e9;
|
||||
}
|
||||
|
||||
.date-range-icon svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
|
||||
.date-range-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.date-range-label {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 10px;
|
||||
display: inline-flex;
|
||||
max-width: calc(100% - 20px);
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
overflow: hidden;
|
||||
padding: 0 5px;
|
||||
border-radius: 5px;
|
||||
background: rgba(2, 15, 31, 0.98);
|
||||
color: #7f9bb7;
|
||||
font-size: 10px;
|
||||
font-weight: 720;
|
||||
line-height: 1.05;
|
||||
font-weight: 760;
|
||||
line-height: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.date-range-label svg {
|
||||
flex: 0 0 auto;
|
||||
color: #cbd9e9;
|
||||
}
|
||||
|
||||
.date-range-values {
|
||||
display: grid;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(0, auto) 16px minmax(0, auto);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 7px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.date-range-values b {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #eaf6ff;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 760;
|
||||
line-height: 1.15;
|
||||
line-height: 18px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@ -255,8 +320,11 @@
|
||||
}
|
||||
|
||||
.date-range-chevron {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 8px;
|
||||
color: #cbd9e9;
|
||||
justify-self: end;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.filter-popover {
|
||||
@ -650,6 +718,15 @@
|
||||
box-shadow: 0 0 14px rgba(247, 163, 58, 0.72);
|
||||
}
|
||||
|
||||
.header-error {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
color: #f7a33a;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
@ -785,3 +862,73 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.self-game-screen {
|
||||
display: grid;
|
||||
grid-template-rows: 44px auto repeat(3, 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-kpi-grid .metric-card {
|
||||
grid-column: span 1;
|
||||
height: 118px;
|
||||
}
|
||||
|
||||
.self-game-kpi-grid .metric-content {
|
||||
grid-template-rows: auto minmax(0, 1fr) auto 18px;
|
||||
}
|
||||
|
||||
.self-game-kpi-grid .metric-content strong {
|
||||
font-size: clamp(16px, 0.98vw, 21px);
|
||||
}
|
||||
|
||||
.self-game-panel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.self-game-panel-grid--single {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.self-game-panel-grid .databi-panel {
|
||||
min-height: 236px;
|
||||
}
|
||||
|
||||
.self-game-panel-grid--single .databi-panel {
|
||||
min-height: 190px;
|
||||
}
|
||||
|
||||
@ -133,3 +133,121 @@
|
||||
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--wide {
|
||||
min-width: 980px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.risk-user-cell {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: 30px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.risk-user-avatar {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(39, 228, 245, 0.28);
|
||||
border-radius: 50%;
|
||||
background: rgba(39, 228, 245, 0.12);
|
||||
color: #e9f6ff;
|
||||
font-size: 12px;
|
||||
font-weight: 760;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.risk-user-avatar--empty {
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.risk-user-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.risk-user-copy strong,
|
||||
.risk-user-copy small {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.risk-user-copy strong {
|
||||
color: #e9f6ff;
|
||||
font-size: 12px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.risk-user-copy small {
|
||||
color: #88a7c2;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@ -155,6 +155,7 @@ export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefr
|
||||
<TextField
|
||||
className="app-switch"
|
||||
disabled={appScope.loading || !appScope.apps.length}
|
||||
label="应用"
|
||||
select
|
||||
size="small"
|
||||
value={appScope.appCode}
|
||||
@ -172,6 +173,7 @@ export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefr
|
||||
</TextField>
|
||||
<TextField
|
||||
className="timezone-switch"
|
||||
label="时区"
|
||||
select
|
||||
size="small"
|
||||
slotProps={{ htmlInput: { "aria-label": "显示时区" } }}
|
||||
@ -185,8 +187,11 @@ export function Header({ activeLabel, isSidebarCollapsed, menuItems = [], onRefr
|
||||
))}
|
||||
</TextField>
|
||||
<button className="search-trigger" type="button" onClick={openSearch}>
|
||||
<Search fontSize="small" />
|
||||
<span>搜索菜单、用户、角色...</span>
|
||||
<span className="search-trigger__label">
|
||||
<Search fontSize="inherit" />
|
||||
<span>搜索</span>
|
||||
</span>
|
||||
<span className="search-trigger__value">搜索菜单、用户、角色...</span>
|
||||
</button>
|
||||
<IconButton label="刷新" onClick={refresh}>
|
||||
<Refresh fontSize="small" />
|
||||
|
||||
@ -211,6 +211,46 @@
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.coinButton {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
min-height: 28px;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: transparent;
|
||||
text-underline-offset: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.coinButton:hover,
|
||||
.coinButton:focus-visible {
|
||||
color: var(--primary);
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
|
||||
.coinButton:focus-visible {
|
||||
border-radius: var(--radius-sm);
|
||||
outline: 2px solid var(--primary-border-strong);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.coinLedgerDrawer.coinLedgerDrawer {
|
||||
width: min(960px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.coinLedgerDrawerBody.coinLedgerDrawerBody {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
|
||||
@ -165,6 +165,11 @@ export function useAppUsersPage() {
|
||||
setActiveAction("country");
|
||||
};
|
||||
|
||||
const openCoinLedger = (user) => {
|
||||
setActiveUser(user);
|
||||
setActiveAction("coin-ledger");
|
||||
};
|
||||
|
||||
const closeAction = () => {
|
||||
setActiveAction("");
|
||||
setActiveUser(null);
|
||||
@ -310,6 +315,7 @@ export function useAppUsersPage() {
|
||||
locationFilterOptions,
|
||||
locationFilterValue,
|
||||
openCountry,
|
||||
openCoinLedger,
|
||||
openEdit,
|
||||
openPassword,
|
||||
page,
|
||||
|
||||
@ -16,12 +16,14 @@ import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { appUserStatusFilters, appUserStatusLabels, genderOptions } from "@/features/app-users/constants.js";
|
||||
import { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js";
|
||||
import { CoinLedgerTable } from "@/features/operations/components/CoinLedgerTable.jsx";
|
||||
import styles from "@/features/app-users/app-users.module.css";
|
||||
|
||||
export function AppUserListPage() {
|
||||
@ -115,7 +117,7 @@ export function AppUserListPage() {
|
||||
),
|
||||
label: "金币",
|
||||
width: "minmax(90px, 0.6fr)",
|
||||
render: (user) => formatNumber(user.coin),
|
||||
render: (user) => <CoinValue page={page} user={user} />,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
@ -280,10 +282,40 @@ export function AppUserListPage() {
|
||||
onChange={(event) => page.setPasswordForm({ ...page.passwordForm, password: event.target.value })}
|
||||
/>
|
||||
</ActionModal>
|
||||
|
||||
<SideDrawer
|
||||
className={styles.coinLedgerDrawer}
|
||||
contentClassName={styles.coinLedgerDrawerBody}
|
||||
open={page.activeAction === "coin-ledger"}
|
||||
title={`金币流水 - ${userLedgerTitle(page.activeUser)}`}
|
||||
width="wide"
|
||||
onClose={page.closeAction}
|
||||
>
|
||||
{page.activeUser ? (
|
||||
<CoinLedgerTable lockedUser={page.activeUser} userId={page.activeUser.userId} />
|
||||
) : null}
|
||||
</SideDrawer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CoinValue({ page, user }) {
|
||||
const value = formatNumber(user.coin);
|
||||
if (!page.abilities.canCoinLedger) {
|
||||
return value;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
aria-label={`查看 ${userLedgerTitle(user)} 的金币流水`}
|
||||
className={styles.coinButton}
|
||||
type="button"
|
||||
onClick={() => page.openCoinLedger(user)}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function UserLocation({ page, user }) {
|
||||
const countryLabel = formatCountry(user);
|
||||
const regionLabel = user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-");
|
||||
@ -392,6 +424,13 @@ function formatNumber(value) {
|
||||
return Number(value || 0).toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
function userLedgerTitle(user) {
|
||||
if (!user) {
|
||||
return "用户";
|
||||
}
|
||||
return user.username || user.displayUserId || user.prettyDisplayUserId || user.userId || "用户";
|
||||
}
|
||||
|
||||
function formatCountry(user) {
|
||||
return user.countryDisplayName || user.countryName || user.country || "-";
|
||||
}
|
||||
|
||||
125
src/features/app-users/pages/AppUserListPage.test.jsx
Normal file
125
src/features/app-users/pages/AppUserListPage.test.jsx
Normal file
@ -0,0 +1,125 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { AppUserListPage } from "./AppUserListPage.jsx";
|
||||
import { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
ledgerTable: vi.fn(),
|
||||
openCoinLedger: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/app-users/hooks/useAppUsersPage.js", () => ({
|
||||
useAppUsersPage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/operations/components/CoinLedgerTable.jsx", () => ({
|
||||
CoinLedgerTable: (props) => {
|
||||
mocks.ledgerTable(props);
|
||||
return <div data-testid="coin-ledger-table">{props.userId}</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("clicking an app user's coin opens the coin ledger drawer", () => {
|
||||
vi.mocked(useAppUsersPage).mockReturnValue(pageFixture());
|
||||
|
||||
render(<AppUserListPage />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "查看 d 的金币流水" }));
|
||||
|
||||
expect(mocks.openCoinLedger).toHaveBeenCalledWith(expect.objectContaining({ userId: "10001" }));
|
||||
});
|
||||
|
||||
test("coin ledger drawer passes the active app user to the shared ledger table", () => {
|
||||
const activeUser = userFixture();
|
||||
vi.mocked(useAppUsersPage).mockReturnValue(
|
||||
pageFixture({
|
||||
activeAction: "coin-ledger",
|
||||
activeUser,
|
||||
}),
|
||||
);
|
||||
|
||||
render(<AppUserListPage />);
|
||||
|
||||
expect(screen.getByTestId("coin-ledger-table")).toHaveTextContent("10001");
|
||||
expect(mocks.ledgerTable).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ lockedUser: activeUser, userId: "10001" }),
|
||||
);
|
||||
});
|
||||
|
||||
function pageFixture(patch = {}) {
|
||||
return {
|
||||
abilities: {
|
||||
canCoinLedger: true,
|
||||
canPassword: true,
|
||||
canStatus: true,
|
||||
canUpdate: true,
|
||||
canUpload: true,
|
||||
canView: true,
|
||||
},
|
||||
activeAction: "",
|
||||
activeUser: null,
|
||||
batchBan: vi.fn(),
|
||||
changeLocationFilter: vi.fn(),
|
||||
changeQuery: vi.fn(),
|
||||
changeSort: vi.fn(),
|
||||
changeStatus: vi.fn(),
|
||||
changeTimeRange: vi.fn(),
|
||||
closeAction: vi.fn(),
|
||||
countryForm: { country: "" },
|
||||
countryOptions: [],
|
||||
data: { items: [userFixture()], page: 1, pageSize: 50, total: 1 },
|
||||
editForm: { avatar: "", gender: "", username: "" },
|
||||
error: null,
|
||||
loading: false,
|
||||
loadingAction: "",
|
||||
loadingCountries: false,
|
||||
loadingRegions: false,
|
||||
locationFilterOptions: [],
|
||||
locationFilterValue: "",
|
||||
openCoinLedger: mocks.openCoinLedger,
|
||||
openCountry: vi.fn(),
|
||||
openEdit: vi.fn(),
|
||||
openPassword: vi.fn(),
|
||||
page: 1,
|
||||
passwordForm: { password: "" },
|
||||
query: "",
|
||||
regionOptions: [],
|
||||
reload: vi.fn(),
|
||||
selectedUserIds: [],
|
||||
setCountryForm: vi.fn(),
|
||||
setEditForm: vi.fn(),
|
||||
setPage: vi.fn(),
|
||||
setPasswordForm: vi.fn(),
|
||||
setSelectedUserIds: vi.fn(),
|
||||
sortBy: "created_at",
|
||||
sortDirection: "desc",
|
||||
status: "",
|
||||
submitCountry: vi.fn(),
|
||||
submitEdit: vi.fn(),
|
||||
submitPassword: vi.fn(),
|
||||
timeRange: { endMs: "", startMs: "" },
|
||||
toggleBan: vi.fn(),
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function userFixture() {
|
||||
return {
|
||||
avatar: "",
|
||||
coin: 223437,
|
||||
country: "AE",
|
||||
countryDisplayName: "阿拉伯联合酋长国",
|
||||
createdAtMs: 1779322523000,
|
||||
displayUserId: "163337",
|
||||
lastActiveAtMs: 1781325798000,
|
||||
regionId: 2,
|
||||
regionName: "中东区",
|
||||
status: "active",
|
||||
userId: "10001",
|
||||
username: "d",
|
||||
};
|
||||
}
|
||||
@ -558,7 +558,7 @@ function GrantDialog({ page }) {
|
||||
>
|
||||
<AdminFormSection title="发放信息">
|
||||
<TextField
|
||||
label="用户 ID"
|
||||
label="用户 ID / 短 ID"
|
||||
required
|
||||
value={page.grantForm.targetUserId}
|
||||
onChange={(event) => page.setGrantForm({ ...page.grantForm, targetUserId: event.target.value })}
|
||||
|
||||
@ -10,6 +10,7 @@ export function useAppUserAbilities() {
|
||||
canPrettyGrant: can(PERMISSIONS.prettyIdGrant),
|
||||
canPrettyUpdate: can(PERMISSIONS.prettyIdUpdate),
|
||||
canPrettyView: can(PERMISSIONS.prettyIdView),
|
||||
canCoinLedger: can(PERMISSIONS.coinLedgerView),
|
||||
canStatus: can(PERMISSIONS.appUserStatus),
|
||||
canUpdate: can(PERMISSIONS.appUserUpdate),
|
||||
canUpload: can(PERMISSIONS.uploadCreate),
|
||||
|
||||
@ -98,10 +98,7 @@ export const prettyDisplayIDGrantSchema = z.object({
|
||||
return Number.isInteger(days) && days >= 0 && days <= 36500;
|
||||
}, "有效天数必须是 0 到 36500 的整数"),
|
||||
reason: z.string().trim().max(64, "原因不能超过 64 个字符").optional().default(""),
|
||||
targetUserId: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[1-9]\d*$/, "请输入用户 ID"),
|
||||
targetUserId: z.string().trim().min(1, "请输入用户 ID 或短 ID").max(64, "用户 ID 或短 ID 不能超过 64 个字符"),
|
||||
});
|
||||
|
||||
// 状态变更只暴露未占用号码的 available/disabled/reserved,assigned 号码必须通过释放或覆盖链路改变。
|
||||
|
||||
@ -37,7 +37,7 @@ export interface CumulativeRechargeRewardConfigPayload {
|
||||
}
|
||||
|
||||
export interface CumulativeRechargeRewardGrantUserDto {
|
||||
userId?: number;
|
||||
userId?: string;
|
||||
displayUserId?: string;
|
||||
prettyDisplayUserId?: string;
|
||||
prettyId?: string;
|
||||
@ -52,7 +52,7 @@ export interface CumulativeRechargeRewardGrantDto {
|
||||
eventId?: string;
|
||||
transactionId?: string;
|
||||
commandId?: string;
|
||||
userId: number;
|
||||
userId: string;
|
||||
user?: CumulativeRechargeRewardGrantUserDto;
|
||||
tierId?: number;
|
||||
tierCode?: string;
|
||||
@ -143,9 +143,9 @@ function normalizeGrant(item: RawGrant): CumulativeRechargeRewardGrantDto {
|
||||
eventId: stringValue(item.eventId ?? item.event_id),
|
||||
transactionId: stringValue(item.transactionId ?? item.transaction_id),
|
||||
commandId: stringValue(item.commandId ?? item.command_id),
|
||||
userId: numberValue(item.userId ?? item.user_id),
|
||||
userId: stringValue(item.userId ?? item.user_id),
|
||||
user: {
|
||||
userId: numberValue(rawUser.userId ?? rawUser.user_id),
|
||||
userId: stringValue(rawUser.userId ?? rawUser.user_id),
|
||||
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
|
||||
prettyDisplayUserId: stringValue(rawUser.prettyDisplayUserId ?? rawUser.pretty_display_user_id),
|
||||
prettyId: stringValue(rawUser.prettyId ?? rawUser.pretty_id),
|
||||
|
||||
@ -45,7 +45,7 @@ export interface FirstRechargeRewardConfigPayload {
|
||||
}
|
||||
|
||||
export interface FirstRechargeRewardClaimUserDto {
|
||||
userId?: number;
|
||||
userId?: string;
|
||||
displayUserId?: string;
|
||||
prettyDisplayUserId?: string;
|
||||
prettyId?: string;
|
||||
@ -58,7 +58,7 @@ export interface FirstRechargeRewardClaimDto {
|
||||
eventId?: string;
|
||||
transactionId?: string;
|
||||
commandId?: string;
|
||||
userId: number;
|
||||
userId: string;
|
||||
user?: FirstRechargeRewardClaimUserDto;
|
||||
tierId?: number;
|
||||
tierCode?: string;
|
||||
@ -151,9 +151,9 @@ function normalizeClaim(item: RawClaim): FirstRechargeRewardClaimDto {
|
||||
eventId: stringValue(item.eventId ?? item.event_id),
|
||||
transactionId: stringValue(item.transactionId ?? item.transaction_id),
|
||||
commandId: stringValue(item.commandId ?? item.command_id),
|
||||
userId: numberValue(item.userId ?? item.user_id),
|
||||
userId: stringValue(item.userId ?? item.user_id),
|
||||
user: {
|
||||
userId: numberValue(rawUser.userId ?? rawUser.user_id),
|
||||
userId: stringValue(rawUser.userId ?? rawUser.user_id),
|
||||
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
|
||||
prettyDisplayUserId: stringValue(rawUser.prettyDisplayUserId ?? rawUser.pretty_display_user_id),
|
||||
prettyId: stringValue(rawUser.prettyId ?? rawUser.pretty_id),
|
||||
|
||||
@ -96,6 +96,46 @@ export interface DiceConfigDto {
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface SelfGameNewUserPolicyDto {
|
||||
appCode?: string;
|
||||
gameId: string;
|
||||
enabled: boolean;
|
||||
protectionRounds: number;
|
||||
protectionHours: number;
|
||||
maxStakeCoin: number;
|
||||
lifetimeSubsidyQuotaCoin: number;
|
||||
day1SubsidyQuotaCoin: number;
|
||||
singleRoundSubsidyCapCoin: number;
|
||||
strategyLevel: string;
|
||||
loseStreakProtectionEnabled: boolean;
|
||||
loseStreakTrigger: number;
|
||||
normalPhaseTargetWinRatePercent: number;
|
||||
blackPoolRobotForceWinEnabled: boolean;
|
||||
createdAtMs?: number;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface SelfGameStakePoolDto {
|
||||
appCode?: string;
|
||||
gameId: string;
|
||||
stakeCoin: number;
|
||||
balanceCoin: number;
|
||||
reservedCoin: number;
|
||||
availableCoin: number;
|
||||
targetBalanceCoin: number;
|
||||
safeBalanceCoin: number;
|
||||
warningBalanceCoin: number;
|
||||
dangerBalanceCoin: number;
|
||||
status: string;
|
||||
poolLevel: string;
|
||||
requiredPoolCoin: number;
|
||||
humanWinCapacity: number;
|
||||
botMatchEnabled: boolean;
|
||||
blackReason?: string;
|
||||
createdAtMs?: number;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface DiceRobotDto {
|
||||
appCode?: string;
|
||||
gameId: string;
|
||||
@ -178,13 +218,14 @@ export interface RoomRpsChallengePage {
|
||||
}
|
||||
|
||||
export type DiceConfigPayload = Omit<DiceConfigDto, "appCode" | "createdAtMs" | "updatedAtMs" | "poolBalanceCoin">;
|
||||
export type SelfGameNewUserPolicyPayload = Omit<SelfGameNewUserPolicyDto, "appCode" | "createdAtMs" | "updatedAtMs">;
|
||||
|
||||
export interface RoomRpsConfigPayload {
|
||||
status: string;
|
||||
challengeTimeoutMs: number;
|
||||
revealCountdownMs: number;
|
||||
stakeGifts: Array<{
|
||||
giftId: number;
|
||||
giftId: string;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
@ -196,6 +237,11 @@ export interface DicePoolAdjustPayload {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export type SelfGameStakePoolPayload = Pick<
|
||||
SelfGameStakePoolDto,
|
||||
"targetBalanceCoin" | "safeBalanceCoin" | "warningBalanceCoin" | "dangerBalanceCoin" | "status"
|
||||
>;
|
||||
|
||||
export interface DiceGenerateRobotsPayload {
|
||||
count: number;
|
||||
nicknameLanguage?: "english" | "arabic";
|
||||
@ -328,14 +374,54 @@ export function updateDiceConfig(gameId: string, payload: DiceConfigPayload): Pr
|
||||
).then(normalizeDiceConfig);
|
||||
}
|
||||
|
||||
export function adjustDicePool(
|
||||
export function getSelfGameNewUserPolicy(gameId: string): Promise<SelfGameNewUserPolicyDto> {
|
||||
return apiRequest<SelfGameNewUserPolicyDto>(
|
||||
`/v1/admin/game/self-games/${encodeURIComponent(gameId)}/new-user-policy`,
|
||||
{ method: "GET" },
|
||||
).then(normalizeSelfGameNewUserPolicy);
|
||||
}
|
||||
|
||||
export function updateSelfGameNewUserPolicy(
|
||||
gameId: string,
|
||||
payload: SelfGameNewUserPolicyPayload,
|
||||
): Promise<SelfGameNewUserPolicyDto> {
|
||||
return apiRequest<SelfGameNewUserPolicyDto, SelfGameNewUserPolicyPayload>(
|
||||
`/v1/admin/game/self-games/${encodeURIComponent(gameId)}/new-user-policy`,
|
||||
{ body: payload, method: "PATCH" },
|
||||
).then(normalizeSelfGameNewUserPolicy);
|
||||
}
|
||||
|
||||
export function listSelfGameStakePools(
|
||||
gameId: string,
|
||||
): Promise<{ items: SelfGameStakePoolDto[]; serverTimeMs?: number }> {
|
||||
return apiRequest<{ items: SelfGameStakePoolDto[]; serverTimeMs?: number }>(
|
||||
`/v1/admin/game/self-games/${encodeURIComponent(gameId)}/stake-pools`,
|
||||
{ method: "GET" },
|
||||
).then((page) => ({
|
||||
...page,
|
||||
items: (page.items || []).map(normalizeSelfGameStakePool),
|
||||
}));
|
||||
}
|
||||
|
||||
export function updateSelfGameStakePool(
|
||||
gameId: string,
|
||||
stakeCoin: number,
|
||||
payload: SelfGameStakePoolPayload,
|
||||
): Promise<SelfGameStakePoolDto> {
|
||||
return apiRequest<SelfGameStakePoolDto, SelfGameStakePoolPayload>(
|
||||
`/v1/admin/game/self-games/${encodeURIComponent(gameId)}/stake-pools/${encodeURIComponent(String(stakeCoin))}`,
|
||||
{ body: payload, method: "PATCH" },
|
||||
).then(normalizeSelfGameStakePool);
|
||||
}
|
||||
|
||||
export function adjustSelfGameStakePool(
|
||||
gameId: string,
|
||||
stakeCoin: number,
|
||||
payload: DicePoolAdjustPayload,
|
||||
): Promise<{ config: DiceConfigDto; adjustment: unknown }> {
|
||||
const endpoint = API_ENDPOINTS.adjustDicePool;
|
||||
return apiRequest<{ config: DiceConfigDto; adjustment: unknown }, DicePoolAdjustPayload>(
|
||||
apiEndpointPath(API_OPERATIONS.adjustDicePool, { game_id: gameId }),
|
||||
{ body: payload, method: endpoint.method },
|
||||
`/v1/admin/game/self-games/${encodeURIComponent(gameId)}/stake-pools/${encodeURIComponent(String(stakeCoin))}/adjust`,
|
||||
{ body: payload, method: "POST" },
|
||||
).then((result) => ({ ...result, config: normalizeDiceConfig(result.config || {}) }));
|
||||
}
|
||||
|
||||
@ -358,19 +444,19 @@ export function generateDiceRobots(payload: DiceGenerateRobotsPayload) {
|
||||
).then((result) => ({ ...result, items: (result.items || []).map(normalizeDiceRobot) }));
|
||||
}
|
||||
|
||||
export function setDiceRobotStatus(userId: string, status: string, gameId = "dice") {
|
||||
export function setDiceRobotStatus(userId: string, status: string, gameId = "") {
|
||||
const endpoint = API_ENDPOINTS.setDiceRobotStatus;
|
||||
return apiRequest<DiceRobotDto, { status: string }>(
|
||||
apiEndpointPath(API_OPERATIONS.setDiceRobotStatus, { user_id: userId }),
|
||||
{ body: { status }, method: endpoint.method, query: { gameId } },
|
||||
{ body: { status }, method: endpoint.method, query: gameId ? { gameId } : undefined },
|
||||
).then(normalizeDiceRobot);
|
||||
}
|
||||
|
||||
export function deleteDiceRobot(userId: string, gameId = "dice"): Promise<{ deleted: boolean }> {
|
||||
export function deleteDiceRobot(userId: string, gameId = ""): Promise<{ deleted: boolean }> {
|
||||
const endpoint = API_ENDPOINTS.deleteDiceRobot;
|
||||
return apiRequest<{ deleted: boolean }>(apiEndpointPath(API_OPERATIONS.deleteDiceRobot, { user_id: userId }), {
|
||||
method: endpoint.method,
|
||||
query: { gameId },
|
||||
query: gameId ? { gameId } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@ -500,6 +586,50 @@ function normalizeDiceConfig(config: Partial<DiceConfigDto>): DiceConfigDto {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSelfGameNewUserPolicy(policy: Partial<SelfGameNewUserPolicyDto>): SelfGameNewUserPolicyDto {
|
||||
return {
|
||||
appCode: stringValue(policy.appCode),
|
||||
gameId: stringValue(policy.gameId || "dice"),
|
||||
enabled: policy.enabled !== false,
|
||||
protectionRounds: numberValue(policy.protectionRounds || 30),
|
||||
protectionHours: numberValue(policy.protectionHours || 168),
|
||||
maxStakeCoin: numberValue(policy.maxStakeCoin || 5000),
|
||||
lifetimeSubsidyQuotaCoin: numberValue(policy.lifetimeSubsidyQuotaCoin || 30000),
|
||||
day1SubsidyQuotaCoin: numberValue(policy.day1SubsidyQuotaCoin || 15000),
|
||||
singleRoundSubsidyCapCoin: numberValue(policy.singleRoundSubsidyCapCoin || 5000),
|
||||
strategyLevel: stringValue(policy.strategyLevel || "soft_boost"),
|
||||
loseStreakProtectionEnabled: policy.loseStreakProtectionEnabled !== false,
|
||||
loseStreakTrigger: numberValue(policy.loseStreakTrigger || 2),
|
||||
normalPhaseTargetWinRatePercent: numberValue(policy.normalPhaseTargetWinRatePercent || 53),
|
||||
blackPoolRobotForceWinEnabled: policy.blackPoolRobotForceWinEnabled === true,
|
||||
createdAtMs: numberValue(policy.createdAtMs),
|
||||
updatedAtMs: numberValue(policy.updatedAtMs),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSelfGameStakePool(pool: Partial<SelfGameStakePoolDto>): SelfGameStakePoolDto {
|
||||
return {
|
||||
appCode: stringValue(pool.appCode),
|
||||
gameId: stringValue(pool.gameId || "dice"),
|
||||
stakeCoin: numberValue(pool.stakeCoin),
|
||||
balanceCoin: numberValue(pool.balanceCoin),
|
||||
reservedCoin: numberValue(pool.reservedCoin),
|
||||
availableCoin: numberValue(pool.availableCoin),
|
||||
targetBalanceCoin: numberValue(pool.targetBalanceCoin),
|
||||
safeBalanceCoin: numberValue(pool.safeBalanceCoin),
|
||||
warningBalanceCoin: numberValue(pool.warningBalanceCoin),
|
||||
dangerBalanceCoin: numberValue(pool.dangerBalanceCoin),
|
||||
status: stringValue(pool.status || "active"),
|
||||
poolLevel: stringValue(pool.poolLevel || "black"),
|
||||
requiredPoolCoin: numberValue(pool.requiredPoolCoin),
|
||||
humanWinCapacity: numberValue(pool.humanWinCapacity),
|
||||
botMatchEnabled: pool.botMatchEnabled === true,
|
||||
blackReason: stringValue(pool.blackReason),
|
||||
createdAtMs: numberValue(pool.createdAtMs),
|
||||
updatedAtMs: numberValue(pool.updatedAtMs),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDiceRobot(robot: Partial<DiceRobotDto>): DiceRobotDto {
|
||||
return {
|
||||
appCode: stringValue(robot.appCode),
|
||||
|
||||
@ -65,26 +65,221 @@
|
||||
}
|
||||
|
||||
.giftTierList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.giftTierRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) minmax(110px, 0.5fr) minmax(96px, auto);
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card-strong);
|
||||
}
|
||||
|
||||
.giftTierGift {
|
||||
min-height: 78px;
|
||||
padding: 8px;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.giftTierGift:hover {
|
||||
background: var(--bg-card);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.giftTierControls {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 58px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.giftTierSort {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.giftTierSort :global(.MuiOutlinedInput-root) {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.giftTierSwitch {
|
||||
min-height: 40px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fullField {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.strategyLevelLabel {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.strategyLevelHelpIcon {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.strategyLevelTooltipPaper {
|
||||
max-width: min(560px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.strategyLevelTooltip {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: inherit;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.strategyLevelTooltipTitle {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.strategyLevelTooltipList {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.stakePoolList {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stakePoolRow {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.stakePoolRow:hover,
|
||||
.stakePoolRowActive {
|
||||
border-color: var(--primary-border);
|
||||
background: var(--primary-surface);
|
||||
}
|
||||
|
||||
.stakePoolMain {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stakePoolStake {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stakePoolMetrics {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.stakePoolWarning {
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.stakePoolEmpty {
|
||||
padding: 16px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stakePoolHint {
|
||||
margin-top: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stakePoolInlineActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.stakePoolPrimaryButton {
|
||||
min-height: var(--control-height);
|
||||
padding: 0 18px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stakePoolPrimaryButton:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.poolLevelBadge {
|
||||
display: inline-flex;
|
||||
min-width: 42px;
|
||||
justify-content: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.poolLevel_green {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.poolLevel_yellow {
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.poolLevel_red {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.poolLevel_black,
|
||||
.poolLevel_unknown {
|
||||
background: rgba(15, 23, 42, 0.12);
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.giftTierRow {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.giftTierControls {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.giftTierSort {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.platformList {
|
||||
|
||||
@ -30,6 +30,7 @@ const defaultGenerateForm = {
|
||||
count: 20,
|
||||
nicknameLanguage: "arabic",
|
||||
};
|
||||
const maxGenerateRobotCount = 1000;
|
||||
|
||||
export function GameRobotPage() {
|
||||
const { can } = useAuth();
|
||||
@ -50,7 +51,7 @@ export function GameRobotPage() {
|
||||
async (cursor = "", append = false) => {
|
||||
setLoading(!append);
|
||||
try {
|
||||
const result = await listDiceRobots({ gameId: "dice", status, cursor, pageSize: 50 });
|
||||
const result = await listDiceRobots({ status, cursor, pageSize: 50 });
|
||||
setItems((current) => (append ? [...current, ...(result.items || [])] : result.items || []));
|
||||
setNextCursor(result.nextCursor || "");
|
||||
} catch (err) {
|
||||
@ -70,7 +71,7 @@ export function GameRobotPage() {
|
||||
async (robot, checked) => {
|
||||
setLoadingAction(`status-${robot.userId}`);
|
||||
try {
|
||||
await setDiceRobotStatus(robot.userId, checked ? "active" : "disabled", robot.gameId);
|
||||
await setDiceRobotStatus(robot.userId, checked ? "active" : "disabled");
|
||||
await load("", false);
|
||||
showToast("机器人状态已更新", "success");
|
||||
} catch (err) {
|
||||
@ -95,7 +96,7 @@ export function GameRobotPage() {
|
||||
}
|
||||
setLoadingAction(`delete-${robot.userId}`);
|
||||
try {
|
||||
await deleteDiceRobot(robot.userId, robot.gameId || "dice");
|
||||
await deleteDiceRobot(robot.userId);
|
||||
await load("", false);
|
||||
showToast("全站机器人已删除", "success");
|
||||
} catch (err) {
|
||||
@ -265,21 +266,21 @@ function GenerateDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
||||
>
|
||||
<AdminFormFieldGrid>
|
||||
<TextField
|
||||
helperText="最多 200 个"
|
||||
helperText={`最多 ${maxGenerateRobotCount} 个`}
|
||||
label="数量"
|
||||
type="number"
|
||||
value={form.count}
|
||||
slotProps={{ htmlInput: { max: 200, min: 1 } }}
|
||||
slotProps={{ htmlInput: { max: maxGenerateRobotCount, min: 1 } }}
|
||||
onChange={(event) => setForm({ ...form, count: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="昵称语言"
|
||||
label="账号语言"
|
||||
select
|
||||
value={form.nicknameLanguage || "arabic"}
|
||||
onChange={(event) => setForm({ ...form, nicknameLanguage: event.target.value })}
|
||||
>
|
||||
<MenuItem value="arabic">阿拉伯语名称</MenuItem>
|
||||
<MenuItem value="english">英语名称</MenuItem>
|
||||
<MenuItem value="arabic">阿拉伯语账号</MenuItem>
|
||||
<MenuItem value="english">英语账号</MenuItem>
|
||||
</TextField>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormDialog>
|
||||
|
||||
@ -33,7 +33,7 @@ test("renders game robot page without crashing", async () => {
|
||||
await waitFor(() => expect(screen.getByText("机器人用户")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
test("shows nickname language selector when generating robots", async () => {
|
||||
test("shows account language selector when generating robots", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AppProviders>
|
||||
@ -46,10 +46,10 @@ test("shows nickname language selector when generating robots", async () => {
|
||||
await waitFor(() => expect(screen.getByText("机器人用户")).toBeInTheDocument());
|
||||
await user.click(screen.getByRole("button", { name: "批量创建" }));
|
||||
|
||||
expect(screen.getByLabelText("昵称语言")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("combobox", { name: "昵称语言" }));
|
||||
expect(screen.getByRole("option", { name: "阿拉伯语名称" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "英语名称" })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("账号语言")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("combobox", { name: "账号语言" }));
|
||||
expect(screen.getByRole("option", { name: "阿拉伯语账号" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "英语账号" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
function jsonResponse(body) {
|
||||
|
||||
@ -7,7 +7,7 @@ import { PERMISSIONS } from "@/app/permissions";
|
||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||
import { getRoomRpsConfig, updateRoomRpsConfig } from "@/features/games/api";
|
||||
import { listGifts } from "@/features/resources/api";
|
||||
import { GiftSelectField } from "@/features/resources/components/GiftSelectDrawer.jsx";
|
||||
import { GiftPreviewSelectField } from "@/features/resources/components/GiftSelectDrawer.jsx";
|
||||
import styles from "@/features/games/games.module.css";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
@ -154,11 +154,12 @@ export function RoomRpsConfigPage() {
|
||||
|
||||
const submit = async (event) => {
|
||||
event.preventDefault();
|
||||
const payload = formToPayload(form);
|
||||
if (payload.stakeGifts.length !== 4 || payload.stakeGifts.some((gift) => !gift.giftId)) {
|
||||
showToast("房内猜拳必须配置 4 个礼物档位", "error");
|
||||
const validationError = validateStakeGifts(form.stakeGifts);
|
||||
if (validationError) {
|
||||
showToast(validationError, "error");
|
||||
return;
|
||||
}
|
||||
const payload = formToPayload(form);
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await updateRoomRpsConfig(payload);
|
||||
@ -214,6 +215,7 @@ function RoomRpsConfigDialog({ form, gifts, giftsLoading, loading, open, setForm
|
||||
open={open}
|
||||
submitLabel="保存配置"
|
||||
title="房内猜拳配置"
|
||||
size="large"
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
@ -246,28 +248,40 @@ function RoomRpsConfigDialog({ form, gifts, giftsLoading, loading, open, setForm
|
||||
<div className={styles.giftTierList}>
|
||||
{form.stakeGifts.map((gift, index) => (
|
||||
<div className={styles.giftTierRow} key={gift.sortOrder || index}>
|
||||
<GiftSelectField
|
||||
<GiftPreviewSelectField
|
||||
badge={`${Number(gift.sortOrder || index + 1)} 档`}
|
||||
className={styles.giftTierGift}
|
||||
drawerTitle={`选择第 ${index + 1} 档礼物`}
|
||||
fallbackGift={gift}
|
||||
gifts={gifts}
|
||||
label={`第 ${index + 1} 档礼物`}
|
||||
loading={giftsLoading}
|
||||
placeholder="点击选择礼物"
|
||||
value={gift.giftId}
|
||||
onChange={(value) => setGiftTier(setForm, form, index, { giftId: value })}
|
||||
/>
|
||||
<TextField
|
||||
label="排序"
|
||||
type="number"
|
||||
value={gift.sortOrder}
|
||||
onChange={(event) =>
|
||||
setGiftTier(setForm, form, index, { sortOrder: event.target.value })
|
||||
onChange={(value, selectedGift) =>
|
||||
setGiftTier(setForm, form, index, giftPatchFromSelected(value, selectedGift))
|
||||
}
|
||||
/>
|
||||
<AdminFormSwitchField
|
||||
checked={gift.enabled}
|
||||
label="启用"
|
||||
onChange={(checked) => setGiftTier(setForm, form, index, { enabled: checked })}
|
||||
/>
|
||||
<div className={styles.giftTierControls}>
|
||||
<TextField
|
||||
className={styles.giftTierSort}
|
||||
label="排序"
|
||||
size="small"
|
||||
type="number"
|
||||
value={gift.sortOrder}
|
||||
onChange={(event) =>
|
||||
setGiftTier(setForm, form, index, { sortOrder: event.target.value })
|
||||
}
|
||||
/>
|
||||
<AdminFormSwitchField
|
||||
checked={gift.enabled}
|
||||
checkedLabel="启用"
|
||||
className={styles.giftTierSwitch}
|
||||
label={`第 ${index + 1} 档启用状态`}
|
||||
uncheckedLabel="停用"
|
||||
onChange={(checked) => setGiftTier(setForm, form, index, { enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -288,6 +302,9 @@ function configToForm(config) {
|
||||
.map((gift, index) => ({
|
||||
enabled: gift.enabled !== false,
|
||||
giftId: gift.giftId || "",
|
||||
giftIconUrl: gift.giftIconUrl || "",
|
||||
giftName: gift.giftName || "",
|
||||
giftPriceCoin: Number(gift.giftPriceCoin || 0),
|
||||
sortOrder: gift.sortOrder || index + 1,
|
||||
}));
|
||||
while (stakeGifts.length < 4) {
|
||||
@ -301,6 +318,34 @@ function configToForm(config) {
|
||||
};
|
||||
}
|
||||
|
||||
function giftPatchFromSelected(giftId, gift) {
|
||||
return {
|
||||
giftId: String(giftId || "").trim(),
|
||||
giftIconUrl: gift?.giftIconUrl || gift?.resource?.previewUrl || gift?.resource?.assetUrl || "",
|
||||
giftName: gift?.name || gift?.giftName || gift?.resource?.name || "",
|
||||
giftPriceCoin: Number(gift?.coinPrice ?? gift?.giftPriceCoin ?? gift?.resource?.coinPrice ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function validateStakeGifts(stakeGifts) {
|
||||
if (!Array.isArray(stakeGifts) || stakeGifts.length !== 4) {
|
||||
return "房内猜拳必须配置 4 个礼物档位";
|
||||
}
|
||||
const seen = new Set();
|
||||
for (let index = 0; index < stakeGifts.length; index += 1) {
|
||||
const gift = stakeGifts[index];
|
||||
const giftId = String(gift?.giftId || "").trim();
|
||||
if (!giftId) {
|
||||
return `第 ${index + 1} 档礼物未选择`;
|
||||
}
|
||||
if (seen.has(giftId)) {
|
||||
return `第 ${index + 1} 档礼物重复,请选择不同礼物`;
|
||||
}
|
||||
seen.add(giftId);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function formToPayload(form) {
|
||||
return {
|
||||
status: form.status || "active",
|
||||
@ -308,7 +353,7 @@ function formToPayload(form) {
|
||||
revealCountdownMs: Number(form.revealCountdownMs || 3000),
|
||||
stakeGifts: form.stakeGifts.map((gift, index) => ({
|
||||
enabled: gift.enabled !== false,
|
||||
giftId: Number(gift.giftId || 0),
|
||||
giftId: String(gift.giftId || "").trim(),
|
||||
sortOrder: Number(gift.sortOrder || index + 1),
|
||||
})),
|
||||
};
|
||||
|
||||
@ -38,7 +38,7 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("room rps gift tier uses shared gift drawer selection before saving", async () => {
|
||||
test("room rps gift tier uses gift preview card drawer selection before saving", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
@ -54,19 +54,20 @@ test("room rps gift tier uses shared gift drawer selection before saving", async
|
||||
await user.click(screen.getByRole("button", { name: "编辑" }));
|
||||
expect(await screen.findByRole("dialog", { name: "房内猜拳配置" })).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByDisplayValue("Rock Rose"));
|
||||
await user.click(screen.getByRole("button", { name: /第 1 档礼物 Rock Rose/ }));
|
||||
|
||||
expect(screen.getByRole("dialog", { name: "选择第 1 档礼物" })).toHaveAttribute("data-z-index", "1600");
|
||||
await user.click(screen.getByText("Lucky Star"));
|
||||
expect(screen.getByText("Invalid Alpha Gift")).toBeInTheDocument();
|
||||
await user.click(screen.getByText("Invalid Alpha Gift"));
|
||||
|
||||
expect(screen.getByDisplayValue("Lucky Star")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /第 1 档礼物 Invalid Alpha Gift/ })).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "保存配置" }));
|
||||
|
||||
await waitFor(() => expect(patchBodies).toHaveLength(1));
|
||||
expect(patchBodies[0].stakeGifts[0]).toMatchObject({
|
||||
enabled: true,
|
||||
giftId: 10005,
|
||||
giftId: "Gifi-3",
|
||||
sortOrder: 1,
|
||||
});
|
||||
});
|
||||
@ -95,8 +96,30 @@ async function mockFetch(input, init = {}) {
|
||||
if (url.includes("/v1/admin/gifts")) {
|
||||
return jsonResponse({
|
||||
items: [
|
||||
{ coinPrice: 10, giftId: "10001", giftTypeCode: "normal", name: "Rock Rose", resourceId: 11 },
|
||||
{ coinPrice: 20, giftId: "10005", giftTypeCode: "normal", name: "Lucky Star", resourceId: 12 },
|
||||
{
|
||||
coinPrice: 10,
|
||||
giftId: "10001",
|
||||
giftTypeCode: "normal",
|
||||
name: "Rock Rose",
|
||||
resource: { previewUrl: "https://cdn.example.com/rock.webp", resourceCode: "rock_rose" },
|
||||
resourceId: 11,
|
||||
},
|
||||
{
|
||||
coinPrice: 20,
|
||||
giftId: "10005",
|
||||
giftTypeCode: "normal",
|
||||
name: "Lucky Star",
|
||||
resource: { previewUrl: "https://cdn.example.com/lucky.webp", resourceCode: "lucky_star" },
|
||||
resourceId: 12,
|
||||
},
|
||||
{
|
||||
coinPrice: 1,
|
||||
giftId: "Gifi-3",
|
||||
giftTypeCode: "normal",
|
||||
name: "Invalid Alpha Gift",
|
||||
resource: { previewUrl: "https://cdn.example.com/invalid.webp", resourceCode: "invalid_alpha" },
|
||||
resourceId: 13,
|
||||
},
|
||||
],
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
|
||||
@ -1,12 +1,24 @@
|
||||
import HelpOutlineOutlined from "@mui/icons-material/HelpOutlineOutlined";
|
||||
import AccountBalanceWalletOutlined from "@mui/icons-material/AccountBalanceWalletOutlined";
|
||||
import AddCircleOutlineOutlined from "@mui/icons-material/AddCircleOutlineOutlined";
|
||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||
import TuneOutlined from "@mui/icons-material/TuneOutlined";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { PERMISSIONS } from "@/app/permissions";
|
||||
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||
import { adjustDicePool, listSelfGames, updateDiceConfig } from "@/features/games/api";
|
||||
import {
|
||||
adjustSelfGameStakePool,
|
||||
getSelfGameNewUserPolicy,
|
||||
listSelfGameStakePools,
|
||||
listSelfGames,
|
||||
updateDiceConfig,
|
||||
updateSelfGameNewUserPolicy,
|
||||
updateSelfGameStakePool,
|
||||
} from "@/features/games/api";
|
||||
import styles from "@/features/games/games.module.css";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
@ -15,7 +27,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";
|
||||
@ -32,12 +49,37 @@ const defaultConfigForm = {
|
||||
robotMatchWaitMs: 3000,
|
||||
};
|
||||
|
||||
const defaultPoolForm = {
|
||||
const defaultStakePoolConfigForm = {
|
||||
stakeCoin: "",
|
||||
targetBalanceCoin: "",
|
||||
safeBalanceCoin: "",
|
||||
warningBalanceCoin: "",
|
||||
dangerBalanceCoin: "",
|
||||
status: "active",
|
||||
};
|
||||
|
||||
const defaultStakePoolAdjustForm = {
|
||||
stakeCoin: "",
|
||||
amountCoin: "",
|
||||
direction: "in",
|
||||
reason: "admin_adjust",
|
||||
};
|
||||
|
||||
const defaultPolicyForm = {
|
||||
enabled: true,
|
||||
protectionRounds: 30,
|
||||
protectionHours: 168,
|
||||
maxStakeCoin: 5000,
|
||||
lifetimeSubsidyQuotaCoin: 30000,
|
||||
day1SubsidyQuotaCoin: 15000,
|
||||
singleRoundSubsidyCapCoin: 5000,
|
||||
strategyLevel: "soft_boost",
|
||||
loseStreakProtectionEnabled: true,
|
||||
loseStreakTrigger: 2,
|
||||
normalPhaseTargetWinRatePercent: 53,
|
||||
blackPoolRobotForceWinEnabled: false,
|
||||
};
|
||||
|
||||
export function SelfGamePage() {
|
||||
const { can } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
@ -48,7 +90,10 @@ export function SelfGamePage() {
|
||||
const [activeAction, setActiveAction] = useState("");
|
||||
const [activeGame, setActiveGame] = useState(null);
|
||||
const [configForm, setConfigForm] = useState(defaultConfigForm);
|
||||
const [poolForm, setPoolForm] = useState(defaultPoolForm);
|
||||
const [stakePools, setStakePools] = useState([]);
|
||||
const [stakePoolConfigForm, setStakePoolConfigForm] = useState(defaultStakePoolConfigForm);
|
||||
const [stakePoolAdjustForm, setStakePoolAdjustForm] = useState(defaultStakePoolAdjustForm);
|
||||
const [policyForm, setPolicyForm] = useState(defaultPolicyForm);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@ -66,6 +111,74 @@ export function SelfGamePage() {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
setActiveAction("");
|
||||
setActiveGame(null);
|
||||
}, []);
|
||||
|
||||
const openConfig = useCallback((game) => {
|
||||
setActiveGame(game);
|
||||
setConfigForm(configToForm(game));
|
||||
setActiveAction("config");
|
||||
}, []);
|
||||
|
||||
const applyStakePools = useCallback((pools, direction = "in") => {
|
||||
const items = pools || [];
|
||||
setStakePools(items);
|
||||
const selected = items[0];
|
||||
if (!selected) {
|
||||
setStakePoolConfigForm(defaultStakePoolConfigForm);
|
||||
setStakePoolAdjustForm({ ...defaultStakePoolAdjustForm, direction });
|
||||
return;
|
||||
}
|
||||
setStakePoolConfigForm(stakePoolToConfigForm(selected));
|
||||
setStakePoolAdjustForm(stakePoolToAdjustForm(selected, direction));
|
||||
}, []);
|
||||
|
||||
const reloadStakePools = useCallback(
|
||||
async (gameId, direction = stakePoolAdjustForm.direction || "in") => {
|
||||
const result = await listSelfGameStakePools(gameId);
|
||||
applyStakePools(result.items || [], direction);
|
||||
},
|
||||
[applyStakePools, stakePoolAdjustForm.direction],
|
||||
);
|
||||
|
||||
const openStakePools = useCallback(
|
||||
async (game, direction = "in") => {
|
||||
setActiveGame(game);
|
||||
setActiveAction("stake-pools");
|
||||
setLoadingAction("stake-pools-load");
|
||||
try {
|
||||
await reloadStakePools(game.gameId, direction);
|
||||
} catch (err) {
|
||||
showToast(err.message || "加载档位奖池失败", "error");
|
||||
closeDialog();
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
},
|
||||
[closeDialog, reloadStakePools, showToast],
|
||||
);
|
||||
|
||||
const openPolicy = useCallback(
|
||||
async (game) => {
|
||||
setActiveGame(game);
|
||||
setPolicyForm({ ...defaultPolicyForm, gameId: game.gameId });
|
||||
setActiveAction("policy");
|
||||
setLoadingAction("policy-load");
|
||||
try {
|
||||
const policy = await getSelfGameNewUserPolicy(game.gameId);
|
||||
setPolicyForm(policyToForm(policy, game.gameId));
|
||||
} catch (err) {
|
||||
showToast(err.message || "加载新手策略失败", "error");
|
||||
closeDialog();
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
},
|
||||
[closeDialog, showToast],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
@ -100,8 +213,8 @@ 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 · 策略引擎`,
|
||||
},
|
||||
{
|
||||
key: "updated",
|
||||
@ -112,39 +225,36 @@ export function SelfGamePage() {
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
width: "128px",
|
||||
width: "232px",
|
||||
render: (game) => (
|
||||
<AdminRowActions>
|
||||
<AdminActionIconButton disabled={!canUpdate} label="配置骰子" onClick={() => openConfig(game)}>
|
||||
<SettingsOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton disabled={!canUpdate} label="调整奖池" onClick={() => openPool(game)}>
|
||||
<AdminActionIconButton disabled={!canUpdate} label="新手策略" onClick={() => openPolicy(game)}>
|
||||
<TuneOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton
|
||||
disabled={!canUpdate}
|
||||
label="档位奖池"
|
||||
onClick={() => openStakePools(game, "in")}
|
||||
>
|
||||
<AccountBalanceWalletOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton
|
||||
disabled={!canUpdate}
|
||||
label="快速加奖池"
|
||||
onClick={() => openStakePools(game, "in")}
|
||||
>
|
||||
<AddCircleOutlineOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
</AdminRowActions>
|
||||
),
|
||||
},
|
||||
],
|
||||
[canUpdate],
|
||||
[canUpdate, openConfig, openPolicy, openStakePools],
|
||||
);
|
||||
|
||||
const openConfig = (game) => {
|
||||
setActiveGame(game);
|
||||
setConfigForm(configToForm(game));
|
||||
setActiveAction("config");
|
||||
};
|
||||
|
||||
const openPool = (game) => {
|
||||
setActiveGame(game);
|
||||
setPoolForm(defaultPoolForm);
|
||||
setActiveAction("pool");
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setActiveAction("");
|
||||
setActiveGame(null);
|
||||
};
|
||||
|
||||
const submitConfig = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!activeGame) return;
|
||||
@ -161,21 +271,57 @@ export function SelfGamePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const submitPool = async (event) => {
|
||||
const submitStakePoolConfig = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!activeGame) return;
|
||||
setLoadingAction("pool");
|
||||
setLoadingAction("stake-pool-config");
|
||||
try {
|
||||
await adjustDicePool(activeGame.gameId, {
|
||||
amountCoin: Number(poolForm.amountCoin || 0),
|
||||
direction: poolForm.direction,
|
||||
reason: poolForm.reason,
|
||||
await updateSelfGameStakePool(
|
||||
activeGame.gameId,
|
||||
Number(stakePoolConfigForm.stakeCoin || 0),
|
||||
stakePoolConfigPayload(stakePoolConfigForm),
|
||||
);
|
||||
await reloadStakePools(activeGame.gameId, stakePoolAdjustForm.direction || "in");
|
||||
showToast("档位奖池配置已保存", "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "保存档位奖池失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
const submitStakePoolAdjust = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!activeGame) return;
|
||||
setLoadingAction("stake-pool-adjust");
|
||||
try {
|
||||
await adjustSelfGameStakePool(activeGame.gameId, Number(stakePoolAdjustForm.stakeCoin || 0), {
|
||||
amountCoin: Number(stakePoolAdjustForm.amountCoin || 0),
|
||||
direction: stakePoolAdjustForm.direction,
|
||||
reason: stakePoolAdjustForm.reason,
|
||||
});
|
||||
await load();
|
||||
showToast("奖池已调整", "success");
|
||||
await reloadStakePools(activeGame.gameId, stakePoolAdjustForm.direction || "in");
|
||||
setStakePoolAdjustForm((current) => ({ ...current, amountCoin: "" }));
|
||||
showToast(stakePoolAdjustForm.direction === "out" ? "档位奖池已扣除" : "档位奖池已增加", "success");
|
||||
} catch (err) {
|
||||
showToast(err.message || "调整档位奖池失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
};
|
||||
|
||||
const submitPolicy = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!activeGame) return;
|
||||
setLoadingAction("policy");
|
||||
try {
|
||||
await updateSelfGameNewUserPolicy(activeGame.gameId, policyPayload(policyForm, activeGame.gameId));
|
||||
await load();
|
||||
showToast("新手策略已保存", "success");
|
||||
closeDialog();
|
||||
} catch (err) {
|
||||
showToast(err.message || "调整奖池失败", "error");
|
||||
showToast(err.message || "保存新手策略失败", "error");
|
||||
} finally {
|
||||
setLoadingAction("");
|
||||
}
|
||||
@ -205,13 +351,27 @@ export function SelfGamePage() {
|
||||
onClose={closeDialog}
|
||||
onSubmit={submitConfig}
|
||||
/>
|
||||
<PoolDialog
|
||||
form={poolForm}
|
||||
loading={loadingAction === "pool"}
|
||||
open={activeAction === "pool"}
|
||||
setForm={setPoolForm}
|
||||
<StakePoolsDialog
|
||||
adjustForm={stakePoolAdjustForm}
|
||||
configForm={stakePoolConfigForm}
|
||||
loading={loadingAction === "stake-pools-load" || loadingAction === "stake-pool-config"}
|
||||
pools={stakePools}
|
||||
setAdjustForm={setStakePoolAdjustForm}
|
||||
setConfigForm={setStakePoolConfigForm}
|
||||
submittingConfig={loadingAction === "stake-pool-config"}
|
||||
submittingAdjust={loadingAction === "stake-pool-adjust"}
|
||||
open={activeAction === "stake-pools"}
|
||||
onClose={closeDialog}
|
||||
onSubmit={submitPool}
|
||||
onSubmitAdjust={submitStakePoolAdjust}
|
||||
onSubmitConfig={submitStakePoolConfig}
|
||||
/>
|
||||
<PolicyDialog
|
||||
form={policyForm}
|
||||
loading={loadingAction === "policy" || loadingAction === "policy-load"}
|
||||
open={activeAction === "policy"}
|
||||
setForm={setPolicyForm}
|
||||
onClose={closeDialog}
|
||||
onSubmit={submitPolicy}
|
||||
/>
|
||||
</AdminListPage>
|
||||
);
|
||||
@ -219,7 +379,14 @@ export function SelfGamePage() {
|
||||
|
||||
function ConfigDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
||||
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="下注与费率">
|
||||
<AdminFormFieldGrid>
|
||||
<TextField
|
||||
@ -273,7 +440,7 @@ function ConfigDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
||||
<AdminFormSwitchField
|
||||
checked={form.robotEnabled}
|
||||
label="机器人补位"
|
||||
onChange={(event) => setForm({ ...form, robotEnabled: event.target.checked })}
|
||||
onChange={(checked) => setForm({ ...form, robotEnabled: checked })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
@ -281,31 +448,333 @@ function ConfigDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PoolDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
||||
function StakePoolsDialog({
|
||||
adjustForm,
|
||||
configForm,
|
||||
loading,
|
||||
open,
|
||||
pools,
|
||||
setAdjustForm,
|
||||
setConfigForm,
|
||||
submittingAdjust,
|
||||
submittingConfig,
|
||||
onClose,
|
||||
onSubmitAdjust,
|
||||
onSubmitConfig,
|
||||
}) {
|
||||
const selectedStakeCoin = Number(configForm.stakeCoin || adjustForm.stakeCoin || 0);
|
||||
const selectedPool = pools.find((pool) => pool.stakeCoin === selectedStakeCoin) || pools[0];
|
||||
const isDeduct = adjustForm.direction === "out";
|
||||
const selectPool = (pool, direction = adjustForm.direction || "in") => {
|
||||
setConfigForm(stakePoolToConfigForm(pool));
|
||||
setAdjustForm(stakePoolToAdjustForm(pool, direction));
|
||||
};
|
||||
return (
|
||||
<AdminFormDialog loading={loading} open={open} submitLabel="确认调整" title="调整奖池" onClose={onClose} onSubmit={onSubmit}>
|
||||
<AdminFormFieldGrid>
|
||||
<TextField
|
||||
label="方向"
|
||||
select
|
||||
value={form.direction}
|
||||
onChange={(event) => setForm({ ...form, direction: event.target.value })}
|
||||
>
|
||||
<MenuItem value="in">增加</MenuItem>
|
||||
<MenuItem value="out">减少</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
label="金币"
|
||||
type="number"
|
||||
value={form.amountCoin}
|
||||
onChange={(event) => setForm({ ...form, amountCoin: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="原因"
|
||||
value={form.reason}
|
||||
onChange={(event) => setForm({ ...form, reason: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
<AdminFormDialog
|
||||
loading={loading}
|
||||
open={open}
|
||||
size="wide"
|
||||
submitDisabled={loading || submittingConfig || !configForm.stakeCoin}
|
||||
submitLabel="保存档位配置"
|
||||
title="档位奖池"
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmitConfig}
|
||||
>
|
||||
<AdminFormSection title="档位水位">
|
||||
<div className={styles.stakePoolList}>
|
||||
{pools.length ? (
|
||||
pools.map((pool) => (
|
||||
<button
|
||||
key={pool.stakeCoin}
|
||||
className={[
|
||||
styles.stakePoolRow,
|
||||
pool.stakeCoin === selectedStakeCoin ? styles.stakePoolRowActive : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
type="button"
|
||||
onClick={() => selectPool(pool)}
|
||||
>
|
||||
<span className={styles.stakePoolMain}>
|
||||
<span className={styles.stakePoolStake}>{formatNumber(pool.stakeCoin)}</span>
|
||||
<span className={poolLevelClass(pool.poolLevel)}>
|
||||
{poolLevelText(pool.poolLevel)}
|
||||
</span>
|
||||
</span>
|
||||
<span className={styles.stakePoolMetrics}>
|
||||
余额 {formatNumber(pool.balanceCoin)} · 可用 {formatNumber(pool.availableCoin)} ·
|
||||
冻结 {formatNumber(pool.reservedCoin)}
|
||||
</span>
|
||||
<span className={styles.stakePoolMetrics}>
|
||||
单局需 {formatNumber(pool.requiredPoolCoin)} · 可承受{" "}
|
||||
{formatNumber(pool.humanWinCapacity)} 次 ·{" "}
|
||||
{pool.botMatchEnabled ? "可补机器人" : "暂停补机器人"}
|
||||
</span>
|
||||
{pool.poolLevel === "black" ? (
|
||||
<span className={styles.stakePoolWarning}>
|
||||
黑色原因:{blackReasonText(pool.blackReason)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className={styles.stakePoolEmpty}>暂无档位奖池</div>
|
||||
)}
|
||||
</div>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="水位阈值">
|
||||
<AdminFormFieldGrid>
|
||||
<TextField disabled label="当前档位" value={formatNumber(configForm.stakeCoin)} />
|
||||
<TextField
|
||||
label="状态"
|
||||
select
|
||||
value={configForm.status}
|
||||
onChange={(event) => setConfigForm({ ...configForm, status: event.target.value })}
|
||||
>
|
||||
<MenuItem value="active">启用</MenuItem>
|
||||
<MenuItem value="disabled">停用</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
label="目标奖池"
|
||||
type="number"
|
||||
value={configForm.targetBalanceCoin}
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
onChange={(event) => setConfigForm({ ...configForm, targetBalanceCoin: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="绿色阈值"
|
||||
type="number"
|
||||
value={configForm.safeBalanceCoin}
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
onChange={(event) => setConfigForm({ ...configForm, safeBalanceCoin: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="黄色阈值"
|
||||
type="number"
|
||||
value={configForm.warningBalanceCoin}
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
onChange={(event) => setConfigForm({ ...configForm, warningBalanceCoin: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="红色阈值"
|
||||
type="number"
|
||||
value={configForm.dangerBalanceCoin}
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
onChange={(event) => setConfigForm({ ...configForm, dangerBalanceCoin: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
{selectedPool ? (
|
||||
<div className={styles.stakePoolHint}>
|
||||
黑色水位自动判断:状态停用,或可用余额低于单局需 {formatNumber(selectedPool.requiredPoolCoin)}。
|
||||
</div>
|
||||
) : null}
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="档位奖池加扣">
|
||||
<AdminFormFieldGrid>
|
||||
<TextField disabled label="当前档位" value={formatNumber(adjustForm.stakeCoin)} />
|
||||
<TextField
|
||||
label="方向"
|
||||
select
|
||||
value={adjustForm.direction}
|
||||
onChange={(event) =>
|
||||
setAdjustForm({
|
||||
...adjustForm,
|
||||
direction: event.target.value,
|
||||
reason: event.target.value === "out" ? "admin_deduct" : "admin_adjust",
|
||||
})
|
||||
}
|
||||
>
|
||||
<MenuItem value="in">增加</MenuItem>
|
||||
<MenuItem value="out">扣除</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
helperText={isDeduct ? "不能扣成负数" : ""}
|
||||
label="金币"
|
||||
type="number"
|
||||
value={adjustForm.amountCoin}
|
||||
slotProps={{ htmlInput: { min: 1 } }}
|
||||
onChange={(event) => setAdjustForm({ ...adjustForm, amountCoin: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="原因"
|
||||
value={adjustForm.reason}
|
||||
onChange={(event) => setAdjustForm({ ...adjustForm, reason: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
<div className={styles.stakePoolInlineActions}>
|
||||
<button
|
||||
className={styles.stakePoolPrimaryButton}
|
||||
disabled={loading || submittingAdjust || !adjustForm.stakeCoin || !adjustForm.amountCoin}
|
||||
type="button"
|
||||
onClick={onSubmitAdjust}
|
||||
>
|
||||
{isDeduct ? "确认扣除" : "确认增加"}
|
||||
</button>
|
||||
</div>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function StrategyLevelLabel() {
|
||||
return (
|
||||
<span className={styles.strategyLevelLabel}>
|
||||
<span>策略等级</span>
|
||||
<HelpOutlineOutlined className={styles.strategyLevelHelpIcon} fontSize="inherit" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StrategyLevelTooltip() {
|
||||
return (
|
||||
<div className={styles.strategyLevelTooltip}>
|
||||
<div className={styles.strategyLevelTooltipTitle}>只影响新手期机器人补位局</div>
|
||||
<div>
|
||||
低风险、绿色水位、下注 1000/5000 且额度足够时:柔性激励按第 1 局 70%、第 2-6 局 60%、第 7-16 局 55%、第
|
||||
17-30 局 50%、第 31 局后正常随机;强激励对应 85%、75%、65%、55%、50%。
|
||||
</div>
|
||||
<ul className={styles.strategyLevelTooltipList}>
|
||||
<li>关闭保护:不强制用户赢,全部走 dice/rock 正常随机。</li>
|
||||
<li>首局不输:第 1 个有效机器人局 100% 正反馈,后续按柔性激励曲线。</li>
|
||||
<li>柔性激励案例:第 3 个有效机器人局命中率 60%;rock 平局不计有效局,也不消耗额度。</li>
|
||||
<li>强激励案例:第 10 个有效机器人局命中率 65%;第 20 个有效机器人局命中率 55%。</li>
|
||||
<li>黄色水位案例:柔性第 1 局 70% 降到 60%,强激励第 1 局 85% 降到 75%。</li>
|
||||
<li>风控案例:risk_score 30-49 降 10%,50-69 降 25%,70 及以上不享受保护。</li>
|
||||
<li>额度案例:默认最大下注 5000,40000/100000 档不触发;首日额度不足时降级正常随机。</li>
|
||||
<li>自然期案例:配置 50% 就完全自然随机;配置 53% 时 dice 约 6% 的机器人局补正为用户赢,rock 约 4.1%。</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyDialog({ form, loading, open, setForm, onClose, onSubmit }) {
|
||||
return (
|
||||
<AdminFormDialog
|
||||
loading={loading}
|
||||
open={open}
|
||||
submitLabel="保存策略"
|
||||
title="新手策略"
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<AdminFormSection title="保护窗口">
|
||||
<AdminFormFieldGrid>
|
||||
<AdminFormSwitchField
|
||||
checked={form.enabled}
|
||||
label="启用"
|
||||
onChange={(checked) => setForm({ ...form, enabled: checked })}
|
||||
/>
|
||||
<Tooltip
|
||||
arrow
|
||||
placement="left"
|
||||
title={<StrategyLevelTooltip />}
|
||||
slotProps={{ tooltip: { className: styles.strategyLevelTooltipPaper } }}
|
||||
>
|
||||
<TextField
|
||||
label={<StrategyLevelLabel />}
|
||||
select
|
||||
value={form.strategyLevel}
|
||||
onChange={(event) => setForm({ ...form, strategyLevel: event.target.value })}
|
||||
>
|
||||
<MenuItem value="none">关闭保护</MenuItem>
|
||||
<MenuItem value="first_no_lose">首局不输</MenuItem>
|
||||
<MenuItem value="soft_boost">柔性激励</MenuItem>
|
||||
<MenuItem value="strong_boost">强激励</MenuItem>
|
||||
</TextField>
|
||||
</Tooltip>
|
||||
<TextField
|
||||
label="保护局数"
|
||||
type="number"
|
||||
value={form.protectionRounds}
|
||||
slotProps={{ htmlInput: { min: 1 } }}
|
||||
onChange={(event) => setForm({ ...form, protectionRounds: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="保护小时"
|
||||
type="number"
|
||||
value={form.protectionHours}
|
||||
slotProps={{ htmlInput: { min: 1 } }}
|
||||
onChange={(event) => setForm({ ...form, protectionHours: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="补贴额度">
|
||||
<AdminFormFieldGrid>
|
||||
<TextField
|
||||
label="最大下注"
|
||||
type="number"
|
||||
value={form.maxStakeCoin}
|
||||
slotProps={{ htmlInput: { min: 1 } }}
|
||||
onChange={(event) => setForm({ ...form, maxStakeCoin: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="生命周期额度"
|
||||
type="number"
|
||||
value={form.lifetimeSubsidyQuotaCoin}
|
||||
slotProps={{ htmlInput: { min: 1 } }}
|
||||
onChange={(event) => setForm({ ...form, lifetimeSubsidyQuotaCoin: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="首日额度"
|
||||
type="number"
|
||||
value={form.day1SubsidyQuotaCoin}
|
||||
slotProps={{ htmlInput: { min: 1 } }}
|
||||
onChange={(event) => setForm({ ...form, day1SubsidyQuotaCoin: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
label="单局上限"
|
||||
type="number"
|
||||
value={form.singleRoundSubsidyCapCoin}
|
||||
slotProps={{ htmlInput: { min: 1 } }}
|
||||
onChange={(event) => setForm({ ...form, singleRoundSubsidyCapCoin: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="连输保护">
|
||||
<AdminFormFieldGrid>
|
||||
<AdminFormSwitchField
|
||||
checked={form.loseStreakProtectionEnabled}
|
||||
label="启用"
|
||||
onChange={(checked) => setForm({ ...form, loseStreakProtectionEnabled: checked })}
|
||||
/>
|
||||
<TextField
|
||||
label="连输阈值"
|
||||
type="number"
|
||||
value={form.loseStreakTrigger}
|
||||
slotProps={{ htmlInput: { min: 1 } }}
|
||||
onChange={(event) => setForm({ ...form, loseStreakTrigger: event.target.value })}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="成熟期体验">
|
||||
<AdminFormFieldGrid>
|
||||
<Tooltip
|
||||
arrow
|
||||
placement="left"
|
||||
title="只影响已过新手保护候选条件的机器人补位局。50% 表示完全自然随机;高于 50% 会换算成少量用户赢补正,不会强制用户输,也不消耗新手补贴额度。"
|
||||
>
|
||||
<TextField
|
||||
helperText="建议 53,用来抵消 5% 平台费和 1% 入池带来的长期负期望"
|
||||
label="自然期目标胜率 %"
|
||||
type="number"
|
||||
value={form.normalPhaseTargetWinRatePercent}
|
||||
slotProps={{ htmlInput: { min: 50, max: 100, step: 1 } }}
|
||||
onChange={(event) => setForm({ ...form, normalPhaseTargetWinRatePercent: event.target.value })}
|
||||
/>
|
||||
</Tooltip>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="黑色水位">
|
||||
<AdminFormFieldGrid>
|
||||
<Tooltip arrow placement="left" title="关闭时黑色档位只等待真人;开启时黑色档位允许机器人补位,并强制机器人获胜。">
|
||||
<AdminFormSwitchField
|
||||
checked={form.blackPoolRobotForceWinEnabled}
|
||||
label="机器人强制获胜"
|
||||
onChange={(checked) => setForm({ ...form, blackPoolRobotForceWinEnabled: checked })}
|
||||
/>
|
||||
</Tooltip>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
);
|
||||
}
|
||||
@ -337,6 +806,94 @@ function configPayload(form, gameId) {
|
||||
};
|
||||
}
|
||||
|
||||
function policyToForm(policy, gameId) {
|
||||
return {
|
||||
...defaultPolicyForm,
|
||||
...policy,
|
||||
gameId,
|
||||
};
|
||||
}
|
||||
|
||||
function policyPayload(form, gameId) {
|
||||
return {
|
||||
gameId,
|
||||
enabled: Boolean(form.enabled),
|
||||
protectionRounds: Number(form.protectionRounds || 30),
|
||||
protectionHours: Number(form.protectionHours || 168),
|
||||
maxStakeCoin: Number(form.maxStakeCoin || 5000),
|
||||
lifetimeSubsidyQuotaCoin: Number(form.lifetimeSubsidyQuotaCoin || 30000),
|
||||
day1SubsidyQuotaCoin: Number(form.day1SubsidyQuotaCoin || 15000),
|
||||
singleRoundSubsidyCapCoin: Number(form.singleRoundSubsidyCapCoin || 5000),
|
||||
strategyLevel: form.strategyLevel || "soft_boost",
|
||||
loseStreakProtectionEnabled: Boolean(form.loseStreakProtectionEnabled),
|
||||
loseStreakTrigger: Number(form.loseStreakTrigger || 2),
|
||||
normalPhaseTargetWinRatePercent: Number(form.normalPhaseTargetWinRatePercent || 53),
|
||||
blackPoolRobotForceWinEnabled: Boolean(form.blackPoolRobotForceWinEnabled),
|
||||
};
|
||||
}
|
||||
|
||||
function stakePoolToConfigForm(pool) {
|
||||
if (!pool) return defaultStakePoolConfigForm;
|
||||
return {
|
||||
stakeCoin: pool.stakeCoin,
|
||||
targetBalanceCoin: pool.targetBalanceCoin,
|
||||
safeBalanceCoin: pool.safeBalanceCoin,
|
||||
warningBalanceCoin: pool.warningBalanceCoin,
|
||||
dangerBalanceCoin: pool.dangerBalanceCoin,
|
||||
status: pool.status || "active",
|
||||
};
|
||||
}
|
||||
|
||||
function stakePoolToAdjustForm(pool, direction = "in") {
|
||||
if (!pool) return { ...defaultStakePoolAdjustForm, direction };
|
||||
return {
|
||||
...defaultStakePoolAdjustForm,
|
||||
stakeCoin: pool.stakeCoin,
|
||||
direction,
|
||||
reason: direction === "out" ? "admin_deduct" : "admin_adjust",
|
||||
};
|
||||
}
|
||||
|
||||
function stakePoolConfigPayload(form) {
|
||||
return {
|
||||
targetBalanceCoin: Number(form.targetBalanceCoin || 0),
|
||||
safeBalanceCoin: Number(form.safeBalanceCoin || 0),
|
||||
warningBalanceCoin: Number(form.warningBalanceCoin || 0),
|
||||
dangerBalanceCoin: Number(form.dangerBalanceCoin || 0),
|
||||
status: form.status || "active",
|
||||
};
|
||||
}
|
||||
|
||||
function poolLevelText(level) {
|
||||
switch (level) {
|
||||
case "green":
|
||||
return "绿色";
|
||||
case "yellow":
|
||||
return "黄色";
|
||||
case "red":
|
||||
return "红色";
|
||||
case "black":
|
||||
return "黑色";
|
||||
default:
|
||||
return level || "-";
|
||||
}
|
||||
}
|
||||
|
||||
function poolLevelClass(level) {
|
||||
return [styles.poolLevelBadge, styles[`poolLevel_${level || "unknown"}`]].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function blackReasonText(reason) {
|
||||
switch (reason) {
|
||||
case "status_disabled":
|
||||
return "档位奖池已停用";
|
||||
case "available_below_required_pool_coin":
|
||||
return "可用余额不足以覆盖真人赢机器人净支出";
|
||||
default:
|
||||
return reason || "可用余额不足";
|
||||
}
|
||||
}
|
||||
|
||||
function parseStakeOptions(text) {
|
||||
return String(text || "")
|
||||
.split(/[,,\s]+/)
|
||||
|
||||
@ -221,6 +221,8 @@ export interface HostSalarySettlementDto {
|
||||
commandId: string;
|
||||
transactionId: string;
|
||||
settlementType: string;
|
||||
triggerMode: string;
|
||||
settlementRole: string;
|
||||
userId: string;
|
||||
user: HostSalarySettlementUserDto;
|
||||
agencyOwnerUserId: string;
|
||||
@ -241,7 +243,75 @@ export interface HostSalarySettlementDto {
|
||||
createdAtMs: number;
|
||||
}
|
||||
|
||||
export interface HostSalarySettlementPendingDto {
|
||||
userId: string;
|
||||
user: HostSalarySettlementUserDto;
|
||||
agencyOwnerUserId: string;
|
||||
agencyOwner: HostSalarySettlementUserDto;
|
||||
role: string;
|
||||
cycleKey: string;
|
||||
regionId: number;
|
||||
regionName?: string;
|
||||
policyId: number;
|
||||
policyName: string;
|
||||
settlementType: string;
|
||||
triggerMode: string;
|
||||
levelNo: number;
|
||||
requiredDiamonds: number;
|
||||
totalDiamonds: number;
|
||||
hostSalaryUsdMinorTarget: number;
|
||||
hostSalaryUsdTarget: string;
|
||||
hostSalaryUsdMinorDelta: number;
|
||||
hostSalaryUsdDelta: string;
|
||||
hostCoinRewardTarget: number;
|
||||
hostCoinRewardDelta: number;
|
||||
agencySalaryUsdMinorTarget: number;
|
||||
agencySalaryUsdTarget: string;
|
||||
agencySalaryUsdMinorDelta: number;
|
||||
agencySalaryUsdDelta: string;
|
||||
residualUsdMinorDelta: number;
|
||||
residualUsdDelta: string;
|
||||
totalUsdMinorDelta: number;
|
||||
totalUsdDelta: string;
|
||||
settledLevelNo: number;
|
||||
settledHostSalaryUsdMinor: number;
|
||||
settledHostSalaryUsd: string;
|
||||
settledHostCoinReward: number;
|
||||
settledAgencySalaryUsdMinor: number;
|
||||
settledAgencySalaryUsd: string;
|
||||
lastSettledTotalDiamonds: number;
|
||||
residualUsdMinor: number;
|
||||
residualUsd: string;
|
||||
monthEndClearedAtMs: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface HostSalarySettlePayload {
|
||||
role: "host" | "agency";
|
||||
settlement_type: "daily" | "half_month" | "month_end";
|
||||
trigger_mode: "manual";
|
||||
cycle_key: string;
|
||||
region_id?: number;
|
||||
country_code?: string;
|
||||
user_ids?: string[];
|
||||
}
|
||||
|
||||
export interface HostSalarySettleResult {
|
||||
role: string;
|
||||
cycleKey: string;
|
||||
settlementType: string;
|
||||
triggerMode: string;
|
||||
requestedCount: number;
|
||||
processedCount: number;
|
||||
successCount: number;
|
||||
skippedCount: number;
|
||||
failureCount: number;
|
||||
records: HostSalarySettlementDto[];
|
||||
}
|
||||
|
||||
type RawSettlement = HostSalarySettlementDto & Record<string, unknown>;
|
||||
type RawHostPending = HostSalarySettlementPendingDto & Record<string, unknown>;
|
||||
type RawHostSettleResult = HostSalarySettleResult & Record<string, unknown>;
|
||||
|
||||
export function listHostSalarySettlements(query: PageQuery = {}): Promise<ApiPage<HostSalarySettlementDto>> {
|
||||
const endpoint = API_ENDPOINTS.listHostSalarySettlements;
|
||||
@ -254,6 +324,25 @@ export function listHostSalarySettlements(query: PageQuery = {}): Promise<ApiPag
|
||||
}));
|
||||
}
|
||||
|
||||
export function listHostSalarySettlementPending(
|
||||
query: PageQuery = {},
|
||||
): Promise<ApiPage<HostSalarySettlementPendingDto>> {
|
||||
return apiRequest<ApiPage<RawHostPending>>("/v1/admin/host-salary-settlements/pending", {
|
||||
method: "GET",
|
||||
query,
|
||||
}).then((page) => ({
|
||||
...page,
|
||||
items: (page.items || []).map(normalizeHostPending),
|
||||
}));
|
||||
}
|
||||
|
||||
export function settleHostSalary(payload: HostSalarySettlePayload): Promise<HostSalarySettleResult> {
|
||||
return apiRequest<RawHostSettleResult, HostSalarySettlePayload>("/v1/admin/host-salary-settlements/settle", {
|
||||
body: payload,
|
||||
method: "POST",
|
||||
}).then(normalizeHostSettleResult);
|
||||
}
|
||||
|
||||
export interface TeamSalarySettlementPendingDto {
|
||||
policyType: "bd" | "admin";
|
||||
userId: string;
|
||||
@ -305,7 +394,7 @@ export interface TeamSalarySettlePayload {
|
||||
cycle_key: string;
|
||||
region_id?: number;
|
||||
country_code?: string;
|
||||
user_ids?: number[];
|
||||
user_ids?: string[];
|
||||
}
|
||||
|
||||
export interface TeamSalarySettleResult {
|
||||
@ -392,6 +481,8 @@ function normalizeSettlement(raw: RawSettlement): HostSalarySettlementDto {
|
||||
commandId: stringValue(raw.commandId ?? raw.command_id),
|
||||
transactionId: stringValue(raw.transactionId ?? raw.transaction_id),
|
||||
settlementType: stringValue(raw.settlementType ?? raw.settlement_type),
|
||||
triggerMode: stringValue(raw.triggerMode ?? raw.trigger_mode) || "automatic",
|
||||
settlementRole: stringValue(raw.settlementRole ?? raw.settlement_role) || "all",
|
||||
userId: stringValue(raw.userId ?? raw.user_id),
|
||||
user: normalizeSettlementUser(user),
|
||||
agencyOwnerUserId: stringValue(raw.agencyOwnerUserId ?? raw.agency_owner_user_id),
|
||||
@ -413,6 +504,55 @@ function normalizeSettlement(raw: RawSettlement): HostSalarySettlementDto {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHostPending(raw: RawHostPending): HostSalarySettlementPendingDto {
|
||||
const user = asRecord(raw.user);
|
||||
const agencyOwner = asRecord(raw.agencyOwner ?? raw.agency_owner);
|
||||
return {
|
||||
userId: stringValue(raw.userId ?? raw.user_id),
|
||||
user: normalizeSettlementUser(user),
|
||||
agencyOwnerUserId: stringValue(raw.agencyOwnerUserId ?? raw.agency_owner_user_id),
|
||||
agencyOwner: normalizeSettlementUser(agencyOwner),
|
||||
role: stringValue(raw.role ?? raw.settlementRole ?? raw.settlement_role) || "host",
|
||||
cycleKey: stringValue(raw.cycleKey ?? raw.cycle_key),
|
||||
regionId: numberValue(raw.regionId ?? raw.region_id),
|
||||
regionName: stringValue(raw.regionName ?? raw.region_name),
|
||||
policyId: numberValue(raw.policyId ?? raw.policy_id),
|
||||
policyName: stringValue(raw.policyName ?? raw.policy_name),
|
||||
settlementType: stringValue(raw.settlementType ?? raw.settlement_type) || "half_month",
|
||||
triggerMode: stringValue(raw.triggerMode ?? raw.trigger_mode) || "manual",
|
||||
levelNo: numberValue(raw.levelNo ?? raw.level_no),
|
||||
requiredDiamonds: numberValue(raw.requiredDiamonds ?? raw.required_diamonds),
|
||||
totalDiamonds: numberValue(raw.totalDiamonds ?? raw.total_diamonds),
|
||||
hostSalaryUsdMinorTarget: numberValue(raw.hostSalaryUsdMinorTarget ?? raw.host_salary_usd_minor_target),
|
||||
hostSalaryUsdTarget: stringValue(raw.hostSalaryUsdTarget ?? raw.host_salary_usd_target),
|
||||
hostSalaryUsdMinorDelta: numberValue(raw.hostSalaryUsdMinorDelta ?? raw.host_salary_usd_minor_delta),
|
||||
hostSalaryUsdDelta: stringValue(raw.hostSalaryUsdDelta ?? raw.host_salary_usd_delta),
|
||||
hostCoinRewardTarget: numberValue(raw.hostCoinRewardTarget ?? raw.host_coin_reward_target),
|
||||
hostCoinRewardDelta: numberValue(raw.hostCoinRewardDelta ?? raw.host_coin_reward_delta),
|
||||
agencySalaryUsdMinorTarget: numberValue(raw.agencySalaryUsdMinorTarget ?? raw.agency_salary_usd_minor_target),
|
||||
agencySalaryUsdTarget: stringValue(raw.agencySalaryUsdTarget ?? raw.agency_salary_usd_target),
|
||||
agencySalaryUsdMinorDelta: numberValue(raw.agencySalaryUsdMinorDelta ?? raw.agency_salary_usd_minor_delta),
|
||||
agencySalaryUsdDelta: stringValue(raw.agencySalaryUsdDelta ?? raw.agency_salary_usd_delta),
|
||||
residualUsdMinorDelta: numberValue(raw.residualUsdMinorDelta ?? raw.residual_usd_minor_delta),
|
||||
residualUsdDelta: stringValue(raw.residualUsdDelta ?? raw.residual_usd_delta),
|
||||
totalUsdMinorDelta: numberValue(raw.totalUsdMinorDelta ?? raw.total_usd_minor_delta),
|
||||
totalUsdDelta: stringValue(raw.totalUsdDelta ?? raw.total_usd_delta),
|
||||
settledLevelNo: numberValue(raw.settledLevelNo ?? raw.settled_level_no),
|
||||
settledHostSalaryUsdMinor: numberValue(raw.settledHostSalaryUsdMinor ?? raw.settled_host_salary_usd_minor),
|
||||
settledHostSalaryUsd: stringValue(raw.settledHostSalaryUsd ?? raw.settled_host_salary_usd),
|
||||
settledHostCoinReward: numberValue(raw.settledHostCoinReward ?? raw.settled_host_coin_reward),
|
||||
settledAgencySalaryUsdMinor: numberValue(
|
||||
raw.settledAgencySalaryUsdMinor ?? raw.settled_agency_salary_usd_minor,
|
||||
),
|
||||
settledAgencySalaryUsd: stringValue(raw.settledAgencySalaryUsd ?? raw.settled_agency_salary_usd),
|
||||
lastSettledTotalDiamonds: numberValue(raw.lastSettledTotalDiamonds ?? raw.last_settled_total_diamonds),
|
||||
residualUsdMinor: numberValue(raw.residualUsdMinor ?? raw.residual_usd_minor),
|
||||
residualUsd: stringValue(raw.residualUsd ?? raw.residual_usd),
|
||||
monthEndClearedAtMs: numberValue(raw.monthEndClearedAtMs ?? raw.month_end_cleared_at_ms),
|
||||
status: stringValue(raw.status) || "pending",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSettlementUser(raw: Record<string, unknown>): HostSalarySettlementUserDto {
|
||||
return {
|
||||
userId: stringValue(raw.userId ?? raw.user_id),
|
||||
@ -491,6 +631,21 @@ function normalizeTeamSettleResult(raw: RawTeamSettleResult): TeamSalarySettleRe
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHostSettleResult(raw: RawHostSettleResult): HostSalarySettleResult {
|
||||
return {
|
||||
role: stringValue(raw.role ?? raw.settlementRole ?? raw.settlement_role),
|
||||
cycleKey: stringValue(raw.cycleKey ?? raw.cycle_key),
|
||||
settlementType: stringValue(raw.settlementType ?? raw.settlement_type),
|
||||
triggerMode: stringValue(raw.triggerMode ?? raw.trigger_mode),
|
||||
requestedCount: numberValue(raw.requestedCount ?? raw.requested_count),
|
||||
processedCount: numberValue(raw.processedCount ?? raw.processed_count),
|
||||
successCount: numberValue(raw.successCount ?? raw.success_count),
|
||||
skippedCount: numberValue(raw.skippedCount ?? raw.skipped_count),
|
||||
failureCount: numberValue(raw.failureCount ?? raw.failure_count),
|
||||
records: arrayValue(raw.records).map((item) => normalizeSettlement(asRecord(item) as RawSettlement)),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTeamPolicy(item: RawTeamPolicy): TeamSalaryPolicyDto {
|
||||
return {
|
||||
id: numberValue(item.id),
|
||||
|
||||
@ -5,11 +5,15 @@ import Tabs from "@mui/material/Tabs";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
listHostSalarySettlements,
|
||||
listHostSalarySettlementPending,
|
||||
listTeamSalarySettlementPending,
|
||||
listTeamSalarySettlementRecords,
|
||||
settleHostSalary,
|
||||
settleTeamSalary,
|
||||
} from "@/features/host-agency-policy/api";
|
||||
import { useHostAgencyPolicyAbilities } from "@/features/host-agency-policy/permissions.js";
|
||||
import { useCountryOptions } from "@/features/host-org/hooks/useCountryOptions.js";
|
||||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||
import {
|
||||
AdminFilterResetButton,
|
||||
AdminFilterSelect,
|
||||
@ -51,6 +55,13 @@ const policyTypeOptions = [
|
||||
|
||||
const policyTypeAllOptions = [["", "全部角色"], ...policyTypeOptions];
|
||||
|
||||
const pendingRoleOptions = [
|
||||
["bd", "BD"],
|
||||
["admin", "Admin"],
|
||||
["host", "Host"],
|
||||
["agency", "Agency"],
|
||||
];
|
||||
|
||||
const triggerOptions = [
|
||||
["automatic", "自动政策"],
|
||||
["manual", "手动政策"],
|
||||
@ -59,18 +70,18 @@ const triggerOptions = [
|
||||
const triggerAllOptions = [["", "全部触发"], ...triggerOptions];
|
||||
|
||||
export function HostSalarySettlementPage() {
|
||||
const [activeTab, setActiveTab] = useState("team-pending");
|
||||
const [activeTab, setActiveTab] = useState("pending");
|
||||
return (
|
||||
<AdminListPage>
|
||||
<div className={styles.policyTabsBar}>
|
||||
<Tabs value={activeTab} onChange={(_, value) => setActiveTab(value)}>
|
||||
<Tab className={styles.policyTab} label="待结算" value="team-pending" />
|
||||
<Tab className={styles.policyTab} label="待结算" value="pending" />
|
||||
<Tab className={styles.policyTab} label="BD/Admin 记录" value="team-records" />
|
||||
<Tab className={styles.policyTab} label="Host/Agency 记录" value="host-records" />
|
||||
</Tabs>
|
||||
</div>
|
||||
{activeTab === "team-pending" ? (
|
||||
<TeamPendingPanel />
|
||||
{activeTab === "pending" ? (
|
||||
<PendingPanel />
|
||||
) : activeTab === "team-records" ? (
|
||||
<TeamRecordsPanel />
|
||||
) : (
|
||||
@ -80,6 +91,221 @@ export function HostSalarySettlementPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function PendingPanel() {
|
||||
const [role, setRole] = useState("bd");
|
||||
if (role === "host" || role === "agency") {
|
||||
return <HostPendingPanel role={role} onRoleChange={setRole} />;
|
||||
}
|
||||
return <TeamPendingPanel role={role} onRoleChange={setRole} />;
|
||||
}
|
||||
|
||||
// HostPendingPanel 展示 Host/Agency 动态账单;列表查询不入账,点击结算后才让 wallet-service 写钱包和记录。
|
||||
function HostPendingPanel({ onRoleChange, role }) {
|
||||
const abilities = useHostAgencyPolicyAbilities();
|
||||
const { showToast } = useToast();
|
||||
const { countryOptions, loadingCountries } = useCountryOptions();
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
const [page, setPage] = useState(1);
|
||||
const [settlementType, setSettlementType] = useState("half_month");
|
||||
const [cycleKey, setCycleKey] = useState(currentCycleKey);
|
||||
const [countryCode, setCountryCode] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [selected, setSelected] = useState(() => new Set());
|
||||
const [settling, setSettling] = useState(false);
|
||||
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
country_code: countryCode,
|
||||
cycle_key: cycleKey,
|
||||
region_id: regionId,
|
||||
role,
|
||||
settlement_type: settlementType,
|
||||
trigger_mode: "manual",
|
||||
}),
|
||||
[countryCode, cycleKey, regionId, role, settlementType],
|
||||
);
|
||||
const query = usePaginatedQuery({
|
||||
errorMessage: "获取 Host/Agency 待结算列表失败",
|
||||
fetcher: listHostSalarySettlementPending,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["host-salary-pending", filters, page],
|
||||
});
|
||||
const data = query.data || { items: [], page, pageSize, total: 0 };
|
||||
const items = data.items || [];
|
||||
const total = data.total || 0;
|
||||
const allVisibleSelected = items.length > 0 && items.every((item) => selected.has(hostPendingKey(item)));
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(new Set());
|
||||
}, [filters]);
|
||||
|
||||
const countryFilterOptions = useMemo(() => [["", loadingCountries ? "加载国家中" : "全部国家"], ...countryOptions.map((item) => [item.value, item.label])], [countryOptions, loadingCountries]);
|
||||
const regionFilterOptions = useMemo(() => [["", loadingRegions ? "加载区域中" : "全部区域"], ...regionOptions.map((item) => [item.value, item.label])], [loadingRegions, regionOptions]);
|
||||
const cycleFilterOptions = useMemo(() => buildCycleOptions(cycleKey, 18), [cycleKey]);
|
||||
|
||||
const changeFilter = (setter) => (value) => {
|
||||
setter(value);
|
||||
setPage(1);
|
||||
};
|
||||
const changeRole = (value) => {
|
||||
onRoleChange(value);
|
||||
setPage(1);
|
||||
};
|
||||
const resetFilters = () => {
|
||||
onRoleChange("bd");
|
||||
setSettlementType("half_month");
|
||||
setCycleKey(currentCycleKey());
|
||||
setCountryCode("");
|
||||
setRegionId("");
|
||||
setPage(1);
|
||||
};
|
||||
const toggleOne = (item) => {
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
const key = hostPendingKey(item);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const toggleVisible = () => {
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
items.forEach((item) => {
|
||||
const key = hostPendingKey(item);
|
||||
if (allVisibleSelected) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const settleRows = async (rows) => {
|
||||
const userIds = rows
|
||||
.map((item) => String(item.userId || "").trim())
|
||||
.filter((id) => /^[1-9]\d*$/.test(id));
|
||||
if (!userIds.length) {
|
||||
return;
|
||||
}
|
||||
setSettling(true);
|
||||
try {
|
||||
const result = await settleHostSalary({
|
||||
country_code: countryCode || undefined,
|
||||
cycle_key: cycleKey,
|
||||
role,
|
||||
region_id: Number(regionId || 0) || undefined,
|
||||
settlement_type: settlementType,
|
||||
trigger_mode: "manual",
|
||||
user_ids: userIds,
|
||||
});
|
||||
showToast({
|
||||
message: `结算完成:成功 ${result.successCount},跳过 ${result.skippedCount},失败 ${result.failureCount}`,
|
||||
severity: result.failureCount > 0 ? "warning" : "success",
|
||||
});
|
||||
setSelected(new Set());
|
||||
await query.reload();
|
||||
} catch (error) {
|
||||
showToast({ message: error?.message || "Host/Agency 工资结算失败", severity: "error" });
|
||||
} finally {
|
||||
setSettling(false);
|
||||
}
|
||||
};
|
||||
const settleSelected = () => {
|
||||
settleRows(items.filter((item) => selected.has(hostPendingKey(item))));
|
||||
};
|
||||
const tableColumns = [
|
||||
{
|
||||
key: "select",
|
||||
label: (
|
||||
<Checkbox
|
||||
checked={allVisibleSelected}
|
||||
disabled={!items.length}
|
||||
indeterminate={selected.size > 0 && !allVisibleSelected}
|
||||
size="small"
|
||||
onChange={toggleVisible}
|
||||
/>
|
||||
),
|
||||
width: "64px",
|
||||
resizable: false,
|
||||
render: (item) => (
|
||||
<Checkbox
|
||||
checked={selected.has(hostPendingKey(item))}
|
||||
size="small"
|
||||
onChange={() => toggleOne(item)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...hostPendingColumns,
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
width: "minmax(120px, 0.55fr)",
|
||||
render: (item) =>
|
||||
<Button
|
||||
disabled={!abilities.canSettleSalary || settling}
|
||||
size="small"
|
||||
startIcon={<PlayArrowOutlined fontSize="small" />}
|
||||
variant="secondary"
|
||||
onClick={() => settleRows([item])}
|
||||
>
|
||||
结算
|
||||
</Button>
|
||||
},
|
||||
];
|
||||
const hasFilters =
|
||||
role !== "bd" || settlementType !== "half_month" || cycleKey !== currentCycleKey() || countryCode || regionId;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminListToolbar
|
||||
actions={
|
||||
<Button
|
||||
disabled={!abilities.canSettleSalary || settling || selected.size === 0}
|
||||
startIcon={<PlayArrowOutlined fontSize="small" />}
|
||||
variant="primary"
|
||||
onClick={settleSelected}
|
||||
>
|
||||
批量结算
|
||||
</Button>
|
||||
}
|
||||
filters={
|
||||
<div className={styles.toolbarFilters}>
|
||||
<AdminFilterSelect label="角色" options={pendingRoleOptions} value={role} onChange={changeRole} />
|
||||
<AdminFilterSelect
|
||||
label="结算类型"
|
||||
options={settlementTypeOptions.filter(([value]) => value !== "")}
|
||||
value={settlementType}
|
||||
onChange={changeFilter(setSettlementType)}
|
||||
/>
|
||||
<AdminFilterSelect label="账单周期" options={cycleFilterOptions} value={cycleKey} onChange={changeFilter(setCycleKey)} />
|
||||
<AdminFilterSelect label="国家" options={countryFilterOptions} value={countryCode} onChange={changeFilter(setCountryCode)} />
|
||||
<AdminFilterSelect label="区域" options={regionFilterOptions} value={regionId} onChange={changeFilter(setRegionId)} />
|
||||
<AdminFilterResetButton disabled={!hasFilters} onClick={resetFilters} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={tableColumns}
|
||||
items={items}
|
||||
minWidth="1540px"
|
||||
pagination={paginationProps(data, page, total, setPage)}
|
||||
rowKey={hostPendingKey}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// HostRecordsPanel 保留原 Host/Agency 结算记录查询,避免新增 BD/Admin tab 后丢失主播工资查账入口。
|
||||
function HostRecordsPanel() {
|
||||
const [page, setPage] = useState(1);
|
||||
@ -212,28 +438,28 @@ function HostRecordsPanel() {
|
||||
}
|
||||
|
||||
// TeamPendingPanel 是人工结算入口:列表金额来自后端候选计算,批量按钮只提交当前勾选用户。
|
||||
function TeamPendingPanel() {
|
||||
function TeamPendingPanel({ onRoleChange, role }) {
|
||||
const abilities = useHostAgencyPolicyAbilities();
|
||||
const { showToast } = useToast();
|
||||
const { countryOptions, loadingCountries } = useCountryOptions();
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
const [page, setPage] = useState(1);
|
||||
const [policyType, setPolicyType] = useState("bd");
|
||||
const [triggerMode, setTriggerMode] = useState("automatic");
|
||||
const [cycleKey, setCycleKey] = useState(defaultCycleKey);
|
||||
const [countryCode, setCountryCode] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [selected, setSelected] = useState(() => new Set());
|
||||
const [settling, setSettling] = useState(false);
|
||||
|
||||
// BD/Admin 默认查看 automatic 上月周期;手动政策也可切换 triggerMode 后人工补发。
|
||||
// 待结算只处理人工政策;自动政策没有人工待结算入口,只在记录里查实际落库结果。
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
country_code: countryCode,
|
||||
cycle_key: cycleKey,
|
||||
policy_type: policyType,
|
||||
policy_type: role,
|
||||
region_id: regionId,
|
||||
trigger_mode: triggerMode,
|
||||
trigger_mode: "manual",
|
||||
}),
|
||||
[countryCode, cycleKey, policyType, regionId, triggerMode],
|
||||
[countryCode, cycleKey, regionId, role],
|
||||
);
|
||||
const query = usePaginatedQuery({
|
||||
errorMessage: "获取 BD/Admin 待结算列表失败",
|
||||
@ -253,14 +479,21 @@ function TeamPendingPanel() {
|
||||
setSelected(new Set());
|
||||
}, [filters]);
|
||||
|
||||
const countryFilterOptions = useMemo(() => [["", loadingCountries ? "加载国家中" : "全部国家"], ...countryOptions.map((item) => [item.value, item.label])], [countryOptions, loadingCountries]);
|
||||
const regionFilterOptions = useMemo(() => [["", loadingRegions ? "加载区域中" : "全部区域"], ...regionOptions.map((item) => [item.value, item.label])], [loadingRegions, regionOptions]);
|
||||
const cycleFilterOptions = useMemo(() => buildCycleOptions(cycleKey, 18), [cycleKey]);
|
||||
|
||||
const changeFilter = (setter) => (value) => {
|
||||
// 国家/区域/周期变化后必须重算候选,不保留旧页码。
|
||||
setter(value);
|
||||
setPage(1);
|
||||
};
|
||||
const changeRole = (value) => {
|
||||
onRoleChange(value);
|
||||
setPage(1);
|
||||
};
|
||||
const resetFilters = () => {
|
||||
setPolicyType("bd");
|
||||
setTriggerMode("automatic");
|
||||
onRoleChange("bd");
|
||||
setCycleKey(defaultCycleKey());
|
||||
setCountryCode("");
|
||||
setRegionId("");
|
||||
@ -298,8 +531,8 @@ function TeamPendingPanel() {
|
||||
// 只从当前页 items 中提取已勾选用户,服务端仍会再次按 user_ids 白名单过滤。
|
||||
const userIds = items
|
||||
.filter((item) => selected.has(teamPendingKey(item)))
|
||||
.map((item) => Number(item.userId))
|
||||
.filter((id) => Number.isFinite(id) && id > 0);
|
||||
.map((item) => String(item.userId || "").trim())
|
||||
.filter((id) => /^[1-9]\d*$/.test(id));
|
||||
if (!userIds.length) {
|
||||
return;
|
||||
}
|
||||
@ -309,9 +542,9 @@ function TeamPendingPanel() {
|
||||
const result = await settleTeamSalary({
|
||||
country_code: countryCode || undefined,
|
||||
cycle_key: cycleKey,
|
||||
policy_type: policyType,
|
||||
policy_type: role,
|
||||
region_id: Number(regionId || 0) || undefined,
|
||||
trigger_mode: triggerMode,
|
||||
trigger_mode: "manual",
|
||||
user_ids: userIds,
|
||||
});
|
||||
showToast({
|
||||
@ -347,9 +580,8 @@ function TeamPendingPanel() {
|
||||
},
|
||||
...teamPendingColumns,
|
||||
];
|
||||
// 默认值也纳入 hasFilters,这样运营能一键回到“BD + 自动政策 + 上月周期”的标准入口。
|
||||
const hasFilters =
|
||||
policyType !== "bd" || triggerMode !== "automatic" || cycleKey !== defaultCycleKey() || countryCode || regionId;
|
||||
// 默认值也纳入 hasFilters,这样运营能一键回到“BD + 手动政策 + 上月周期”的标准入口。
|
||||
const hasFilters = role !== "bd" || cycleKey !== defaultCycleKey() || countryCode || regionId;
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -368,34 +600,13 @@ function TeamPendingPanel() {
|
||||
<div className={styles.toolbarFilters}>
|
||||
<AdminFilterSelect
|
||||
label="角色"
|
||||
options={policyTypeOptions}
|
||||
value={policyType}
|
||||
onChange={changeFilter(setPolicyType)}
|
||||
/>
|
||||
<AdminFilterSelect
|
||||
label="政策触发"
|
||||
options={triggerOptions}
|
||||
value={triggerMode}
|
||||
onChange={changeFilter(setTriggerMode)}
|
||||
/>
|
||||
<InlineFilter
|
||||
label="周期"
|
||||
placeholder="YYYY-MM"
|
||||
value={cycleKey}
|
||||
onChange={changeFilter(setCycleKey)}
|
||||
/>
|
||||
<InlineFilter
|
||||
label="国家"
|
||||
placeholder="国家代码"
|
||||
value={countryCode}
|
||||
onChange={changeFilter(setCountryCode)}
|
||||
/>
|
||||
<InlineFilter
|
||||
label="区域"
|
||||
placeholder="区域 ID"
|
||||
value={regionId}
|
||||
onChange={changeFilter(setRegionId)}
|
||||
options={pendingRoleOptions}
|
||||
value={role}
|
||||
onChange={changeRole}
|
||||
/>
|
||||
<AdminFilterSelect label="账单周期" options={cycleFilterOptions} value={cycleKey} onChange={changeFilter(setCycleKey)} />
|
||||
<AdminFilterSelect label="国家" options={countryFilterOptions} value={countryCode} onChange={changeFilter(setCountryCode)} />
|
||||
<AdminFilterSelect label="区域" options={regionFilterOptions} value={regionId} onChange={changeFilter(setRegionId)} />
|
||||
<AdminFilterResetButton disabled={!hasFilters} onClick={resetFilters} />
|
||||
</div>
|
||||
}
|
||||
@ -612,6 +823,89 @@ const hostRecordColumns = [
|
||||
},
|
||||
];
|
||||
|
||||
// hostPendingColumns 展示 Host/Agency 当前账单差额;结算按钮单独放在操作列,便于自动政策行只读展示。
|
||||
const hostPendingColumns = [
|
||||
{
|
||||
key: "host",
|
||||
label: "主播",
|
||||
width: "minmax(240px, 1.1fr)",
|
||||
render: (item) => <UserCell fallbackId={item.userId} user={item.user} />,
|
||||
},
|
||||
{
|
||||
key: "agency",
|
||||
label: "代理",
|
||||
width: "minmax(220px, 1fr)",
|
||||
render: (item) => <UserCell fallbackId={item.agencyOwnerUserId} user={item.agencyOwner} />,
|
||||
},
|
||||
{
|
||||
key: "scope",
|
||||
label: "周期/政策",
|
||||
width: "minmax(230px, 1fr)",
|
||||
render: (item) => (
|
||||
<div className={styles.stack}>
|
||||
<span>
|
||||
{item.cycleKey || "-"} · {settlementTypeLabel(item.settlementType)}
|
||||
</span>
|
||||
<span className={styles.meta}>{item.regionName || `区域 ${item.regionId || "-"}`}</span>
|
||||
<span className={styles.meta}>{item.policyName || `政策 ${item.policyId || "-"}`}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "level",
|
||||
label: "等级/钻石",
|
||||
width: "minmax(160px, 0.75fr)",
|
||||
render: (item) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{item.levelNo > 0 ? `等级 ${item.levelNo}` : "未达等级"}</span>
|
||||
<span className={styles.meta}>{formatNumber(item.totalDiamonds)} 钻石</span>
|
||||
<span className={styles.meta}>门槛 {formatNumber(item.requiredDiamonds)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "hostReward",
|
||||
label: "主播待发",
|
||||
width: "minmax(170px, 0.8fr)",
|
||||
render: (item) => (
|
||||
<div className={styles.stack}>
|
||||
<span className={styles.amountPositive}>{formatMoneyDelta(item.hostSalaryUsdDelta)}</span>
|
||||
<span className={styles.meta}>金币 {formatSignedInteger(item.hostCoinRewardDelta)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "agencyReward",
|
||||
label: "代理/剩余",
|
||||
width: "minmax(170px, 0.8fr)",
|
||||
render: (item) => <AgencyRewardCell item={item} />,
|
||||
},
|
||||
{
|
||||
key: "total",
|
||||
label: "待发总额",
|
||||
width: "minmax(150px, 0.7fr)",
|
||||
render: (item) => <span className={styles.amountPositive}>{formatMoneyDelta(item.totalUsdDelta)}</span>,
|
||||
},
|
||||
{
|
||||
key: "progress",
|
||||
label: "已结进度",
|
||||
width: "minmax(170px, 0.8fr)",
|
||||
render: (item) => (
|
||||
<div className={styles.stack}>
|
||||
<span>等级 {item.settledLevelNo || 0}</span>
|
||||
<span className={styles.meta}>主播 ${item.settledHostSalaryUsd || "0"}</span>
|
||||
<span className={styles.meta}>代理 ${item.settledAgencySalaryUsd || "0"}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "minmax(120px, 0.55fr)",
|
||||
render: (item) => <StatusCell item={item} />,
|
||||
},
|
||||
];
|
||||
|
||||
// teamPendingColumns 展示 BD/Admin 的“现在会发多少钱”和“此前已发到哪一档”。
|
||||
const teamPendingColumns = [
|
||||
{
|
||||
@ -751,7 +1045,10 @@ function CycleCell({ item }) {
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<span>{item.cycleKey || "-"}</span>
|
||||
<span className={styles.meta}>{settlementTypeLabel(item.settlementType)}</span>
|
||||
<span className={styles.meta}>
|
||||
{settlementTypeLabel(item.settlementType)} · {triggerLabel(item.triggerMode)} ·{" "}
|
||||
{roleLabel(item.settlementRole)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -824,6 +1121,10 @@ function teamPendingKey(item) {
|
||||
return `${item.policyType}:${item.cycleKey}:${item.regionId}:${item.userId}`;
|
||||
}
|
||||
|
||||
function hostPendingKey(item) {
|
||||
return `${item.role}:${item.settlementType}:${item.cycleKey}:${item.regionId}:${item.userId}`;
|
||||
}
|
||||
|
||||
// userIdText 展示短 ID / 长 ID;筛选仍然用长 user_id。
|
||||
function userIdText(user, fallbackId) {
|
||||
const id = String(user?.userId || fallbackId || "").trim();
|
||||
@ -842,6 +1143,28 @@ function defaultCycleKey() {
|
||||
return `${firstOfMonth.getUTCFullYear()}-${String(firstOfMonth.getUTCMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// currentCycleKey 计算当前 UTC 自然月,和 Host/Agency 周期账户的 yyyy-MM 保持一致。
|
||||
function currentCycleKey() {
|
||||
const now = new Date();
|
||||
return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function buildCycleOptions(selectedCycleKey, monthCount) {
|
||||
const now = new Date();
|
||||
const anchor = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||
const options = [];
|
||||
for (let index = 0; index < monthCount; index += 1) {
|
||||
const item = new Date(anchor);
|
||||
item.setUTCMonth(anchor.getUTCMonth() - index);
|
||||
const value = `${item.getUTCFullYear()}-${String(item.getUTCMonth() + 1).padStart(2, "0")}`;
|
||||
options.push([value, value]);
|
||||
}
|
||||
if (selectedCycleKey && !options.some(([value]) => value === selectedCycleKey)) {
|
||||
return [[selectedCycleKey, selectedCycleKey], ...options];
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
// settlementTypeLabel 把 Host/Agency 结算类型枚举转成运营文案。
|
||||
function settlementTypeLabel(value) {
|
||||
return (
|
||||
@ -860,6 +1183,7 @@ function statusLabel(value) {
|
||||
return (
|
||||
{
|
||||
failed: "失败",
|
||||
pending: "待结算",
|
||||
skipped: "跳过",
|
||||
succeeded: "成功",
|
||||
}[value] ||
|
||||
@ -873,6 +1197,7 @@ function statusTone(value) {
|
||||
return (
|
||||
{
|
||||
failed: "danger",
|
||||
pending: "warning",
|
||||
skipped: "warning",
|
||||
succeeded: "running",
|
||||
}[value] || "stopped"
|
||||
@ -881,7 +1206,17 @@ function statusTone(value) {
|
||||
|
||||
// roleLabel 把 policy_type 展示为工资角色名称。
|
||||
function roleLabel(value) {
|
||||
return value === "admin" ? "Admin" : value === "bd" ? "BD" : value || "-";
|
||||
return (
|
||||
{
|
||||
admin: "Admin",
|
||||
agency: "Agency",
|
||||
all: "Host/Agency",
|
||||
bd: "BD",
|
||||
host: "Host",
|
||||
}[value] ||
|
||||
value ||
|
||||
"-"
|
||||
);
|
||||
}
|
||||
|
||||
// triggerLabel 把触发方式展示为自动/手动。
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
createCoinSeller,
|
||||
createRegion,
|
||||
creditCoinSellerStock,
|
||||
debitCoinSellerStock,
|
||||
deleteAgency,
|
||||
disableRegion,
|
||||
listAgencies,
|
||||
@ -33,8 +34,23 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
||||
),
|
||||
);
|
||||
|
||||
await listBDs({ page: 1, page_size: 10, parent_leader_user_id: 21, region_id: 7, status: "active" });
|
||||
await listAgencies({ keyword: "agency", page: 2, page_size: 10, parent_bd_user_id: 31 });
|
||||
await listBDs({
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
parent_leader_user_id: 21,
|
||||
region_id: 7,
|
||||
sort_by: "salary_wallet",
|
||||
sort_direction: "asc",
|
||||
status: "active",
|
||||
});
|
||||
await listAgencies({
|
||||
keyword: "agency",
|
||||
page: 2,
|
||||
page_size: 10,
|
||||
parent_bd_user_id: 31,
|
||||
sort_by: "salary_wallet",
|
||||
sort_direction: "desc",
|
||||
});
|
||||
await listHosts({ agency_id: 41, page: 1, page_size: 10, sort_by: "diamond", sort_direction: "desc" });
|
||||
await listCoinSellers({
|
||||
page: 1,
|
||||
@ -47,20 +63,25 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
||||
await createBDLeader({
|
||||
commandId: "bd-leader-test",
|
||||
positionAlias: "Senior Admin",
|
||||
targetUserId: 1002,
|
||||
targetUserId: "1002",
|
||||
});
|
||||
await updateBDLeaderPositionAlias(1002, {
|
||||
await updateBDLeaderPositionAlias("1002", {
|
||||
commandId: "bd-leader-position-alias-test",
|
||||
positionAlias: "Regional Admin",
|
||||
});
|
||||
await createCoinSeller({ commandId: "coin-seller-test", reason: "contact", targetUserId: 1001 });
|
||||
await setCoinSellerStatus(1001, { commandId: "coin-seller-status-test", reason: "disable", status: "disabled" });
|
||||
await creditCoinSellerStock(1001, {
|
||||
await createCoinSeller({ commandId: "coin-seller-test", reason: "contact", targetUserId: "1001" });
|
||||
await setCoinSellerStatus("1001", { commandId: "coin-seller-status-test", reason: "disable", status: "disabled" });
|
||||
await creditCoinSellerStock("1001", {
|
||||
coinAmount: 8000000,
|
||||
commandId: "coin-seller-stock-test",
|
||||
rechargeAmount: "100.000000",
|
||||
type: "usdt_purchase",
|
||||
});
|
||||
await debitCoinSellerStock("1001", {
|
||||
coinAmount: 1000,
|
||||
commandId: "coin-seller-stock-debit-test",
|
||||
reason: "deduct bad stock",
|
||||
});
|
||||
await deleteAgency(7001, { commandId: "agency-delete-test", reason: "cleanup" });
|
||||
|
||||
const [bdUrl, bdInit] = vi.mocked(fetch).mock.calls[0];
|
||||
@ -72,15 +93,20 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
||||
const [coinSellerCreateUrl, coinSellerCreateInit] = vi.mocked(fetch).mock.calls[6];
|
||||
const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[7];
|
||||
const [coinSellerStockUrl, coinSellerStockInit] = vi.mocked(fetch).mock.calls[8];
|
||||
const [agencyDeleteUrl, agencyDeleteInit] = vi.mocked(fetch).mock.calls[9];
|
||||
const [coinSellerStockDebitUrl, coinSellerStockDebitInit] = vi.mocked(fetch).mock.calls[9];
|
||||
const [agencyDeleteUrl, agencyDeleteInit] = vi.mocked(fetch).mock.calls[10];
|
||||
|
||||
expect(String(bdUrl)).toContain("/api/v1/admin/bds?");
|
||||
expect(String(bdUrl)).toContain("parent_leader_user_id=21");
|
||||
expect(String(bdUrl)).toContain("region_id=7");
|
||||
expect(String(bdUrl)).toContain("sort_by=salary_wallet");
|
||||
expect(String(bdUrl)).toContain("sort_direction=asc");
|
||||
expect(String(bdUrl)).toContain("status=active");
|
||||
expect(bdInit?.method).toBe("GET");
|
||||
expect(String(agencyUrl)).toContain("/api/v1/admin/agencies?");
|
||||
expect(String(agencyUrl)).toContain("parent_bd_user_id=31");
|
||||
expect(String(agencyUrl)).toContain("sort_by=salary_wallet");
|
||||
expect(String(agencyUrl)).toContain("sort_direction=desc");
|
||||
expect(String(hostUrl)).toContain("/api/v1/admin/hosts?");
|
||||
expect(String(hostUrl)).toContain("agency_id=41");
|
||||
expect(String(hostUrl)).toContain("sort_by=diamond");
|
||||
@ -104,6 +130,8 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
||||
expect(coinSellerStatusInit?.method).toBe("PATCH");
|
||||
expect(String(coinSellerStockUrl)).toContain("/api/v1/admin/coin-sellers/1001/stock-credits");
|
||||
expect(coinSellerStockInit?.method).toBe("POST");
|
||||
expect(String(coinSellerStockDebitUrl)).toContain("/api/v1/admin/coin-sellers/1001/stock-debits");
|
||||
expect(coinSellerStockDebitInit?.method).toBe("POST");
|
||||
expect(String(agencyDeleteUrl)).toContain("/api/v1/admin/agencies/7001/delete");
|
||||
expect(agencyDeleteInit?.method).toBe("POST");
|
||||
expect(JSON.parse(String(agencyDeleteInit?.body))).toMatchObject({ commandId: "agency-delete-test" });
|
||||
|
||||
@ -14,6 +14,8 @@ import type {
|
||||
CoinSellerSalaryRatesPayload,
|
||||
CoinSellerStockCreditDto,
|
||||
CoinSellerStockCreditPayload,
|
||||
CoinSellerStockDebitDto,
|
||||
CoinSellerStockDebitPayload,
|
||||
CoinSellerStatusPayload,
|
||||
CountryDto,
|
||||
CountryPayload,
|
||||
@ -31,6 +33,9 @@ import type {
|
||||
RegionDto,
|
||||
RegionPayload,
|
||||
RegionUpdatePayload,
|
||||
SalaryWalletAdjustPayload,
|
||||
SalaryWalletAdjustResultDto,
|
||||
SalaryWalletHistoryDto,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
export function listCountries(query: PageQuery = {}): Promise<ApiList<CountryDto>> {
|
||||
@ -179,6 +184,20 @@ export function listCoinSellers(query: PageQuery = {}): Promise<ApiPage<CoinSell
|
||||
});
|
||||
}
|
||||
|
||||
export function listSalaryWalletHistory(query: PageQuery = {}): Promise<ApiPage<SalaryWalletHistoryDto>> {
|
||||
return apiRequest<ApiPage<SalaryWalletHistoryDto>>("/v1/admin/host-org/salary-wallets/history", {
|
||||
method: "GET",
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
export function adjustSalaryWallet(payload: SalaryWalletAdjustPayload): Promise<SalaryWalletAdjustResultDto> {
|
||||
return apiRequest<SalaryWalletAdjustResultDto, SalaryWalletAdjustPayload>("/v1/admin/host-org/salary-wallets/adjust", {
|
||||
body: payload,
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export function createBDLeader(payload: CreateBDLeaderPayload): Promise<BDProfileDto> {
|
||||
const endpoint = API_ENDPOINTS.createBDLeader;
|
||||
return apiRequest<BDProfileDto, CreateBDLeaderPayload>(apiEndpointPath(API_OPERATIONS.createBDLeader), {
|
||||
@ -266,6 +285,19 @@ export function creditCoinSellerStock(
|
||||
);
|
||||
}
|
||||
|
||||
export function debitCoinSellerStock(
|
||||
userId: EntityId,
|
||||
payload: CoinSellerStockDebitPayload,
|
||||
): Promise<CoinSellerStockDebitDto> {
|
||||
return apiRequest<CoinSellerStockDebitDto, CoinSellerStockDebitPayload>(
|
||||
`/v1/admin/coin-sellers/${encodeURIComponent(String(userId))}/stock-debits`,
|
||||
{
|
||||
body: payload,
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getCoinSellerSalaryRates(regionId: EntityId): Promise<CoinSellerSalaryRatesDto> {
|
||||
const endpoint = API_ENDPOINTS.getCoinSellerSalaryRates;
|
||||
return apiRequest<CoinSellerSalaryRatesDto>(
|
||||
|
||||
32
src/features/host-org/components/HostOrgSortHeader.jsx
Normal file
32
src/features/host-org/components/HostOrgSortHeader.jsx
Normal file
@ -0,0 +1,32 @@
|
||||
import ArrowDownwardOutlined from "@mui/icons-material/ArrowDownwardOutlined";
|
||||
import ArrowUpwardOutlined from "@mui/icons-material/ArrowUpwardOutlined";
|
||||
import SwapVertOutlined from "@mui/icons-material/SwapVertOutlined";
|
||||
|
||||
export function HostOrgSortHeader({ field, label, onToggle, sortBy, sortDirection }) {
|
||||
const active = sortBy === field;
|
||||
const SortIcon =
|
||||
active && sortDirection === "asc"
|
||||
? ArrowUpwardOutlined
|
||||
: active && sortDirection === "desc"
|
||||
? ArrowDownwardOutlined
|
||||
: SwapVertOutlined;
|
||||
const directionLabel = sortDirection === "asc" ? "升序" : "降序";
|
||||
const nextDirectionLabel = active && sortDirection === "desc" ? "升序" : "降序";
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={`按${label}${nextDirectionLabel}排序`}
|
||||
className={["admin-cell__head-trigger", active ? "admin-cell__head-trigger--active" : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
type="button"
|
||||
onClick={() => onToggle(field)}
|
||||
>
|
||||
<SortIcon className="admin-cell__head-icon" fontSize="inherit" />
|
||||
<span className="admin-cell__head-label">
|
||||
{label}
|
||||
{active ? <span className="admin-cell__head-filter">({directionLabel})</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
355
src/features/host-org/components/SalaryWalletControls.jsx
Normal file
355
src/features/host-org/components/SalaryWalletControls.jsx
Normal file
@ -0,0 +1,355 @@
|
||||
import AccountBalanceWalletOutlined from "@mui/icons-material/AccountBalanceWalletOutlined";
|
||||
import PaidOutlined from "@mui/icons-material/PaidOutlined";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { adjustSalaryWallet, listSalaryWalletHistory } from "@/features/host-org/api";
|
||||
import styles from "@/features/host-org/host-org.module.css";
|
||||
import {
|
||||
AdminFormAmountField,
|
||||
AdminFormDialog,
|
||||
AdminFormFieldGrid,
|
||||
AdminFormReadOnlyField,
|
||||
AdminFormSection,
|
||||
} from "@/shared/ui/AdminFormDialog.jsx";
|
||||
import { AdminActionIconButton } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
|
||||
const historyPageSize = 20;
|
||||
|
||||
const emptyHistory = { items: [], page: 1, pageSize: historyPageSize, total: 0 };
|
||||
|
||||
const emptyAdjustForm = () => ({
|
||||
amount: "",
|
||||
reason: "",
|
||||
type: "increase",
|
||||
});
|
||||
|
||||
const historyColumns = [
|
||||
{
|
||||
key: "createdAtMs",
|
||||
label: "时间",
|
||||
render: (item) => formatMillis(item.createdAtMs),
|
||||
width: "minmax(170px, 0.9fr)",
|
||||
},
|
||||
{
|
||||
key: "amountMinor",
|
||||
label: "金额",
|
||||
render: (item) => <SalaryHistoryAmount item={item} />,
|
||||
width: "minmax(120px, 0.65fr)",
|
||||
},
|
||||
{
|
||||
key: "balance",
|
||||
label: "余额",
|
||||
render: (item) => formatSalaryWalletAmount(item.availableAfter),
|
||||
width: "minmax(120px, 0.65fr)",
|
||||
},
|
||||
{
|
||||
key: "bizType",
|
||||
label: "类型",
|
||||
render: (item) => salaryWalletBizLabel(item.bizType),
|
||||
width: "minmax(150px, 0.8fr)",
|
||||
},
|
||||
{
|
||||
key: "operator",
|
||||
label: "操作人",
|
||||
render: (item) => salaryWalletOperatorName(item),
|
||||
width: "minmax(150px, 0.8fr)",
|
||||
},
|
||||
{
|
||||
key: "reason",
|
||||
label: "原因",
|
||||
render: (item) => item.reason || "-",
|
||||
width: "minmax(220px, 1.15fr)",
|
||||
},
|
||||
];
|
||||
|
||||
export function SalaryWalletCell({ role, userId, userLabel, wallet }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const balance = Number(wallet?.availableAmount || 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={styles.salaryWalletButton}
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<span className={styles.salaryWalletAmount}>{formatSalaryWalletAmount(balance)}</span>
|
||||
</button>
|
||||
<SalaryWalletHistoryDrawer
|
||||
open={open}
|
||||
role={role}
|
||||
userId={userId}
|
||||
userLabel={userLabel}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SalaryWalletAdjustButton({ disabled = false, onAdjusted, role, userId, userLabel }) {
|
||||
const { showToast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [form, setForm] = useState(emptyAdjustForm);
|
||||
|
||||
const close = () => {
|
||||
if (submitting) {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
setForm(emptyAdjustForm());
|
||||
};
|
||||
|
||||
const submit = async (event) => {
|
||||
event.preventDefault();
|
||||
let amountMinor;
|
||||
try {
|
||||
amountMinor = parseSalaryUSDMinor(form.amount);
|
||||
if (!String(form.reason || "").trim()) {
|
||||
throw new Error("请填写原因");
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(err.message || "工资操作参数不正确", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await adjustSalaryWallet({
|
||||
amountMinor,
|
||||
commandId: makeSalaryWalletCommandId(role, userId),
|
||||
reason: String(form.reason || "").trim(),
|
||||
role,
|
||||
targetUserId: userId,
|
||||
type: form.type,
|
||||
});
|
||||
showToast("工资钱包已更新", "success");
|
||||
setOpen(false);
|
||||
setForm(emptyAdjustForm());
|
||||
await onAdjusted?.(result);
|
||||
} catch (err) {
|
||||
showToast(err.message || "工资钱包操作失败", "error");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminActionIconButton
|
||||
disabled={disabled || !role || !userId}
|
||||
label="工资操作"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<PaidOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminFormDialog
|
||||
disabled={disabled}
|
||||
loading={submitting}
|
||||
open={open}
|
||||
size="large"
|
||||
submitDisabled={disabled || submitting || !role || !userId}
|
||||
submitLabel="确认"
|
||||
title="工资操作"
|
||||
onClose={close}
|
||||
onSubmit={submit}
|
||||
>
|
||||
<AdminFormSection title="目标身份">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<AdminFormReadOnlyField label="身份" value={salaryWalletRoleLabel(role)} />
|
||||
<AdminFormReadOnlyField label="用户" value={userLabel || userId || "-"} />
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="操作信息">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<TextField
|
||||
disabled={disabled || submitting}
|
||||
label="类型"
|
||||
required
|
||||
select
|
||||
value={form.type}
|
||||
onChange={(event) => setForm((current) => ({ ...current, type: event.target.value }))}
|
||||
>
|
||||
<MenuItem value="increase">工资增加</MenuItem>
|
||||
<MenuItem value="decrease">工资减少</MenuItem>
|
||||
</TextField>
|
||||
<AdminFormAmountField
|
||||
disabled={disabled || submitting}
|
||||
label="金额"
|
||||
required
|
||||
unit="USD"
|
||||
value={form.amount}
|
||||
onChange={(event) => setForm((current) => ({ ...current, amount: event.target.value }))}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
<TextField
|
||||
disabled={disabled || submitting}
|
||||
label="原因"
|
||||
multiline
|
||||
minRows={3}
|
||||
required
|
||||
value={form.reason}
|
||||
onChange={(event) => setForm((current) => ({ ...current, reason: event.target.value }))}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
</AdminFormDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SalaryWalletHistoryDrawer({ onClose, open, role, userId, userLabel }) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [data, setData] = useState(emptyHistory);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
if (!open || !role || !userId) {
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
const nextData = await listSalaryWalletHistory({
|
||||
page,
|
||||
page_size: historyPageSize,
|
||||
role,
|
||||
user_id: userId,
|
||||
});
|
||||
setData(nextData || emptyHistory);
|
||||
} catch (err) {
|
||||
setError(err.message || "加载工资钱包历史失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [open, page, role, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
void loadHistory();
|
||||
}, [loadHistory, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setPage(1);
|
||||
}
|
||||
}, [open, role, userId]);
|
||||
|
||||
const total = data.total || 0;
|
||||
|
||||
return (
|
||||
<SideDrawer
|
||||
className={styles.salaryWalletDrawer}
|
||||
contentClassName={styles.salaryWalletDrawerBody}
|
||||
open={open}
|
||||
title={`工资钱包历史 - ${userLabel || userId || ""}`}
|
||||
width="wide"
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className={styles.salaryWalletSummary}>
|
||||
<AccountBalanceWalletOutlined fontSize="small" />
|
||||
<span>{salaryWalletRoleLabel(role)}</span>
|
||||
</div>
|
||||
<DataState error={error} loading={loading} onRetry={loadHistory}>
|
||||
<DataTable
|
||||
columns={historyColumns}
|
||||
emptyLabel="暂无工资钱包历史"
|
||||
items={data.items || []}
|
||||
minWidth="930px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
page,
|
||||
pageSize: data.pageSize || historyPageSize,
|
||||
total,
|
||||
onPageChange: setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(item) => item.entryId || item.transactionId}
|
||||
/>
|
||||
</DataState>
|
||||
</SideDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
function SalaryHistoryAmount({ item }) {
|
||||
const delta = Number(item.availableDelta ?? item.amountMinor ?? 0);
|
||||
const expense = delta < 0;
|
||||
return (
|
||||
<span className={expense ? styles.salaryWalletExpense : styles.salaryWalletIncome}>
|
||||
{expense ? "-" : "+"}
|
||||
{formatSalaryWalletAmount(Math.abs(delta))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatSalaryWalletAmount(value) {
|
||||
const minor = Number(value || 0);
|
||||
return `$${(minor / 100).toLocaleString("en-US", {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: 2,
|
||||
})}`;
|
||||
}
|
||||
|
||||
function parseSalaryUSDMinor(value) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!/^\d+(\.\d{1,2})?$/.test(raw)) {
|
||||
throw new Error("请输入正确的 USD 金额,最多两位小数");
|
||||
}
|
||||
const [integerPart, decimalPart = ""] = raw.split(".");
|
||||
const amountMinor = Number(integerPart) * 100 + Number(decimalPart.padEnd(2, "0"));
|
||||
if (!Number.isSafeInteger(amountMinor) || amountMinor <= 0) {
|
||||
throw new Error("请输入大于 0 的工资金额");
|
||||
}
|
||||
return amountMinor;
|
||||
}
|
||||
|
||||
function salaryWalletRoleLabel(role) {
|
||||
switch (role) {
|
||||
case "host":
|
||||
return "Host";
|
||||
case "agency":
|
||||
return "Agency";
|
||||
case "bd":
|
||||
return "BD";
|
||||
case "bd_leader":
|
||||
return "BD Leader";
|
||||
default:
|
||||
return role || "-";
|
||||
}
|
||||
}
|
||||
|
||||
function salaryWalletBizLabel(value) {
|
||||
switch (value) {
|
||||
case "manual_credit":
|
||||
return "手工调整";
|
||||
case "salary_settlement":
|
||||
case "host_salary_settlement":
|
||||
case "team_salary_settlement":
|
||||
return "工资结算";
|
||||
case "transfer":
|
||||
return "转账";
|
||||
case "withdraw":
|
||||
return "提现";
|
||||
default:
|
||||
return value || "-";
|
||||
}
|
||||
}
|
||||
|
||||
function salaryWalletOperatorName(item) {
|
||||
const operator = item.operator || {};
|
||||
return operator.name || operator.username || item.operatorUserId || "-";
|
||||
}
|
||||
|
||||
function makeSalaryWalletCommandId(role, userId) {
|
||||
return `salary-wallet-${role || "role"}-${userId || "user"}-${Date.now()}`;
|
||||
}
|
||||
@ -28,6 +28,8 @@ export function useHostAgenciesPage() {
|
||||
const [status, setStatus] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [parentBdUserId, setParentBdUserId] = useState("");
|
||||
const [sortBy, setSortBy] = useState("");
|
||||
const [sortDirection, setSortDirection] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [agencyForm, setAgencyForm] = useState(emptyAgencyForm);
|
||||
const [rowPatches, setRowPatches] = useState({});
|
||||
@ -39,9 +41,11 @@ export function useHostAgenciesPage() {
|
||||
keyword: query,
|
||||
parent_bd_user_id: parentBdUserId,
|
||||
region_id: regionId,
|
||||
sort_by: sortBy,
|
||||
sort_direction: sortDirection,
|
||||
status,
|
||||
}),
|
||||
[parentBdUserId, query, regionId, status],
|
||||
[parentBdUserId, query, regionId, sortBy, sortDirection, status],
|
||||
);
|
||||
const {
|
||||
data = emptyData,
|
||||
@ -97,11 +101,19 @@ export function useHostAgenciesPage() {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeSort = (field) => {
|
||||
setSortDirection((current) => (sortBy === field ? (current === "asc" ? "desc" : "asc") : "desc"));
|
||||
setSortBy(field);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setStatus("");
|
||||
setRegionId("");
|
||||
setParentBdUserId("");
|
||||
setSortBy("");
|
||||
setSortDirection("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
@ -212,8 +224,11 @@ export function useHostAgenciesPage() {
|
||||
removeAgencyFromList,
|
||||
setAgencyForm,
|
||||
setPage,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
status,
|
||||
submitAgency,
|
||||
changeSort,
|
||||
toggleAgencyJoinEnabledFromList,
|
||||
};
|
||||
}
|
||||
|
||||
@ -38,6 +38,8 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
|
||||
const [status, setStatus] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [parentLeaderUserId, setParentLeaderUserId] = useState("");
|
||||
const [sortBy, setSortBy] = useState("");
|
||||
const [sortDirection, setSortDirection] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [bdLeaderForm, setBDLeaderForm] = useState(emptyBDLeaderForm);
|
||||
const [bdForm, setBDForm] = useState(emptyBDForm);
|
||||
@ -54,9 +56,11 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
|
||||
keyword: query,
|
||||
parent_leader_user_id: profileType === "bd" ? parentLeaderUserId : "",
|
||||
region_id: regionId,
|
||||
sort_by: sortBy,
|
||||
sort_direction: sortDirection,
|
||||
status,
|
||||
}),
|
||||
[parentLeaderUserId, profileType, query, regionId, status],
|
||||
[parentLeaderUserId, profileType, query, regionId, sortBy, sortDirection, status],
|
||||
);
|
||||
const fetcher = profileType === "bd" ? listBDs : listBDLeaders;
|
||||
const {
|
||||
@ -158,11 +162,19 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeSort = (field) => {
|
||||
setSortDirection((current) => (sortBy === field ? (current === "asc" ? "desc" : "asc") : "desc"));
|
||||
setSortBy(field);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setQuery("");
|
||||
setStatus("");
|
||||
setRegionId("");
|
||||
setParentLeaderUserId("");
|
||||
setSortBy("");
|
||||
setSortDirection("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
@ -361,10 +373,13 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
|
||||
setBDForm,
|
||||
setBDLeaderForm,
|
||||
setPage,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
status,
|
||||
submitBD,
|
||||
submitBDLeader,
|
||||
submitLeaderBD,
|
||||
changeSort,
|
||||
toggleBD,
|
||||
toggleBDLeader,
|
||||
};
|
||||
|
||||
@ -6,6 +6,7 @@ import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
import {
|
||||
createCoinSeller,
|
||||
creditCoinSellerStock,
|
||||
debitCoinSellerStock,
|
||||
getCoinSellerSalaryRates,
|
||||
listCoinSellers,
|
||||
replaceCoinSellerSalaryRates,
|
||||
@ -15,6 +16,7 @@ import { useCoinSellerAbilities } from "@/features/host-org/permissions.js";
|
||||
import {
|
||||
coinSellerStatusSchema,
|
||||
coinSellerStockCreditSchema,
|
||||
coinSellerStockDebitSchema,
|
||||
createCoinSellerSchema,
|
||||
} from "@/features/host-org/schema";
|
||||
|
||||
@ -35,6 +37,11 @@ const emptyStockForm = () => ({
|
||||
rechargeAmount: "",
|
||||
type: "usdt_purchase",
|
||||
});
|
||||
const emptyStockDebitForm = () => ({
|
||||
coinAmount: "",
|
||||
commandId: makeCommandId("coin-seller-stock-debit"),
|
||||
reason: "",
|
||||
});
|
||||
const emptyRateTier = (index = 0) => ({
|
||||
coinPerUsd: "",
|
||||
enabled: true,
|
||||
@ -57,6 +64,7 @@ export function useHostCoinSellersPage() {
|
||||
const [createForm, setCreateForm] = useState(emptyCreateForm);
|
||||
const [editForm, setEditForm] = useState(emptyEditForm);
|
||||
const [stockForm, setStockForm] = useState(emptyStockForm);
|
||||
const [stockDebitForm, setStockDebitForm] = useState(emptyStockDebitForm);
|
||||
const [rateRegionId, setRateRegionId] = useState("");
|
||||
const [rateTiers, setRateTiers] = useState([]);
|
||||
const [loadingRates, setLoadingRates] = useState(false);
|
||||
@ -158,6 +166,15 @@ export function useHostCoinSellersPage() {
|
||||
setActiveAction("stock");
|
||||
};
|
||||
|
||||
const openStockDebit = (seller) => {
|
||||
if (!seller?.userId || seller.status !== "active" || !abilities.canStockCredit) {
|
||||
return;
|
||||
}
|
||||
setSelectedSeller(seller);
|
||||
setStockDebitForm(emptyStockDebitForm());
|
||||
setActiveAction("stock-debit");
|
||||
};
|
||||
|
||||
const openSellerLedger = (seller) => {
|
||||
if (!seller?.userId || !abilities.canLedger) {
|
||||
return;
|
||||
@ -308,6 +325,20 @@ export function useHostCoinSellersPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const submitStockDebit = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!selectedSeller?.userId) {
|
||||
return;
|
||||
}
|
||||
await runAction(`coin-seller-stock-debit-${selectedSeller.userId}`, "币商金币已扣除", async () => {
|
||||
const payload = parseForm(coinSellerStockDebitSchema, stockDebitForm);
|
||||
await debitCoinSellerStock(selectedSeller.userId, payload);
|
||||
setStockDebitForm(emptyStockDebitForm());
|
||||
closeAction();
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
|
||||
const submitRateSettings = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!rateRegionId) {
|
||||
@ -383,6 +414,7 @@ export function useHostCoinSellersPage() {
|
||||
openEditSeller,
|
||||
openSellerLedger,
|
||||
openStockCredit,
|
||||
openStockDebit,
|
||||
page,
|
||||
query,
|
||||
regionId,
|
||||
@ -400,7 +432,9 @@ export function useHostCoinSellersPage() {
|
||||
setEditForm,
|
||||
setPage,
|
||||
setStockForm,
|
||||
setStockDebitForm,
|
||||
status,
|
||||
stockDebitForm,
|
||||
stockForm,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
@ -408,6 +442,7 @@ export function useHostCoinSellersPage() {
|
||||
submitCreateSeller,
|
||||
submitEditSeller,
|
||||
submitStockCredit,
|
||||
submitStockDebit,
|
||||
toggleSeller,
|
||||
updateRateTier,
|
||||
};
|
||||
|
||||
@ -14,18 +14,19 @@ export function useHostHostsPage() {
|
||||
const [status, setStatus] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [agencyId, setAgencyId] = useState("");
|
||||
const [diamondSortDirection, setDiamondSortDirection] = useState("");
|
||||
const [sortBy, setSortBy] = useState("");
|
||||
const [sortDirection, setSortDirection] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
agency_id: agencyId,
|
||||
keyword: query,
|
||||
region_id: regionId,
|
||||
sort_by: diamondSortDirection ? "diamond" : "",
|
||||
sort_direction: diamondSortDirection,
|
||||
sort_by: sortBy,
|
||||
sort_direction: sortDirection,
|
||||
status,
|
||||
}),
|
||||
[agencyId, diamondSortDirection, query, regionId, status],
|
||||
[agencyId, query, regionId, sortBy, sortDirection, status],
|
||||
);
|
||||
const {
|
||||
data = emptyData,
|
||||
@ -61,8 +62,9 @@ export function useHostHostsPage() {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const toggleDiamondSort = () => {
|
||||
setDiamondSortDirection((current) => (current === "desc" ? "asc" : "desc"));
|
||||
const changeSort = (field) => {
|
||||
setSortDirection((current) => (sortBy === field ? (current === "asc" ? "desc" : "asc") : "desc"));
|
||||
setSortBy(field);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
@ -71,7 +73,8 @@ export function useHostHostsPage() {
|
||||
setStatus("");
|
||||
setRegionId("");
|
||||
setAgencyId("");
|
||||
setDiamondSortDirection("");
|
||||
setSortBy("");
|
||||
setSortDirection("");
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
@ -83,7 +86,7 @@ export function useHostHostsPage() {
|
||||
changeRegionId,
|
||||
changeStatus,
|
||||
data,
|
||||
diamondSortDirection,
|
||||
diamondSortDirection: sortBy === "diamond" ? sortDirection : "",
|
||||
error,
|
||||
loading,
|
||||
loadingRegions,
|
||||
@ -94,7 +97,10 @@ export function useHostHostsPage() {
|
||||
reload,
|
||||
resetFilters,
|
||||
setPage,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
status,
|
||||
toggleDiamondSort,
|
||||
changeSort,
|
||||
toggleDiamondSort: () => changeSort("diamond"),
|
||||
};
|
||||
}
|
||||
|
||||
@ -276,6 +276,77 @@
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.salaryWalletButton {
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
min-height: 34px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.salaryWalletButton:hover .salaryWalletAmount {
|
||||
color: var(--primary-strong);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.salaryWalletAmount {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--primary);
|
||||
font-weight: 780;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.salaryWalletDrawer.salaryWalletDrawer {
|
||||
width: min(980px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.salaryWalletDrawerBody.salaryWalletDrawerBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.salaryWalletDrawerBody :global(.data-state) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.salaryWalletSummary {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--admin-font-size);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.salaryWalletIncome {
|
||||
color: var(--success);
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.salaryWalletExpense {
|
||||
color: var(--danger);
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.contactPlaceholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
@ -7,6 +7,8 @@ import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { agencyStatusFilters } from "@/features/host-org/constants.js";
|
||||
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||
import { HostOrgEntity, HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
|
||||
import { HostOrgSortHeader } from "@/features/host-org/components/HostOrgSortHeader.jsx";
|
||||
import { SalaryWalletAdjustButton, SalaryWalletCell } from "@/features/host-org/components/SalaryWalletControls.jsx";
|
||||
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
||||
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
|
||||
import { useHostAgenciesPage } from "@/features/host-org/hooks/useHostAgenciesPage.js";
|
||||
@ -27,6 +29,21 @@ const agencyColumns = [
|
||||
render: (item) => <AgencyOwner item={item} />,
|
||||
width: "minmax(220px, 1.2fr)",
|
||||
},
|
||||
{
|
||||
key: "salaryWallet",
|
||||
label: "工资钱包",
|
||||
sortable: true,
|
||||
sortField: "salary_wallet",
|
||||
render: (item) => (
|
||||
<SalaryWalletCell
|
||||
role="agency"
|
||||
userId={item.ownerUserId}
|
||||
userLabel={agencyOwnerLabel(item)}
|
||||
wallet={item.salaryWallet}
|
||||
/>
|
||||
),
|
||||
width: "minmax(140px, 0.85fr)",
|
||||
},
|
||||
{
|
||||
key: "parentBdUserId",
|
||||
label: "上级 BD",
|
||||
@ -61,7 +78,7 @@ const agencyColumns = [
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
render: (item, _index, context) => <AgencyActions item={item} page={context?.page} />,
|
||||
width: "minmax(80px, 0.45fr)",
|
||||
width: "minmax(118px, 0.6fr)",
|
||||
},
|
||||
];
|
||||
|
||||
@ -107,15 +124,27 @@ export function HostAgenciesPage() {
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.sortable) {
|
||||
return {
|
||||
...column,
|
||||
header: (
|
||||
<HostOrgSortHeader
|
||||
field={column.sortField}
|
||||
label={column.label}
|
||||
sortBy={page.sortBy}
|
||||
sortDirection={page.sortDirection}
|
||||
onToggle={page.changeSort}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
return column;
|
||||
});
|
||||
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
<HostOrgToolbar
|
||||
actions={toolbarActions}
|
||||
/>
|
||||
<HostOrgToolbar actions={toolbarActions} />
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
@ -123,7 +152,7 @@ export function HostAgenciesPage() {
|
||||
columns={columns}
|
||||
context={{ page, regionOptions: page.regionOptions }}
|
||||
items={items}
|
||||
minWidth="1180px"
|
||||
minWidth="1340px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
@ -177,9 +206,7 @@ export function HostAgenciesPage() {
|
||||
disabled={createDisabled}
|
||||
label="入会状态"
|
||||
uncheckedLabel="禁止"
|
||||
onChange={(event) =>
|
||||
page.setAgencyForm({ ...page.agencyForm, joinEnabled: event.target.checked })
|
||||
}
|
||||
onChange={(event) => page.setAgencyForm({ ...page.agencyForm, joinEnabled: event.target.checked })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={createDisabled}
|
||||
@ -247,6 +274,14 @@ function AgencyJoinSwitch({ item, page }) {
|
||||
function AgencyActions({ item, page }) {
|
||||
return (
|
||||
<AdminRowActions>
|
||||
{page?.abilities.canSalaryAdjust ? (
|
||||
<SalaryWalletAdjustButton
|
||||
role="agency"
|
||||
userId={item.ownerUserId}
|
||||
userLabel={agencyOwnerLabel(item)}
|
||||
onAdjusted={page.reload}
|
||||
/>
|
||||
) : null}
|
||||
{page?.abilities.canDelete ? (
|
||||
<AdminActionIconButton
|
||||
disabled={page.loadingAction === `agency-delete-${item.agencyId}`}
|
||||
@ -269,3 +304,7 @@ const dangerActionSx = {
|
||||
backgroundColor: "var(--danger-surface)",
|
||||
},
|
||||
};
|
||||
|
||||
function agencyOwnerLabel(item) {
|
||||
return item.ownerDisplayUserId || item.ownerUsername || item.ownerUserId;
|
||||
}
|
||||
|
||||
@ -10,10 +10,14 @@ import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLay
|
||||
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||
import { HostOrgSortHeader } from "@/features/host-org/components/HostOrgSortHeader.jsx";
|
||||
import { SalaryWalletAdjustButton, SalaryWalletCell } from "@/features/host-org/components/SalaryWalletControls.jsx";
|
||||
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
|
||||
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
||||
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
|
||||
import { useHostBdsPage } from "@/features/host-org/hooks/useHostBdsPage.js";
|
||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import styles from "@/features/host-org/host-org.module.css";
|
||||
|
||||
const bdLeaderBaseColumns = [
|
||||
@ -29,6 +33,21 @@ const bdLeaderBaseColumns = [
|
||||
render: (item, _index, context) => regionName(item, context?.regionOptions),
|
||||
width: "minmax(130px, 0.75fr)",
|
||||
},
|
||||
{
|
||||
key: "salaryWallet",
|
||||
label: "工资钱包",
|
||||
sortable: true,
|
||||
sortField: "salary_wallet",
|
||||
render: (item) => (
|
||||
<SalaryWalletCell
|
||||
role="bd_leader"
|
||||
userId={item.userId}
|
||||
userLabel={leaderUserLabel(item)}
|
||||
wallet={item.salaryWallet}
|
||||
/>
|
||||
),
|
||||
width: "minmax(140px, 0.85fr)",
|
||||
},
|
||||
{
|
||||
key: "positionAlias",
|
||||
label: "职位别名",
|
||||
@ -66,17 +85,51 @@ export function HostBdLeadersPage() {
|
||||
const createDisabled = !page.abilities.canCreate;
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const regionFilter = createRegionColumnFilter({
|
||||
loading: page.loadingRegions,
|
||||
options: page.regionOptions,
|
||||
value: page.regionId,
|
||||
onChange: page.changeRegionId,
|
||||
});
|
||||
const bdLeaderColumns = [
|
||||
...bdLeaderBaseColumns,
|
||||
page.abilities.canCreate || page.abilities.canUpdate
|
||||
page.abilities.canCreate || page.abilities.canUpdate || page.abilities.canSalaryAdjust
|
||||
? {
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
render: (item) => <BDLeaderActions item={item} page={page} />,
|
||||
width: "minmax(118px, 0.7fr)",
|
||||
width: "minmax(154px, 0.8fr)",
|
||||
}
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map((column) => {
|
||||
if (column.key === "user") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索用户 ID、展示 ID、用户名",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.sortable) {
|
||||
return {
|
||||
...column,
|
||||
header: (
|
||||
<HostOrgSortHeader
|
||||
field={column.sortField}
|
||||
label={column.label}
|
||||
sortBy={page.sortBy}
|
||||
sortDirection={page.sortDirection}
|
||||
onToggle={page.changeSort}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
return column;
|
||||
});
|
||||
const toolbarActions = page.abilities.canCreate
|
||||
? [
|
||||
{
|
||||
@ -99,7 +152,7 @@ export function HostBdLeadersPage() {
|
||||
columns={bdLeaderColumns}
|
||||
context={{ page, regionOptions: page.regionOptions }}
|
||||
items={items}
|
||||
minWidth="1120px"
|
||||
minWidth="1300px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
@ -110,6 +163,7 @@ export function HostBdLeadersPage() {
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
regionFilter={regionFilter}
|
||||
rowKey={(item) => `bd-leader-${item.userId}`}
|
||||
/>
|
||||
</div>
|
||||
@ -343,6 +397,14 @@ function SubBDCount({ item, page }) {
|
||||
function BDLeaderActions({ item, page }) {
|
||||
return (
|
||||
<AdminRowActions>
|
||||
{page.abilities.canSalaryAdjust ? (
|
||||
<SalaryWalletAdjustButton
|
||||
role="bd_leader"
|
||||
userId={item.userId}
|
||||
userLabel={leaderUserLabel(item)}
|
||||
onAdjusted={page.reload}
|
||||
/>
|
||||
) : null}
|
||||
{page.abilities.canCreate ? (
|
||||
<AdminActionIconButton label="增加下级 BD" onClick={() => page.openAddLeaderBD(item)}>
|
||||
<PersonAddAlt1Outlined fontSize="small" />
|
||||
@ -402,3 +464,7 @@ const dangerActionSx = {
|
||||
color: "var(--danger)",
|
||||
},
|
||||
};
|
||||
|
||||
function leaderUserLabel(item) {
|
||||
return item.displayUserId || item.username || item.userId;
|
||||
}
|
||||
|
||||
98
src/features/host-org/pages/HostBdLeadersPage.test.jsx
Normal file
98
src/features/host-org/pages/HostBdLeadersPage.test.jsx
Normal file
@ -0,0 +1,98 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { HostBdLeadersPage } from "./HostBdLeadersPage.jsx";
|
||||
import { useHostBdsPage } from "@/features/host-org/hooks/useHostBdsPage.js";
|
||||
|
||||
vi.mock("@/features/host-org/hooks/useHostBdsPage.js", () => ({
|
||||
useHostBdsPage: vi.fn(),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("bd leader table exposes user and region column filters", () => {
|
||||
const changeQuery = vi.fn();
|
||||
const changeRegionId = vi.fn();
|
||||
vi.mocked(useHostBdsPage).mockReturnValue(pageFixture({ changeQuery, changeRegionId }));
|
||||
|
||||
render(<HostBdLeadersPage />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "用户" }));
|
||||
fireEvent.change(screen.getByPlaceholderText("搜索用户 ID、展示 ID、用户名"), {
|
||||
target: { value: "164453" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "搜索" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "区域" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "南亚区" }));
|
||||
|
||||
expect(changeQuery).toHaveBeenCalledWith("164453");
|
||||
expect(changeRegionId).toHaveBeenCalledWith("1");
|
||||
});
|
||||
|
||||
function pageFixture(patch = {}) {
|
||||
return {
|
||||
abilities: {
|
||||
canCreate: false,
|
||||
canUpdate: false,
|
||||
canView: true,
|
||||
},
|
||||
activeAction: "",
|
||||
bdForm: { parentLeaderUserId: "", reason: "", targetUserId: "" },
|
||||
bdLeaderForm: { positionAlias: "", reason: "", targetUserId: "" },
|
||||
bdUserLookup: { error: "", loading: false, user: null },
|
||||
changeParentLeaderUserId: vi.fn(),
|
||||
changeQuery: vi.fn(),
|
||||
changeRegionId: vi.fn(),
|
||||
changeStatus: vi.fn(),
|
||||
closeAction: vi.fn(),
|
||||
data: { items: [leaderFixture()], page: 1, pageSize: 50, total: 1 },
|
||||
error: null,
|
||||
leaderBDs: { items: [], page: 1, pageSize: 50, total: 0 },
|
||||
leaderBDsError: "",
|
||||
leaderBDsLoading: false,
|
||||
loadLeaderBDs: vi.fn(),
|
||||
loading: false,
|
||||
loadingAction: "",
|
||||
loadingRegions: false,
|
||||
openAddLeaderBD: vi.fn(),
|
||||
openBDLeaderForm: vi.fn(),
|
||||
openLeaderBDs: vi.fn(),
|
||||
page: 1,
|
||||
parentLeaderUserId: "",
|
||||
query: "",
|
||||
regionId: "",
|
||||
regionOptions: [{ label: "南亚区", value: "1" }],
|
||||
reload: vi.fn(),
|
||||
removeBDLeader: vi.fn(),
|
||||
resetFilters: vi.fn(),
|
||||
saveBDLeaderPositionAlias: vi.fn(),
|
||||
selectedLeader: null,
|
||||
setBDForm: vi.fn(),
|
||||
setBDLeaderForm: vi.fn(),
|
||||
setPage: vi.fn(),
|
||||
status: "",
|
||||
submitBDLeader: vi.fn(),
|
||||
submitLeaderBD: vi.fn(),
|
||||
toggleBDLeader: vi.fn(),
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function leaderFixture() {
|
||||
return {
|
||||
avatar: "",
|
||||
createdAtMs: 1760000000000,
|
||||
createdByDisplayUserId: "164000",
|
||||
createdByUserId: "1000",
|
||||
createdByUsername: "hyappadmin",
|
||||
displayUserId: "164453",
|
||||
positionAlias: "admin",
|
||||
regionId: "1",
|
||||
regionName: "南亚区",
|
||||
status: "active",
|
||||
subBdCount: 0,
|
||||
userId: "3001",
|
||||
username: "ABER KING",
|
||||
};
|
||||
}
|
||||
@ -6,9 +6,12 @@ import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { bdStatusFilters } from "@/features/host-org/constants.js";
|
||||
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||
import { HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
|
||||
import { HostOrgSortHeader } from "@/features/host-org/components/HostOrgSortHeader.jsx";
|
||||
import { SalaryWalletAdjustButton, SalaryWalletCell } from "@/features/host-org/components/SalaryWalletControls.jsx";
|
||||
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
||||
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
|
||||
import { useHostBdsPage } from "@/features/host-org/hooks/useHostBdsPage.js";
|
||||
import { AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import styles from "@/features/host-org/host-org.module.css";
|
||||
|
||||
@ -20,6 +23,16 @@ const bdColumns = [
|
||||
width: "minmax(220px, 1.25fr)",
|
||||
},
|
||||
{ key: "role", label: "角色", width: "minmax(100px, 0.65fr)" },
|
||||
{
|
||||
key: "salaryWallet",
|
||||
label: "工资钱包",
|
||||
sortable: true,
|
||||
sortField: "salary_wallet",
|
||||
render: (item) => (
|
||||
<SalaryWalletCell role="bd" userId={item.userId} userLabel={bdUserLabel(item)} wallet={item.salaryWallet} />
|
||||
),
|
||||
width: "minmax(140px, 0.85fr)",
|
||||
},
|
||||
{
|
||||
key: "parentLeaderUserId",
|
||||
label: "上级 Leader",
|
||||
@ -68,47 +81,71 @@ export function HostBdsPage() {
|
||||
? { icon: <Add fontSize="small" />, label: "创建 BD", onClick: page.openBDForm, variant: "primary" }
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
const columns = bdColumns.map((column) => {
|
||||
if (column.key === "user") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索用户 ID、展示 ID、用户名",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "parentLeaderUserId") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索 Leader 用户 ID",
|
||||
value: page.parentLeaderUserId,
|
||||
onChange: page.changeParentLeaderUserId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "status") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: bdStatusFilters,
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return column;
|
||||
});
|
||||
const columns = [
|
||||
...bdColumns,
|
||||
page.abilities.canSalaryAdjust
|
||||
? {
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
render: (item, _index, context) => <BDActions item={item} page={context?.page} />,
|
||||
width: "minmax(80px, 0.45fr)",
|
||||
}
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map((column) => {
|
||||
if (column.key === "user") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索用户 ID、展示 ID、用户名",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "parentLeaderUserId") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索 Leader 用户 ID",
|
||||
value: page.parentLeaderUserId,
|
||||
onChange: page.changeParentLeaderUserId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "status") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: bdStatusFilters,
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.sortable) {
|
||||
return {
|
||||
...column,
|
||||
header: (
|
||||
<HostOrgSortHeader
|
||||
field={column.sortField}
|
||||
label={column.label}
|
||||
sortBy={page.sortBy}
|
||||
sortDirection={page.sortDirection}
|
||||
onToggle={page.changeSort}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
return column;
|
||||
});
|
||||
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
<div className={styles.contentPanel}>
|
||||
<HostOrgToolbar
|
||||
actions={toolbarActions}
|
||||
/>
|
||||
<HostOrgToolbar actions={toolbarActions} />
|
||||
|
||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||
<div className={styles.listBlock}>
|
||||
@ -116,7 +153,7 @@ export function HostBdsPage() {
|
||||
columns={columns}
|
||||
context={{ page, regionOptions: page.regionOptions }}
|
||||
items={items}
|
||||
minWidth="1260px"
|
||||
minWidth="1440px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
@ -219,3 +256,20 @@ function Creator({ item }) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BDActions({ item, page }) {
|
||||
return (
|
||||
<AdminRowActions>
|
||||
<SalaryWalletAdjustButton
|
||||
role="bd"
|
||||
userId={item.userId}
|
||||
userLabel={bdUserLabel(item)}
|
||||
onAdjusted={page?.reload}
|
||||
/>
|
||||
</AdminRowActions>
|
||||
);
|
||||
}
|
||||
|
||||
function bdUserLabel(item) {
|
||||
return item.displayUserId || item.username || item.userId;
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import ArrowDownwardOutlined from "@mui/icons-material/ArrowDownwardOutlined";
|
||||
import ArrowUpwardOutlined from "@mui/icons-material/ArrowUpwardOutlined";
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
||||
import RemoveCircleOutlineOutlined from "@mui/icons-material/RemoveCircleOutlineOutlined";
|
||||
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
|
||||
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||
import SwapVertOutlined from "@mui/icons-material/SwapVertOutlined";
|
||||
@ -79,6 +80,7 @@ export function HostCoinSellersPage() {
|
||||
const page = useHostCoinSellersPage();
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const stockDebitForm = page.stockDebitForm || { coinAmount: "", reason: "" };
|
||||
const createDisabled = !page.abilities.canCreate;
|
||||
const updateDisabled = !page.abilities.canUpdate;
|
||||
const columns = [
|
||||
@ -95,7 +97,7 @@ export function HostCoinSellersPage() {
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
render: (item) => <SellerActions item={item} page={page} />,
|
||||
width: "minmax(90px, 0.5fr)",
|
||||
width: "minmax(130px, 0.65fr)",
|
||||
}
|
||||
: null,
|
||||
]
|
||||
@ -303,6 +305,52 @@ export function HostCoinSellersPage() {
|
||||
</AdminFormSection>
|
||||
</HostOrgActionModal>
|
||||
|
||||
<HostOrgActionModal
|
||||
disabled={!page.abilities.canStockCredit || page.selectedSeller?.status !== "active"}
|
||||
loading={String(page.loadingAction).startsWith("coin-seller-stock-debit")}
|
||||
open={page.activeAction === "stock-debit"}
|
||||
sectioned={false}
|
||||
size="standard"
|
||||
submitLabel="确认扣除"
|
||||
onClose={page.closeAction}
|
||||
onSubmit={page.submitStockDebit}
|
||||
title="币商金币扣除"
|
||||
>
|
||||
<AdminFormSection title="基础信息">
|
||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||
<AdminFormReadOnlyField label="币商用户 ID" value={page.selectedSeller?.userId || ""} />
|
||||
<AdminFormReadOnlyField
|
||||
label="当前库存余额"
|
||||
value={formatNumber(page.selectedSeller?.merchantBalance)}
|
||||
/>
|
||||
</AdminFormFieldGrid>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="扣除信息">
|
||||
<AdminFormAmountField
|
||||
disabled={!page.abilities.canStockCredit}
|
||||
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
|
||||
label="扣除金币"
|
||||
required
|
||||
unit="金币"
|
||||
value={stockDebitForm.coinAmount}
|
||||
onChange={(event) =>
|
||||
page.setStockDebitForm?.({ ...stockDebitForm, coinAmount: event.target.value })
|
||||
}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
<AdminFormSection title="备注信息">
|
||||
<TextField
|
||||
disabled={!page.abilities.canStockCredit}
|
||||
label="原因"
|
||||
multiline
|
||||
required
|
||||
minRows={2}
|
||||
value={stockDebitForm.reason}
|
||||
onChange={(event) => page.setStockDebitForm?.({ ...stockDebitForm, reason: event.target.value })}
|
||||
/>
|
||||
</AdminFormSection>
|
||||
</HostOrgActionModal>
|
||||
|
||||
<SideDrawer
|
||||
className={styles.ledgerDrawer}
|
||||
contentClassName={styles.ledgerDrawerBody}
|
||||
@ -464,6 +512,16 @@ function SellerActions({ item, page }) {
|
||||
<MonetizationOnOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
{page.abilities.canStockCredit ? (
|
||||
<AdminActionIconButton
|
||||
disabled={item.status !== "active" || page.loadingAction === `coin-seller-stock-debit-${item.userId}`}
|
||||
label="扣金币"
|
||||
sx={coinDebitActionSx}
|
||||
onClick={() => page.openStockDebit(item)}
|
||||
>
|
||||
<RemoveCircleOutlineOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : null}
|
||||
</AdminRowActions>
|
||||
);
|
||||
}
|
||||
@ -555,3 +613,17 @@ const coinCreditActionSx = {
|
||||
color: "#f59e0b",
|
||||
},
|
||||
};
|
||||
|
||||
const coinDebitActionSx = {
|
||||
borderColor: "rgba(220, 38, 38, 0.34)",
|
||||
backgroundColor: "rgba(254, 226, 226, 0.65)",
|
||||
color: "#b91c1c",
|
||||
"&:hover": {
|
||||
borderColor: "rgba(220, 38, 38, 0.58)",
|
||||
backgroundColor: "rgba(254, 202, 202, 0.75)",
|
||||
color: "#991b1b",
|
||||
},
|
||||
"& .MuiSvgIcon-root": {
|
||||
color: "#dc2626",
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import ArrowDownwardOutlined from "@mui/icons-material/ArrowDownwardOutlined";
|
||||
import ArrowUpwardOutlined from "@mui/icons-material/ArrowUpwardOutlined";
|
||||
import SwapVertOutlined from "@mui/icons-material/SwapVertOutlined";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { hostSourceLabels, hostStatusFilters } from "@/features/host-org/constants.js";
|
||||
import { HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
|
||||
import { HostOrgSortHeader } from "@/features/host-org/components/HostOrgSortHeader.jsx";
|
||||
import { SalaryWalletAdjustButton, SalaryWalletCell } from "@/features/host-org/components/SalaryWalletControls.jsx";
|
||||
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
||||
import { useHostHostsPage } from "@/features/host-org/hooks/useHostHostsPage.js";
|
||||
import { AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import styles from "@/features/host-org/host-org.module.css";
|
||||
|
||||
@ -27,9 +27,26 @@ const hostColumns = [
|
||||
{
|
||||
key: "diamond",
|
||||
label: "钻石",
|
||||
sortable: true,
|
||||
sortField: "diamond",
|
||||
render: (item) => formatNumber(item.diamond),
|
||||
width: "minmax(110px, 0.7fr)",
|
||||
},
|
||||
{
|
||||
key: "salaryWallet",
|
||||
label: "工资钱包",
|
||||
sortable: true,
|
||||
sortField: "salary_wallet",
|
||||
render: (item) => (
|
||||
<SalaryWalletCell
|
||||
role="host"
|
||||
userId={item.userId}
|
||||
userLabel={hostUserLabel(item)}
|
||||
wallet={item.salaryWallet}
|
||||
/>
|
||||
),
|
||||
width: "minmax(140px, 0.85fr)",
|
||||
},
|
||||
{
|
||||
key: "regionId",
|
||||
label: "区域",
|
||||
@ -66,51 +83,80 @@ export function HostHostsPage() {
|
||||
const page = useHostHostsPage();
|
||||
const items = page.data.items || [];
|
||||
const total = page.data.total || 0;
|
||||
const columns = hostColumns.map((column) => {
|
||||
if (column.key === "user") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索用户 ID、展示 ID、用户名、Agency",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "currentAgencyId") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索 Agency ID",
|
||||
value: page.agencyId,
|
||||
onChange: page.changeAgencyId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "status") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: hostStatusFilters,
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "diamond") {
|
||||
return {
|
||||
...column,
|
||||
header: (
|
||||
<DiamondSortHeader
|
||||
direction={page.diamondSortDirection}
|
||||
onToggle={page.toggleDiamondSort}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
return column;
|
||||
});
|
||||
const columns = [
|
||||
...hostColumns,
|
||||
page.abilities.canSalaryAdjust
|
||||
? {
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
render: (item, _index, context) => <HostActions item={item} page={context?.page} />,
|
||||
width: "minmax(80px, 0.45fr)",
|
||||
}
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map((column) => {
|
||||
if (column.key === "user") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索用户 ID、展示 ID、用户名、Agency",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "currentAgencyId") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索 Agency ID",
|
||||
value: page.agencyId,
|
||||
onChange: page.changeAgencyId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "status") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: hostStatusFilters,
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "diamond") {
|
||||
return {
|
||||
...column,
|
||||
header: (
|
||||
<HostOrgSortHeader
|
||||
field={column.sortField}
|
||||
label={column.label}
|
||||
sortBy={page.sortBy}
|
||||
sortDirection={page.sortDirection}
|
||||
onToggle={page.changeSort}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
if (column.sortable) {
|
||||
return {
|
||||
...column,
|
||||
header: (
|
||||
<HostOrgSortHeader
|
||||
field={column.sortField}
|
||||
label={column.label}
|
||||
sortBy={page.sortBy}
|
||||
sortDirection={page.sortDirection}
|
||||
onToggle={page.changeSort}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
return column;
|
||||
});
|
||||
|
||||
return (
|
||||
<section className={styles.root}>
|
||||
@ -119,9 +165,9 @@ export function HostHostsPage() {
|
||||
<div className={styles.listBlock}>
|
||||
<HostOrgTable
|
||||
columns={columns}
|
||||
context={{ regionOptions: page.regionOptions }}
|
||||
context={{ page, regionOptions: page.regionOptions }}
|
||||
items={items}
|
||||
minWidth="1240px"
|
||||
minWidth="1420px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
@ -145,28 +191,6 @@ function formatNumber(value) {
|
||||
return Number(value || 0).toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
function DiamondSortHeader({ direction, onToggle }) {
|
||||
const SortIcon =
|
||||
direction === "asc" ? ArrowUpwardOutlined : direction === "desc" ? ArrowDownwardOutlined : SwapVertOutlined;
|
||||
const directionLabel = direction === "asc" ? "升序" : "降序";
|
||||
const nextDirectionLabel = direction === "desc" ? "升序" : "降序";
|
||||
return (
|
||||
<button
|
||||
aria-label={`按钻石${nextDirectionLabel}排序`}
|
||||
className={["admin-cell__head-trigger", direction ? "admin-cell__head-trigger--active" : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<SortIcon className="admin-cell__head-icon" fontSize="inherit" />
|
||||
<span className="admin-cell__head-label">
|
||||
钻石{direction ? <span className="admin-cell__head-filter">({directionLabel})</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function HostUser({ item }) {
|
||||
return (
|
||||
<HostOrgPerson
|
||||
@ -206,3 +230,20 @@ function HostStatusSwitch({ item }) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HostActions({ item, page }) {
|
||||
return (
|
||||
<AdminRowActions>
|
||||
<SalaryWalletAdjustButton
|
||||
role="host"
|
||||
userId={item.userId}
|
||||
userLabel={hostUserLabel(item)}
|
||||
onAdjusted={page?.reload}
|
||||
/>
|
||||
</AdminRowActions>
|
||||
);
|
||||
}
|
||||
|
||||
function hostUserLabel(item) {
|
||||
return item.displayUserId || item.username || item.userId;
|
||||
}
|
||||
|
||||
231
src/features/host-org/pages/HostSalaryWalletSortHeaders.test.jsx
Normal file
231
src/features/host-org/pages/HostSalaryWalletSortHeaders.test.jsx
Normal file
@ -0,0 +1,231 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { HostAgenciesPage } from "./HostAgenciesPage.jsx";
|
||||
import { HostBdLeadersPage } from "./HostBdLeadersPage.jsx";
|
||||
import { HostBdsPage } from "./HostBdsPage.jsx";
|
||||
import { HostHostsPage } from "./HostHostsPage.jsx";
|
||||
import { useHostAgenciesPage } from "@/features/host-org/hooks/useHostAgenciesPage.js";
|
||||
import { useHostBdsPage } from "@/features/host-org/hooks/useHostBdsPage.js";
|
||||
import { useHostHostsPage } from "@/features/host-org/hooks/useHostHostsPage.js";
|
||||
|
||||
vi.mock("@/features/host-org/hooks/useHostAgenciesPage.js", () => ({
|
||||
useHostAgenciesPage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/host-org/hooks/useHostBdsPage.js", () => ({
|
||||
useHostBdsPage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/host-org/hooks/useHostHostsPage.js", () => ({
|
||||
useHostHostsPage: vi.fn(),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("agency list salary wallet header requests salary wallet sorting", () => {
|
||||
const changeSort = vi.fn();
|
||||
vi.mocked(useHostAgenciesPage).mockReturnValue(agencyPageFixture({ changeSort }));
|
||||
|
||||
render(<HostAgenciesPage />);
|
||||
fireEvent.click(screen.getByLabelText("按工资钱包降序排序"));
|
||||
|
||||
expect(changeSort).toHaveBeenCalledWith("salary_wallet");
|
||||
});
|
||||
|
||||
test("bd leader list salary wallet header requests salary wallet sorting", () => {
|
||||
const changeSort = vi.fn();
|
||||
vi.mocked(useHostBdsPage).mockReturnValue(bdPageFixture({ changeSort, profileType: "leader" }));
|
||||
|
||||
render(<HostBdLeadersPage />);
|
||||
fireEvent.click(screen.getByLabelText("按工资钱包降序排序"));
|
||||
|
||||
expect(changeSort).toHaveBeenCalledWith("salary_wallet");
|
||||
});
|
||||
|
||||
test("bd list salary wallet header requests salary wallet sorting", () => {
|
||||
const changeSort = vi.fn();
|
||||
vi.mocked(useHostBdsPage).mockReturnValue(bdPageFixture({ changeSort, profileType: "bd" }));
|
||||
|
||||
render(<HostBdsPage />);
|
||||
fireEvent.click(screen.getByLabelText("按工资钱包降序排序"));
|
||||
|
||||
expect(changeSort).toHaveBeenCalledWith("salary_wallet");
|
||||
});
|
||||
|
||||
test("host list salary wallet header requests salary wallet sorting", () => {
|
||||
const changeSort = vi.fn();
|
||||
vi.mocked(useHostHostsPage).mockReturnValue(hostPageFixture({ changeSort }));
|
||||
|
||||
render(<HostHostsPage />);
|
||||
fireEvent.click(screen.getByLabelText("按工资钱包降序排序"));
|
||||
|
||||
expect(changeSort).toHaveBeenCalledWith("salary_wallet");
|
||||
});
|
||||
|
||||
function agencyPageFixture(patch = {}) {
|
||||
return {
|
||||
abilities: { canCreate: false, canDelete: false, canSalaryAdjust: false, canStatus: false },
|
||||
activeAction: "",
|
||||
agencyForm: { joinEnabled: true, name: "", ownerUserId: "", parentBdUserId: "", reason: "" },
|
||||
changeParentBdUserId: vi.fn(),
|
||||
changeQuery: vi.fn(),
|
||||
changeRegionId: vi.fn(),
|
||||
changeSort: vi.fn(),
|
||||
changeStatus: vi.fn(),
|
||||
closeAction: vi.fn(),
|
||||
closeAgencyFromList: vi.fn(),
|
||||
data: { items: [agencyFixture()], page: 1, pageSize: 50, total: 1 },
|
||||
error: null,
|
||||
loading: false,
|
||||
loadingAction: "",
|
||||
loadingRegions: false,
|
||||
openAgencyForm: vi.fn(),
|
||||
page: 1,
|
||||
parentBdUserId: "",
|
||||
query: "",
|
||||
regionId: "",
|
||||
regionOptions: [],
|
||||
reload: vi.fn(),
|
||||
removeAgencyFromList: vi.fn(),
|
||||
resetFilters: vi.fn(),
|
||||
setAgencyForm: vi.fn(),
|
||||
setPage: vi.fn(),
|
||||
sortBy: "",
|
||||
sortDirection: "",
|
||||
status: "",
|
||||
submitAgency: vi.fn(),
|
||||
toggleAgencyJoinEnabledFromList: vi.fn(),
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function bdPageFixture(patch = {}) {
|
||||
return {
|
||||
abilities: { canCreate: false, canSalaryAdjust: false, canUpdate: false, canView: true },
|
||||
activeAction: "",
|
||||
bdForm: { parentLeaderUserId: "", reason: "", targetUserId: "" },
|
||||
bdLeaderForm: { positionAlias: "", reason: "", targetUserId: "" },
|
||||
bdUserLookup: { error: "", loading: false, user: null },
|
||||
changeParentLeaderUserId: vi.fn(),
|
||||
changeQuery: vi.fn(),
|
||||
changeRegionId: vi.fn(),
|
||||
changeSort: vi.fn(),
|
||||
changeStatus: vi.fn(),
|
||||
closeAction: vi.fn(),
|
||||
data: { items: [bdFixture()], page: 1, pageSize: 50, total: 1 },
|
||||
error: null,
|
||||
leaderBDs: { items: [], page: 1, pageSize: 50, total: 0 },
|
||||
leaderBDsError: "",
|
||||
leaderBDsLoading: false,
|
||||
loadLeaderBDs: vi.fn(),
|
||||
loading: false,
|
||||
loadingAction: "",
|
||||
loadingRegions: false,
|
||||
openAddLeaderBD: vi.fn(),
|
||||
openBDForm: vi.fn(),
|
||||
openBDLeaderForm: vi.fn(),
|
||||
openLeaderBDs: vi.fn(),
|
||||
page: 1,
|
||||
parentLeaderUserId: "",
|
||||
profileType: "bd",
|
||||
query: "",
|
||||
regionId: "",
|
||||
regionOptions: [],
|
||||
reload: vi.fn(),
|
||||
removeBDLeader: vi.fn(),
|
||||
resetFilters: vi.fn(),
|
||||
saveBDLeaderPositionAlias: vi.fn(),
|
||||
selectedLeader: null,
|
||||
setBDForm: vi.fn(),
|
||||
setBDLeaderForm: vi.fn(),
|
||||
setPage: vi.fn(),
|
||||
sortBy: "",
|
||||
sortDirection: "",
|
||||
status: "",
|
||||
submitBD: vi.fn(),
|
||||
submitBDLeader: vi.fn(),
|
||||
submitLeaderBD: vi.fn(),
|
||||
toggleBD: vi.fn(),
|
||||
toggleBDLeader: vi.fn(),
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function hostPageFixture(patch = {}) {
|
||||
return {
|
||||
abilities: { canSalaryAdjust: false, canView: true },
|
||||
agencyId: "",
|
||||
changeAgencyId: vi.fn(),
|
||||
changeQuery: vi.fn(),
|
||||
changeRegionId: vi.fn(),
|
||||
changeSort: vi.fn(),
|
||||
changeStatus: vi.fn(),
|
||||
data: { items: [hostFixture()], page: 1, pageSize: 50, total: 1 },
|
||||
diamondSortDirection: "",
|
||||
error: null,
|
||||
loading: false,
|
||||
loadingRegions: false,
|
||||
page: 1,
|
||||
query: "",
|
||||
regionId: "",
|
||||
regionOptions: [],
|
||||
reload: vi.fn(),
|
||||
resetFilters: vi.fn(),
|
||||
setPage: vi.fn(),
|
||||
sortBy: "",
|
||||
sortDirection: "",
|
||||
status: "",
|
||||
toggleDiamondSort: vi.fn(),
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function agencyFixture() {
|
||||
return {
|
||||
agencyId: "9001",
|
||||
joinEnabled: true,
|
||||
name: "Agency One",
|
||||
ownerDisplayUserId: "160001",
|
||||
ownerUserId: "1001",
|
||||
ownerUsername: "agency_owner",
|
||||
parentBdUserId: "2001",
|
||||
regionName: "中东区",
|
||||
salaryWallet: { availableAmount: 1200 },
|
||||
status: "active",
|
||||
updatedAtMs: 1760000000000,
|
||||
};
|
||||
}
|
||||
|
||||
function bdFixture() {
|
||||
return {
|
||||
createdAtMs: 1760000000000,
|
||||
displayUserId: "160002",
|
||||
parentLeaderUserId: "3001",
|
||||
regionName: "中东区",
|
||||
role: "bd",
|
||||
salaryWallet: { availableAmount: 1200 },
|
||||
status: "active",
|
||||
subBdCount: 0,
|
||||
updatedAtMs: 1760000000000,
|
||||
userId: "2001",
|
||||
username: "bd_user",
|
||||
};
|
||||
}
|
||||
|
||||
function hostFixture() {
|
||||
return {
|
||||
currentAgencyId: "9001",
|
||||
diamond: 100,
|
||||
displayUserId: "160003",
|
||||
firstBecameHostAtMs: 1760000000000,
|
||||
regionName: "中东区",
|
||||
salaryWallet: { availableAmount: 1200 },
|
||||
source: "agency_invite",
|
||||
status: "active",
|
||||
updatedAtMs: 1760000000000,
|
||||
userId: "3001",
|
||||
username: "host_user",
|
||||
};
|
||||
}
|
||||
@ -29,6 +29,7 @@ export function useAgencyAbilities() {
|
||||
return {
|
||||
canCreate: can(PERMISSIONS.agencyCreate),
|
||||
canDelete: can(PERMISSIONS.agencyDelete),
|
||||
canSalaryAdjust: can(PERMISSIONS.hostSalarySettlementSettle),
|
||||
canStatus: can(PERMISSIONS.agencyStatus),
|
||||
canView: can(PERMISSIONS.agencyView),
|
||||
};
|
||||
@ -38,6 +39,7 @@ export function useHostAbilities() {
|
||||
const { can } = useAuth();
|
||||
|
||||
return {
|
||||
canSalaryAdjust: can(PERMISSIONS.hostSalarySettlementSettle),
|
||||
canView: can(PERMISSIONS.hostView),
|
||||
};
|
||||
}
|
||||
@ -47,6 +49,7 @@ export function useBDAbilities() {
|
||||
|
||||
return {
|
||||
canCreate: can(PERMISSIONS.bdCreate),
|
||||
canSalaryAdjust: can(PERMISSIONS.hostSalarySettlementSettle),
|
||||
canUpdate: can(PERMISSIONS.bdUpdate),
|
||||
canView: can(PERMISSIONS.bdView),
|
||||
};
|
||||
|
||||
@ -11,7 +11,15 @@ const commandContactSchema = z.object({
|
||||
reason: z.string().trim().max(200, "原因不能超过 200 个字符").optional().default(""),
|
||||
});
|
||||
|
||||
const userIdSchema = z.coerce.number().int().positive("请输入有效用户短 ID");
|
||||
const userIdSchema = z.preprocess(
|
||||
(value) => {
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
return String(value).trim();
|
||||
},
|
||||
z.string().min(1, "请输入有效用户短 ID").regex(/^[1-9]\d*$/, "请输入有效用户短 ID"),
|
||||
);
|
||||
const entityIdSchema = z.preprocess(
|
||||
(value) => {
|
||||
if (value === null || value === undefined) {
|
||||
@ -24,14 +32,14 @@ const entityIdSchema = z.preprocess(
|
||||
const optionalUserIdSchema = z.preprocess(
|
||||
(value) => {
|
||||
if (value === null || value === undefined) {
|
||||
return 0;
|
||||
return "0";
|
||||
}
|
||||
if (typeof value === "string" && value.trim() === "") {
|
||||
return 0;
|
||||
return "0";
|
||||
}
|
||||
return value;
|
||||
return String(value).trim();
|
||||
},
|
||||
z.coerce.number().int().min(0, "请输入有效用户短 ID"),
|
||||
z.string().regex(/^(0|[1-9]\d*)$/, "请输入有效用户短 ID"),
|
||||
);
|
||||
const regionIdSchema = z.coerce.number().int().positive("请输入有效区域 ID");
|
||||
const sortOrderSchema = z.coerce.number().int().min(0, "排序不能小于 0").default(0);
|
||||
@ -122,6 +130,12 @@ export const coinSellerStockCreditSchema = z
|
||||
}
|
||||
});
|
||||
|
||||
export const coinSellerStockDebitSchema = z.object({
|
||||
coinAmount: z.coerce.number().int().positive("请输入扣除金币"),
|
||||
commandId: z.string().trim().min(1, "请输入 Command ID").max(120, "Command ID 不能超过 120 个字符"),
|
||||
reason: z.string().trim().min(1, "请输入扣除原因").max(512, "原因不能超过 512 个字符"),
|
||||
});
|
||||
|
||||
export const createAgencySchema = commandBaseSchema.extend({
|
||||
joinEnabled: z.boolean().default(true),
|
||||
name: z.string().trim().min(1, "请输入 Agency 名称").max(80, "Agency 名称不能超过 80 个字符"),
|
||||
@ -173,6 +187,7 @@ export type BDStatusForm = z.infer<typeof bdStatusSchema>;
|
||||
export type CreateCoinSellerForm = z.infer<typeof createCoinSellerSchema>;
|
||||
export type CoinSellerStatusForm = z.infer<typeof coinSellerStatusSchema>;
|
||||
export type CoinSellerStockCreditForm = z.infer<typeof coinSellerStockCreditSchema>;
|
||||
export type CoinSellerStockDebitForm = z.infer<typeof coinSellerStockDebitSchema>;
|
||||
export type CreateAgencyForm = z.infer<typeof createAgencySchema>;
|
||||
export type AgencyCloseForm = z.infer<typeof agencyCloseSchema>;
|
||||
export type AgencyJoinEnabledForm = z.infer<typeof agencyJoinEnabledSchema>;
|
||||
|
||||
@ -45,7 +45,7 @@ export interface InviteActivityRewardConfigPayload {
|
||||
}
|
||||
|
||||
export interface InviteActivityRewardClaimUserDto {
|
||||
userId?: number;
|
||||
userId?: string;
|
||||
displayUserId?: string;
|
||||
username?: string;
|
||||
avatar?: string;
|
||||
@ -55,7 +55,7 @@ export interface InviteActivityRewardClaimDto {
|
||||
claimId: string;
|
||||
appCode?: string;
|
||||
cycleKey?: string;
|
||||
userId: number;
|
||||
userId: string;
|
||||
user?: InviteActivityRewardClaimUserDto;
|
||||
rewardType?: string;
|
||||
commandId?: string;
|
||||
@ -75,9 +75,32 @@ export interface InviteActivityRewardClaimDto {
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface InviteActivityRewardInviteeSummaryDto {
|
||||
userId: string;
|
||||
user?: InviteActivityRewardClaimUserDto;
|
||||
rechargeCoinAmount: number;
|
||||
invitedAtMs?: number;
|
||||
}
|
||||
|
||||
export interface InviteActivityRewardSummaryDto {
|
||||
key: string;
|
||||
appCode?: string;
|
||||
cycleKey?: string;
|
||||
userId: string;
|
||||
user?: InviteActivityRewardClaimUserDto;
|
||||
inviteCount: number;
|
||||
validInviteCount: number;
|
||||
totalRechargeCoinAmount: number;
|
||||
totalRewardCoinAmount: number;
|
||||
updatedAtMs?: number;
|
||||
invitees: InviteActivityRewardInviteeSummaryDto[];
|
||||
}
|
||||
|
||||
type RawConfig = InviteActivityRewardConfigDto & Record<string, unknown>;
|
||||
type RawTier = InviteActivityRewardTierDto & Record<string, unknown>;
|
||||
type RawClaim = InviteActivityRewardClaimDto & Record<string, unknown>;
|
||||
type RawInviteeSummary = InviteActivityRewardInviteeSummaryDto & Record<string, unknown>;
|
||||
type RawSummary = InviteActivityRewardSummaryDto & Record<string, unknown>;
|
||||
|
||||
export function getInviteActivityRewardConfig(): Promise<InviteActivityRewardConfigDto> {
|
||||
const endpoint = API_ENDPOINTS.getInviteActivityRewardConfig;
|
||||
@ -110,6 +133,18 @@ export function listInviteActivityRewardClaims(query: PageQuery = {}): Promise<A
|
||||
}));
|
||||
}
|
||||
|
||||
export function listInviteActivityRewardSummaries(
|
||||
query: PageQuery = {},
|
||||
): Promise<ApiPage<InviteActivityRewardSummaryDto>> {
|
||||
return apiRequest<ApiPage<RawSummary>>("/v1/admin/activity/invite-reward/summaries", {
|
||||
method: "GET",
|
||||
query,
|
||||
}).then((page) => ({
|
||||
...page,
|
||||
items: (page.items || []).map(normalizeSummary),
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeConfig(item: RawConfig): InviteActivityRewardConfigDto {
|
||||
const rawTiers = Array.isArray(item.tiers) ? (item.tiers as RawTier[]) : [];
|
||||
return {
|
||||
@ -150,9 +185,9 @@ function normalizeClaim(item: RawClaim): InviteActivityRewardClaimDto {
|
||||
claimId: stringValue(item.claimId ?? item.claim_id),
|
||||
appCode: stringValue(item.appCode ?? item.app_code),
|
||||
cycleKey: stringValue(item.cycleKey ?? item.cycle_key),
|
||||
userId: numberValue(item.userId ?? item.user_id),
|
||||
userId: stringValue(item.userId ?? item.user_id),
|
||||
user: {
|
||||
userId: numberValue(rawUser.userId ?? rawUser.user_id),
|
||||
userId: stringValue(rawUser.userId ?? rawUser.user_id),
|
||||
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
|
||||
username: stringValue(rawUser.username),
|
||||
avatar: stringValue(rawUser.avatar),
|
||||
@ -176,6 +211,45 @@ function normalizeClaim(item: RawClaim): InviteActivityRewardClaimDto {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSummary(item: RawSummary): InviteActivityRewardSummaryDto {
|
||||
const rawUser = (item.user || {}) as Record<string, unknown>;
|
||||
const rawInvitees = Array.isArray(item.invitees) ? (item.invitees as RawInviteeSummary[]) : [];
|
||||
const cycleKey = stringValue(item.cycleKey ?? item.cycle_key);
|
||||
const userId = stringValue(item.userId ?? item.user_id);
|
||||
return {
|
||||
key: `${cycleKey}:${userId}`,
|
||||
appCode: stringValue(item.appCode ?? item.app_code),
|
||||
cycleKey,
|
||||
userId,
|
||||
user: normalizeUser(rawUser),
|
||||
inviteCount: numberValue(item.inviteCount ?? item.invite_count),
|
||||
validInviteCount: numberValue(item.validInviteCount ?? item.valid_invite_count),
|
||||
totalRechargeCoinAmount: numberValue(item.totalRechargeCoinAmount ?? item.total_recharge_coin_amount),
|
||||
totalRewardCoinAmount: numberValue(item.totalRewardCoinAmount ?? item.total_reward_coin_amount),
|
||||
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||
invitees: rawInvitees.map(normalizeInviteeSummary),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeInviteeSummary(item: RawInviteeSummary): InviteActivityRewardInviteeSummaryDto {
|
||||
const rawUser = (item.user || {}) as Record<string, unknown>;
|
||||
return {
|
||||
userId: stringValue(item.userId ?? item.user_id),
|
||||
user: normalizeUser(rawUser),
|
||||
rechargeCoinAmount: numberValue(item.rechargeCoinAmount ?? item.recharge_coin_amount),
|
||||
invitedAtMs: numberValue(item.invitedAtMs ?? item.invited_at_ms),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUser(rawUser: Record<string, unknown>): InviteActivityRewardClaimUserDto {
|
||||
return {
|
||||
userId: stringValue(rawUser.userId ?? rawUser.user_id),
|
||||
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
|
||||
username: stringValue(rawUser.username),
|
||||
avatar: stringValue(rawUser.avatar),
|
||||
};
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ import styles from "@/features/invite-activity-reward/invite-activity-reward.mod
|
||||
|
||||
const rewardTypeOptions = [
|
||||
["recharge", "累计充值"],
|
||||
["valid_invite", "有效人数"],
|
||||
["valid_invite", "Valid Users"],
|
||||
];
|
||||
|
||||
export function InviteActivityRewardConfigDrawer({
|
||||
|
||||
@ -25,8 +25,8 @@ export function InviteActivityRewardConfigSummary({ canUpdate, config, configLoa
|
||||
<SummaryItem label="累计充值档位">
|
||||
{thresholdSummary(rechargeTiers, "thresholdCoinAmount", "金币")}
|
||||
</SummaryItem>
|
||||
<SummaryItem label="有效人数档位">
|
||||
{thresholdSummary(validInviteTiers, "thresholdValidInviteCount", "人")}
|
||||
<SummaryItem label="Valid Users Tiers">
|
||||
{thresholdSummary(validInviteTiers, "thresholdValidInviteCount", "people")}
|
||||
</SummaryItem>
|
||||
<SummaryItem label="邀请人即时奖励">
|
||||
{coinSummary(config?.perInviteInviterRewardCoinAmount)}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
getInviteActivityRewardConfig,
|
||||
listInviteActivityRewardClaims,
|
||||
listInviteActivityRewardSummaries,
|
||||
updateInviteActivityRewardConfig,
|
||||
} from "@/features/invite-activity-reward/api";
|
||||
import { useInviteActivityRewardAbilities } from "@/features/invite-activity-reward/permissions.js";
|
||||
@ -9,7 +9,7 @@ import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||
|
||||
const pageSize = 50;
|
||||
const emptyClaims = { items: [], page: 1, pageSize, total: 0 };
|
||||
const emptySummaries = { items: [], page: 1, pageSize, total: 0 };
|
||||
const emptyForm = {
|
||||
enabled: true,
|
||||
perInviteInviterRewardCoinAmount: "",
|
||||
@ -27,25 +27,23 @@ export function useInviteActivityRewardPage() {
|
||||
const [configSaving, setConfigSaving] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [cycleKey, setCycleKey] = useState("");
|
||||
const [rewardType, setRewardType] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const filters = useMemo(
|
||||
() => ({ keyword: query, cycle_key: cycleKey, reward_type: rewardType, status }),
|
||||
[cycleKey, query, rewardType, status],
|
||||
() => ({ keyword: query, cycle_key: cycleKey }),
|
||||
[cycleKey, query],
|
||||
);
|
||||
const {
|
||||
data: claims = emptyClaims,
|
||||
error: claimsError,
|
||||
loading: claimsLoading,
|
||||
reload: reloadClaims,
|
||||
data: summaries = emptySummaries,
|
||||
error: summariesError,
|
||||
loading: summariesLoading,
|
||||
reload: reloadSummaries,
|
||||
} = usePaginatedQuery({
|
||||
errorMessage: "加载邀请活动领取记录失败",
|
||||
fetcher: listInviteActivityRewardClaims,
|
||||
errorMessage: "加载邀请活动列表失败",
|
||||
fetcher: listInviteActivityRewardSummaries,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["invite-activity-reward-claims", filters, page],
|
||||
queryKey: ["invite-activity-reward-summaries", filters, page],
|
||||
});
|
||||
|
||||
const reloadConfig = useCallback(async () => {
|
||||
@ -83,15 +81,6 @@ export function useInviteActivityRewardPage() {
|
||||
setCycleKey(value);
|
||||
setPage(1);
|
||||
};
|
||||
const changeRewardType = (value) => {
|
||||
setRewardType(value);
|
||||
setPage(1);
|
||||
};
|
||||
const changeStatus = (value) => {
|
||||
setStatus(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const submitConfig = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!abilities.canUpdate) {
|
||||
@ -104,7 +93,7 @@ export function useInviteActivityRewardPage() {
|
||||
setForm(formFromConfig(saved));
|
||||
setConfigDrawerOpen(false);
|
||||
showToast("邀请活动配置已保存", "success");
|
||||
await reloadClaims();
|
||||
await reloadSummaries();
|
||||
} catch (err) {
|
||||
showToast(err.message || "保存邀请活动配置失败", "error");
|
||||
} finally {
|
||||
@ -116,11 +105,6 @@ export function useInviteActivityRewardPage() {
|
||||
abilities,
|
||||
changeCycleKey,
|
||||
changeQuery,
|
||||
changeRewardType,
|
||||
changeStatus,
|
||||
claims,
|
||||
claimsError,
|
||||
claimsLoading,
|
||||
closeConfigDrawer,
|
||||
config,
|
||||
configDrawerOpen,
|
||||
@ -131,12 +115,13 @@ export function useInviteActivityRewardPage() {
|
||||
openConfigDrawer,
|
||||
page,
|
||||
query,
|
||||
reloadClaims,
|
||||
reloadConfig,
|
||||
rewardType,
|
||||
setForm,
|
||||
setPage,
|
||||
status,
|
||||
reloadSummaries,
|
||||
summaries,
|
||||
summariesError,
|
||||
summariesLoading,
|
||||
submitConfig,
|
||||
};
|
||||
}
|
||||
@ -180,17 +165,16 @@ function payloadFromForm(form) {
|
||||
throw new Error("累计充值金币门槛必须大于 0");
|
||||
}
|
||||
if (rewardType === "valid_invite" && thresholdValidInviteCount <= 0) {
|
||||
throw new Error("有效人数门槛必须大于 0");
|
||||
throw new Error("Valid users threshold must be greater than 0");
|
||||
}
|
||||
if (rewardCoinAmount <= 0) {
|
||||
throw new Error("奖励金币必须大于 0");
|
||||
}
|
||||
const tierCode = String(tier.tierCode || "").trim() || `invite_${rewardType}_${Date.now()}_${index + 1}`;
|
||||
const tierCode = normalizeTierCode(tier.tierCode, rewardType, index);
|
||||
const tierName =
|
||||
String(tier.tierName || "").trim() ||
|
||||
(rewardType === "recharge"
|
||||
? `累计充值 ${formatNumber(thresholdCoinAmount)} 金币`
|
||||
: `有效邀请 ${formatNumber(thresholdValidInviteCount)} 人`);
|
||||
rewardType === "recharge"
|
||||
? `Cumulative Recharge ${formatNumber(thresholdCoinAmount)} coins`
|
||||
: `Valid Users ${formatNumber(thresholdValidInviteCount)} people`;
|
||||
return {
|
||||
tier_id: Number(tier.tierId || 0),
|
||||
reward_type: rewardType,
|
||||
@ -220,5 +204,13 @@ function payloadFromForm(form) {
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
||||
return new Intl.NumberFormat("en-US").format(Number(value || 0));
|
||||
}
|
||||
|
||||
function normalizeTierCode(value, rewardType, index) {
|
||||
const code = String(value || "").trim();
|
||||
if (code && !/[\u3400-\u9fff]/.test(code)) {
|
||||
return code;
|
||||
}
|
||||
return `invite_${rewardType}_${Date.now()}_${index + 1}`;
|
||||
}
|
||||
|
||||
@ -83,6 +83,74 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rowExpanded {
|
||||
background: var(--fill-secondary);
|
||||
}
|
||||
|
||||
.expandUser {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.expandIcon {
|
||||
display: inline-flex;
|
||||
flex: 0 0 22px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.detailRow {
|
||||
min-height: 0;
|
||||
border-bottom: 1px solid var(--row-border);
|
||||
background: var(--fill-secondary);
|
||||
}
|
||||
|
||||
.detailCell {
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
padding: 0 var(--space-3) var(--space-3) calc(var(--space-3) + 30px);
|
||||
}
|
||||
|
||||
.inviteeTable {
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.inviteeHeader,
|
||||
.inviteeRow {
|
||||
display: grid;
|
||||
min-height: 48px;
|
||||
align-items: center;
|
||||
grid-template-columns: minmax(240px, 1fr) minmax(140px, 0.45fr) minmax(190px, 0.55fr);
|
||||
gap: var(--space-3);
|
||||
padding: 0 var(--space-4);
|
||||
}
|
||||
|
||||
.inviteeHeader {
|
||||
min-height: 40px;
|
||||
background: var(--bg-card-strong);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.inviteeRow {
|
||||
border-top: 1px solid var(--row-border);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.inviteeEmpty {
|
||||
min-height: 48px;
|
||||
padding: var(--space-4);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.drawerSectionTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -116,4 +184,9 @@
|
||||
.inlineFields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.inviteeHeader,
|
||||
.inviteeRow {
|
||||
grid-template-columns: minmax(220px, 1fr) minmax(120px, 0.5fr) minmax(170px, 0.6fr);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
|
||||
import KeyboardArrowRightOutlined from "@mui/icons-material/KeyboardArrowRightOutlined";
|
||||
import { useState } from "react";
|
||||
import { InviteActivityRewardConfigDrawer } from "@/features/invite-activity-reward/components/InviteActivityRewardConfigDrawer.jsx";
|
||||
import { InviteActivityRewardConfigSummary } from "@/features/invite-activity-reward/components/InviteActivityRewardConfigSummary.jsx";
|
||||
import { useInviteActivityRewardPage } from "@/features/invite-activity-reward/hooks/useInviteActivityRewardPage.js";
|
||||
@ -6,143 +9,64 @@ import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
|
||||
const claimStatusOptions = [
|
||||
["pending", "发放中"],
|
||||
["granted", "已发放"],
|
||||
["failed", "发放失败"],
|
||||
];
|
||||
|
||||
const rewardTypeOptions = [
|
||||
["recharge", "累计充值"],
|
||||
["valid_invite", "有效人数"],
|
||||
["invite_inviter", "邀请人即时"],
|
||||
["invite_invitee", "被邀请人即时"],
|
||||
];
|
||||
|
||||
const columnsBase = [
|
||||
{
|
||||
key: "user",
|
||||
label: "用户信息",
|
||||
width: "minmax(260px, 1.1fr)",
|
||||
render: (claim) => <ClaimUser claim={claim} />,
|
||||
},
|
||||
{
|
||||
key: "cycle",
|
||||
label: "周期",
|
||||
width: "minmax(140px, 0.6fr)",
|
||||
render: (claim) => claim.cycleKey || "-",
|
||||
},
|
||||
{
|
||||
key: "rewardType",
|
||||
label: "奖励类型",
|
||||
width: "minmax(130px, 0.6fr)",
|
||||
render: (claim) => rewardTypeLabel(claim.rewardType),
|
||||
},
|
||||
{
|
||||
key: "tier",
|
||||
label: "档位",
|
||||
width: "minmax(210px, 0.9fr)",
|
||||
render: (claim) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{claim.tierName || claim.tierCode || "-"}</span>
|
||||
<span className={styles.meta}>{thresholdLabel(claim)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "reached",
|
||||
label: "达成值",
|
||||
width: "minmax(150px, 0.65fr)",
|
||||
render: (claim) => reachedLabel(claim),
|
||||
},
|
||||
{
|
||||
key: "reward",
|
||||
label: "奖励金币",
|
||||
width: "minmax(130px, 0.6fr)",
|
||||
render: (claim) => formatNumber(claim.rewardCoinAmount),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "minmax(120px, 0.55fr)",
|
||||
render: (claim) => claimStatusLabel(claim.status),
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
label: "创建时间",
|
||||
width: "minmax(180px, 0.85fr)",
|
||||
render: (claim) => formatMillis(claim.createdAtMs),
|
||||
},
|
||||
{
|
||||
key: "claimId",
|
||||
label: "记录",
|
||||
width: "minmax(280px, 1fr)",
|
||||
render: (claim) => (
|
||||
<div className={styles.stack}>
|
||||
<span>{claim.claimId}</span>
|
||||
<span className={styles.meta}>{claim.walletTransactionId || claim.walletCommandId || "-"}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "failure",
|
||||
label: "失败原因",
|
||||
width: "minmax(180px, 0.85fr)",
|
||||
render: (claim) => claim.failureReason || "-",
|
||||
},
|
||||
];
|
||||
|
||||
export function InviteActivityRewardPage() {
|
||||
const page = useInviteActivityRewardPage();
|
||||
const total = page.claims.total || 0;
|
||||
const columns = columnsBase.map((column) => {
|
||||
if (column.key === "user") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索用户 ID、短号、名称",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "cycle") {
|
||||
return {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "周期,如 2026-06",
|
||||
value: page.cycleKey,
|
||||
onChange: page.changeCycleKey,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "rewardType") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: [["", "全部类型"], ...rewardTypeOptions],
|
||||
placeholder: "奖励类型",
|
||||
value: page.rewardType,
|
||||
onChange: page.changeRewardType,
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "status") {
|
||||
return {
|
||||
...column,
|
||||
filter: createOptionsColumnFilter({
|
||||
options: [["", "全部状态"], ...claimStatusOptions],
|
||||
placeholder: "搜索状态",
|
||||
value: page.status,
|
||||
onChange: page.changeStatus,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return column;
|
||||
});
|
||||
const total = page.summaries.total || 0;
|
||||
const [expandedKey, setExpandedKey] = useState("");
|
||||
const toggleSummary = (summary) => {
|
||||
setExpandedKey((current) => (current === summary.key ? "" : summary.key));
|
||||
};
|
||||
const columns = [
|
||||
{
|
||||
key: "user",
|
||||
label: "用户信息",
|
||||
width: "minmax(280px, 1.1fr)",
|
||||
render: (summary) => <SummaryUser expanded={expandedKey === summary.key} summary={summary} />,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "搜索用户 ID、短号、名称",
|
||||
value: page.query,
|
||||
onChange: page.changeQuery,
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "cycle",
|
||||
label: "周期",
|
||||
width: "minmax(140px, 0.55fr)",
|
||||
render: (summary) => summary.cycleKey || "-",
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "周期,如 2026-06",
|
||||
value: page.cycleKey,
|
||||
onChange: page.changeCycleKey,
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "inviteCount",
|
||||
label: "邀请人数",
|
||||
width: "minmax(130px, 0.5fr)",
|
||||
render: (summary) => `${formatNumber(summary.inviteCount)} 人`,
|
||||
},
|
||||
{
|
||||
key: "validInviteCount",
|
||||
label: "有效邀请人数",
|
||||
width: "minmax(150px, 0.55fr)",
|
||||
render: (summary) => `${formatNumber(summary.validInviteCount)} 人`,
|
||||
},
|
||||
{
|
||||
key: "totalRechargeCoinAmount",
|
||||
label: "邀请人数总充值",
|
||||
width: "minmax(170px, 0.65fr)",
|
||||
render: (summary) => formatNumber(summary.totalRechargeCoinAmount),
|
||||
},
|
||||
{
|
||||
key: "totalRewardCoinAmount",
|
||||
label: "总奖励金币",
|
||||
width: "minmax(150px, 0.55fr)",
|
||||
render: (summary) => formatNumber(summary.totalRewardCoinAmount),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
@ -153,23 +77,40 @@ export function InviteActivityRewardPage() {
|
||||
onEdit={page.openConfigDrawer}
|
||||
onRefresh={page.reloadConfig}
|
||||
/>
|
||||
<DataState error={page.claimsError} loading={page.claimsLoading} onRetry={page.reloadClaims}>
|
||||
<DataState error={page.summariesError} loading={page.summariesLoading} onRetry={page.reloadSummaries}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
items={page.claims.items || []}
|
||||
minWidth="1580px"
|
||||
items={page.summaries.items || []}
|
||||
minWidth="1180px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
page: page.page,
|
||||
pageSize: page.claims.pageSize || 50,
|
||||
pageSize: page.summaries.pageSize || 50,
|
||||
total,
|
||||
onPageChange: page.setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(claim) => claim.claimId}
|
||||
renderRowDetail={(summary) =>
|
||||
expandedKey === summary.key ? <InviteeDetail key={`${summary.key}:detail`} summary={summary} /> : null
|
||||
}
|
||||
rowKey={(summary) => summary.key}
|
||||
getRowProps={(summary) => ({
|
||||
"aria-expanded": expandedKey === summary.key,
|
||||
className: expandedKey === summary.key ? styles.rowExpanded : "",
|
||||
role: "button",
|
||||
tabIndex: 0,
|
||||
onClick: () => toggleSummary(summary),
|
||||
onKeyDown: (event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
toggleSummary(summary);
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
@ -187,35 +128,47 @@ export function InviteActivityRewardPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function ClaimUser({ claim }) {
|
||||
const user = claim.user || {};
|
||||
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || claim.userId }} />;
|
||||
function SummaryUser({ expanded, summary }) {
|
||||
const user = summary.user || {};
|
||||
return (
|
||||
<div className={styles.expandUser}>
|
||||
<span className={styles.expandIcon} aria-hidden="true">
|
||||
{expanded ? <KeyboardArrowDownOutlined fontSize="small" /> : <KeyboardArrowRightOutlined fontSize="small" />}
|
||||
</span>
|
||||
<AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || summary.userId }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function rewardTypeLabel(type) {
|
||||
return rewardTypeOptions.find(([value]) => value === type)?.[1] || type || "-";
|
||||
}
|
||||
|
||||
function claimStatusLabel(status) {
|
||||
return claimStatusOptions.find(([value]) => value === status)?.[1] || status || "-";
|
||||
}
|
||||
|
||||
function thresholdLabel(claim) {
|
||||
if (claim.rewardType === "valid_invite") {
|
||||
return `${formatNumber(claim.thresholdValidInviteCount)} 人`;
|
||||
}
|
||||
if (claim.rewardType === "invite_inviter" || claim.rewardType === "invite_invitee") {
|
||||
return claim.tierId ? `关联用户 ${claim.tierId}` : "-";
|
||||
}
|
||||
return `${formatNumber(claim.thresholdCoinAmount)} 金币`;
|
||||
}
|
||||
|
||||
function reachedLabel(claim) {
|
||||
if (claim.rewardType === "invite_inviter" || claim.rewardType === "invite_invitee") {
|
||||
return "1 人";
|
||||
}
|
||||
const unit = claim.rewardType === "valid_invite" ? "人" : "金币";
|
||||
return `${formatNumber(claim.reachedValue)} ${unit}`;
|
||||
function InviteeDetail({ summary }) {
|
||||
const invitees = summary.invitees || [];
|
||||
return (
|
||||
<div className={["admin-row", styles.detailRow].join(" ")}>
|
||||
<div className={styles.detailCell}>
|
||||
<div className={styles.inviteeTable}>
|
||||
<div className={styles.inviteeHeader}>
|
||||
<span>被邀请用户</span>
|
||||
<span>充值</span>
|
||||
<span>邀请时间</span>
|
||||
</div>
|
||||
{invitees.length ? (
|
||||
invitees.map((invitee) => (
|
||||
<div className={styles.inviteeRow} key={`${summary.key}:${invitee.userId}`}>
|
||||
<AdminUserIdentity
|
||||
openInAppUserDetail
|
||||
user={{ ...(invitee.user || {}), userId: invitee.user?.userId || invitee.userId }}
|
||||
/>
|
||||
<span>{formatNumber(invitee.rechargeCoinAmount)}</span>
|
||||
<span>{invitee.invitedAtMs ? formatMillis(invitee.invitedAtMs) : "-"}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className={styles.inviteeEmpty}>当前没有被邀请用户明细</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
|
||||
@ -20,7 +20,12 @@ import {
|
||||
tierRTPContribution,
|
||||
updateStageTier,
|
||||
} from "@/features/lucky-gift/configModel.js";
|
||||
import { broadcastLevelOptions, rewardSourceOptions, stageOptions, optionLabel } from "@/features/lucky-gift/constants.js";
|
||||
import {
|
||||
broadcastLevelOptions,
|
||||
rewardSourceOptions,
|
||||
stageOptions,
|
||||
optionLabel,
|
||||
} from "@/features/lucky-gift/constants.js";
|
||||
import styles from "@/features/lucky-gift/lucky-gift.module.css";
|
||||
|
||||
const helpText = {
|
||||
@ -210,7 +215,12 @@ export function LuckyGiftConfigDrawer({
|
||||
<Button disabled={configSaving} type="button" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={disabled} startIcon={<SaveOutlined fontSize="small" />} type="submit" variant="primary">
|
||||
<Button
|
||||
disabled={disabled}
|
||||
startIcon={<SaveOutlined fontSize="small" />}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
@ -229,7 +239,10 @@ function StageTierEditor({ disabled, form, setForm }) {
|
||||
return (
|
||||
<div className={styles.stageStack}>
|
||||
{stageOptions.map(([stageKey, stageLabel]) => {
|
||||
const stage = (form.stages || []).find((item) => item.stage === stageKey) || { stage: stageKey, tiers: [] };
|
||||
const stage = (form.stages || []).find((item) => item.stage === stageKey) || {
|
||||
stage: stageKey,
|
||||
tiers: [],
|
||||
};
|
||||
const expectedRTP = stageExpectedRTP(stage);
|
||||
const lowerRTP = Number(form.targetRtpPercent || 0) - Number(form.controlBandPercent || 0);
|
||||
const upperRTP = Number(form.targetRtpPercent || 0) + Number(form.controlBandPercent || 0);
|
||||
@ -239,7 +252,9 @@ function StageTierEditor({ disabled, form, setForm }) {
|
||||
<div className={styles.stageHeader}>
|
||||
<FieldLabel help={helpText.stages} text={stageLabel} />
|
||||
<div className={styles.stageMetrics}>
|
||||
<span className={styles.stageTotal}>概率 {formatPercent(stageProbabilityTotal(stage))}</span>
|
||||
<span className={styles.stageTotal}>
|
||||
概率 {formatPercent(stageProbabilityTotal(stage))}
|
||||
</span>
|
||||
<span className={rtpInBand ? styles.stageTotal : styles.stageRTPError}>
|
||||
期望 RTP {formatPercent(expectedRTP)}
|
||||
</span>
|
||||
@ -261,7 +276,7 @@ function StageTierEditor({ disabled, form, setForm }) {
|
||||
type="button"
|
||||
onClick={() =>
|
||||
updateStages(setForm, (stages) =>
|
||||
correctStageTierProbabilities(stages, stageKey, stageTargetRTP[stageKey])
|
||||
correctStageTierProbabilities(stages, stageKey, stageTargetRTP[stageKey]),
|
||||
)
|
||||
}
|
||||
>
|
||||
@ -303,16 +318,10 @@ function defaultStageTargetRTP(targetRTPPercent) {
|
||||
}
|
||||
|
||||
function TierRow({ disabled, setForm, stageKey, stageLabel, tier, tierCount, tierIndex }) {
|
||||
const updateTier = (patch) => updateStages(setForm, (stages) => updateStageTier(stages, stageKey, tierIndex, patch));
|
||||
const updateTier = (patch) =>
|
||||
updateStages(setForm, (stages) => updateStageTier(stages, stageKey, tierIndex, patch));
|
||||
return (
|
||||
<div className={styles.tierRow}>
|
||||
<TextField
|
||||
required
|
||||
disabled={disabled}
|
||||
label="奖档 ID"
|
||||
value={tier.tierId}
|
||||
onChange={(event) => updateTier({ tierId: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
required
|
||||
disabled={disabled}
|
||||
@ -382,7 +391,13 @@ function TierRow({ disabled, setForm, stageKey, stageLabel, tier, tierCount, tie
|
||||
|
||||
function SelectField({ disabled, label, onChange, options, value }) {
|
||||
return (
|
||||
<TextField select disabled={disabled} label={label} value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
<TextField
|
||||
select
|
||||
disabled={disabled}
|
||||
label={label}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
{options.map(([optionValue, optionLabelText]) => (
|
||||
<MenuItem key={optionValue} value={optionValue}>
|
||||
{optionLabelText || optionLabel(options, optionValue)}
|
||||
|
||||
@ -76,6 +76,7 @@ export function formFromConfig(config) {
|
||||
export function payloadFromLuckyGiftForm(form) {
|
||||
// 第一阶段的新契约明确要求后台只提交百分比和倍数;ppm 是 server 的持久化细节,
|
||||
// 这里保留业务单位可以避免再次出现 target_rtp_ppm=95 这类单位错配。
|
||||
const stages = assignGeneratedTierIDs(normalizeStages(form.stages));
|
||||
return {
|
||||
control_band_percent: decimal(form.controlBandPercent),
|
||||
effective_from_ms: integer(form.effectiveFromMs),
|
||||
@ -92,7 +93,7 @@ export function payloadFromLuckyGiftForm(form) {
|
||||
pool_id: String(form.poolId || "").trim(),
|
||||
pool_rate_percent: decimal(form.poolRatePercent),
|
||||
settlement_window_wager: integer(form.settlementWindowWager),
|
||||
stages: normalizeStages(form.stages).map((stage) => ({
|
||||
stages: stages.map((stage) => ({
|
||||
stage: stage.stage,
|
||||
tiers: stage.tiers.map((item) => ({
|
||||
broadcast_level: item.broadcastLevel,
|
||||
@ -126,19 +127,25 @@ export function previewForForm(form) {
|
||||
}
|
||||
|
||||
export function addTierToStage(stages, stageKey) {
|
||||
return stages.map((stage) => {
|
||||
if (stage.stage !== stageKey) {
|
||||
return stage;
|
||||
}
|
||||
const index = stage.tiers.length + 1;
|
||||
return {
|
||||
...stage,
|
||||
tiers: [
|
||||
...stage.tiers,
|
||||
tier(`${stage.stage}_${index}x`, "", "", { broadcastLevel: "none", rewardSource: "base_rtp" }),
|
||||
],
|
||||
};
|
||||
});
|
||||
return assignGeneratedTierIDs(
|
||||
stages.map((stage) => {
|
||||
if (stage.stage !== stageKey) {
|
||||
return stage;
|
||||
}
|
||||
const nextMultiplier = nextSuggestedTierMultiplier(stage.tiers);
|
||||
return {
|
||||
...stage,
|
||||
tiers: [
|
||||
...stage.tiers,
|
||||
tier(tierIDFromMultiplier(stage.stage, nextMultiplier), nextMultiplier, "", {
|
||||
broadcastLevel: "none",
|
||||
highWaterOnly: nextMultiplier >= 5,
|
||||
rewardSource: "base_rtp",
|
||||
}),
|
||||
],
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function removeTierFromStage(stages, stageKey, tierIndex) {
|
||||
@ -171,8 +178,12 @@ export function correctStageTierProbabilities(stages, stageKey, targetRTPPercent
|
||||
const enabledTiers = stage.tiers
|
||||
.map((item, index) => ({ index, multiplier: decimal(item.multiplier), tier: item }))
|
||||
.filter((item) => item.tier.enabled !== false);
|
||||
const baseTiers = enabledTiers.filter((item) => item.tier.rewardSource === "base_rtp" && Number.isFinite(item.multiplier));
|
||||
const positiveTiers = baseTiers.filter((item) => item.multiplier > 0).sort((left, right) => left.multiplier - right.multiplier);
|
||||
const baseTiers = enabledTiers.filter(
|
||||
(item) => item.tier.rewardSource === "base_rtp" && Number.isFinite(item.multiplier),
|
||||
);
|
||||
const positiveTiers = baseTiers
|
||||
.filter((item) => item.multiplier > 0)
|
||||
.sort((left, right) => left.multiplier - right.multiplier);
|
||||
const zeroTiers = baseTiers.filter((item) => item.multiplier === 0);
|
||||
if (enabledTiers.length === 0 || positiveTiers.length === 0) {
|
||||
return stage;
|
||||
@ -184,7 +195,7 @@ export function correctStageTierProbabilities(stages, stageKey, targetRTPPercent
|
||||
}
|
||||
const fixedExpectedRTP = positiveTiers.reduce(
|
||||
(sum, item) => sum + item.multiplier * (probabilityByIndex.get(item.index) || 0),
|
||||
0
|
||||
0,
|
||||
);
|
||||
const remainingProbability = 100 - fixedProbability;
|
||||
const targetExtraRTP = targetRTP - fixedExpectedRTP;
|
||||
@ -227,30 +238,75 @@ export function tierRTPContribution(tier) {
|
||||
return expectedContributionPercent(tier.multiplier, tier.probabilityPercent);
|
||||
}
|
||||
|
||||
export function assignGeneratedTierIDs(stages) {
|
||||
return (stages || []).map((stage) => {
|
||||
const used = new Map();
|
||||
const tiers = (stage.tiers || []).map((item) => {
|
||||
const baseID = tierIDFromMultiplier(stage.stage, item.multiplier);
|
||||
const usedCount = used.get(baseID) || 0;
|
||||
used.set(baseID, usedCount + 1);
|
||||
return {
|
||||
...item,
|
||||
tierId: usedCount === 0 ? baseID : `${baseID}_${usedCount + 1}`,
|
||||
};
|
||||
});
|
||||
return { ...stage, tiers };
|
||||
});
|
||||
}
|
||||
|
||||
function defaultStages() {
|
||||
return stageOptions.map(([stage]) => ({ stage, tiers: defaultStageTiers[stage].map((item) => ({ ...item })) }));
|
||||
}
|
||||
|
||||
function nextSuggestedTierMultiplier(tiers) {
|
||||
const usedMultipliers = (tiers || [])
|
||||
.map((item) => decimal(item.multiplier))
|
||||
.filter((value) => Number.isFinite(value) && value > 0);
|
||||
if (usedMultipliers.length === 0) {
|
||||
return 1;
|
||||
}
|
||||
const maxMultiplier = Math.max(...usedMultipliers);
|
||||
if (maxMultiplier >= 5) {
|
||||
return maxMultiplier * 2;
|
||||
}
|
||||
if (maxMultiplier >= 2) {
|
||||
return 5;
|
||||
}
|
||||
return maxMultiplier + 1;
|
||||
}
|
||||
|
||||
function tierIDFromMultiplier(stage, multiplier) {
|
||||
const value = decimal(multiplier);
|
||||
if (value <= 0) {
|
||||
return `${stage}_none`;
|
||||
}
|
||||
const multiplierText = formatDecimal(value).replace(".", "_");
|
||||
return `${stage}_${multiplierText}x`;
|
||||
}
|
||||
|
||||
function normalizeStages(stages) {
|
||||
// server 返回缺阶段时仍用默认三阶段补齐表单,保证运营看到的是完整发布形态;
|
||||
// 保存时 schema 会再次校验概率合计和 0x 档,不让半成品配置进入发布请求。
|
||||
const byStage = new Map((stages || []).map((stage) => [stage.stage, stage]));
|
||||
return stageOptions.map(([stage]) => {
|
||||
const source = byStage.get(stage);
|
||||
const tiers = Array.isArray(source?.tiers) && source.tiers.length > 0 ? source.tiers : defaultStageTiers[stage];
|
||||
return {
|
||||
stage,
|
||||
tiers: tiers.map((item) => ({
|
||||
broadcastLevel: item.broadcastLevel || "none",
|
||||
enabled: item.enabled !== false,
|
||||
highWaterOnly: Boolean(item.highWaterOnly),
|
||||
multiplier: formatDecimal(item.multiplier),
|
||||
probabilityPercent: formatDecimal(item.probabilityPercent),
|
||||
rewardSource: item.rewardSource || "base_rtp",
|
||||
tierId: item.tierId || "",
|
||||
})),
|
||||
};
|
||||
});
|
||||
return assignGeneratedTierIDs(
|
||||
stageOptions.map(([stage]) => {
|
||||
const source = byStage.get(stage);
|
||||
const tiers =
|
||||
Array.isArray(source?.tiers) && source.tiers.length > 0 ? source.tiers : defaultStageTiers[stage];
|
||||
return {
|
||||
stage,
|
||||
tiers: tiers.map((item) => ({
|
||||
broadcastLevel: item.broadcastLevel || "none",
|
||||
enabled: item.enabled !== false,
|
||||
highWaterOnly: Boolean(item.highWaterOnly),
|
||||
multiplier: formatDecimal(item.multiplier),
|
||||
probabilityPercent: formatDecimal(item.probabilityPercent),
|
||||
rewardSource: item.rewardSource || "base_rtp",
|
||||
tierId: item.tierId || "",
|
||||
})),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function cloneStages(stages) {
|
||||
@ -259,7 +315,10 @@ function cloneStages(stages) {
|
||||
|
||||
function positiveBaselineProbabilities(enabled) {
|
||||
const probabilities = new Map();
|
||||
const floor = enabled.length * minimumCorrectedProbabilityPercent <= 100 ? minimumCorrectedProbabilityPercent : 100 / enabled.length;
|
||||
const floor =
|
||||
enabled.length * minimumCorrectedProbabilityPercent <= 100
|
||||
? minimumCorrectedProbabilityPercent
|
||||
: 100 / enabled.length;
|
||||
for (const item of enabled) {
|
||||
probabilities.set(item.index, roundPercent(floor));
|
||||
}
|
||||
|
||||
@ -56,7 +56,38 @@ describe("lucky gift config payload", () => {
|
||||
const advanced = nextStages.find((stage) => stage.stage === "advanced");
|
||||
|
||||
expect(advanced.tiers).toHaveLength(5);
|
||||
expect(advanced.tiers[4]).toMatchObject({ broadcastLevel: "none", rewardSource: "base_rtp" });
|
||||
expect(advanced.tiers[4]).toMatchObject({
|
||||
broadcastLevel: "none",
|
||||
highWaterOnly: true,
|
||||
multiplier: 10,
|
||||
rewardSource: "base_rtp",
|
||||
tierId: "advanced_10x",
|
||||
});
|
||||
});
|
||||
|
||||
test("regenerates unique tier ids when building submit payload", () => {
|
||||
const form = emptyLuckyGiftForm();
|
||||
const advanced = form.stages.find((stage) => stage.stage === "advanced");
|
||||
advanced.tiers = [
|
||||
...advanced.tiers,
|
||||
{
|
||||
...advanced.tiers[advanced.tiers.length - 1],
|
||||
multiplier: 5,
|
||||
probabilityPercent: 0,
|
||||
tierId: "manual_duplicate",
|
||||
},
|
||||
];
|
||||
|
||||
const payload = payloadFromLuckyGiftForm(form);
|
||||
const advancedPayload = payload.stages.find((stage) => stage.stage === "advanced");
|
||||
|
||||
expect(advancedPayload.tiers.map((tier) => tier.tier_id)).toEqual([
|
||||
"advanced_none",
|
||||
"advanced_1x",
|
||||
"advanced_2x",
|
||||
"advanced_5x",
|
||||
"advanced_5x_2",
|
||||
]);
|
||||
});
|
||||
|
||||
test("corrects a stage probability to requested RTP without zeroing enabled tiers", () => {
|
||||
|
||||
@ -18,4 +18,27 @@ describe("lucky gift config schema", () => {
|
||||
expect(result.error.issues.map((issue) => issue.message)).toContain("启用奖档概率合计必须等于 100%");
|
||||
}
|
||||
});
|
||||
|
||||
test("does not require manually maintained tier ids before submit", () => {
|
||||
const form = emptyLuckyGiftForm();
|
||||
const advanced = form.stages.find((stage: { stage: string }) => stage.stage === "advanced");
|
||||
if (!advanced) {
|
||||
throw new Error("advanced stage missing");
|
||||
}
|
||||
advanced.tiers[0].tierId = "";
|
||||
advanced.tiers = [
|
||||
...advanced.tiers,
|
||||
{
|
||||
...advanced.tiers[advanced.tiers.length - 1],
|
||||
enabled: false,
|
||||
multiplier: 10,
|
||||
probabilityPercent: 0,
|
||||
tierId: "advanced_5x",
|
||||
},
|
||||
];
|
||||
|
||||
const result = luckyGiftConfigFormSchema.safeParse(form);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -17,7 +17,7 @@ const stageTierSchema = z.object({
|
||||
multiplier: z.coerce.number().min(0, "倍率不能小于 0"),
|
||||
probabilityPercent: z.coerce.number().min(0, "概率不能小于 0").max(100, "概率不能超过 100%"),
|
||||
rewardSource: z.enum(rewardSources),
|
||||
tierId: z.string().trim().min(1, "奖档 ID 不能为空").max(96, "奖档 ID 不能超过 96 个字符"),
|
||||
tierId: z.string().optional().default(""),
|
||||
});
|
||||
|
||||
const stageSchema = z.object({
|
||||
@ -31,8 +31,14 @@ export const luckyGiftConfigFormSchema = z
|
||||
effectiveFromMs: z.coerce.number().int("生效时间必须是毫秒整数").min(0, "生效时间不能小于 0"),
|
||||
enabled: z.boolean(),
|
||||
giftPriceReference: z.coerce.number().int("参考价格必须是整数").gt(0, "参考价格必须大于 0"),
|
||||
noviceMaxEquivalentDraws: z.coerce.number().int("新手阶段等价抽数必须是整数").min(0, "新手阶段等价抽数不能小于 0"),
|
||||
normalMaxEquivalentDraws: z.coerce.number().int("正常阶段等价抽数必须是整数").min(0, "正常阶段等价抽数不能小于 0"),
|
||||
noviceMaxEquivalentDraws: z.coerce
|
||||
.number()
|
||||
.int("新手阶段等价抽数必须是整数")
|
||||
.min(0, "新手阶段等价抽数不能小于 0"),
|
||||
normalMaxEquivalentDraws: z.coerce
|
||||
.number()
|
||||
.int("正常阶段等价抽数必须是整数")
|
||||
.min(0, "正常阶段等价抽数不能小于 0"),
|
||||
maxSinglePayout: z.coerce.number().int("单次返奖上限必须是整数").gt(0, "单次返奖上限必须大于 0"),
|
||||
userHourlyPayoutCap: z.coerce.number().int("用户小时上限必须是整数").gt(0, "用户小时上限必须大于 0"),
|
||||
userDailyPayoutCap: z.coerce.number().int("用户每日上限必须是整数").gt(0, "用户每日上限必须大于 0"),
|
||||
@ -97,7 +103,10 @@ export const luckyGiftConfigFormSchema = z
|
||||
if (!tier.enabled || tier.rewardSource !== "base_rtp") {
|
||||
return sum;
|
||||
}
|
||||
return sum + Math.trunc((multiplierToPPM(tier.multiplier) * percentToPPM(tier.probabilityPercent)) / ppmScale);
|
||||
return (
|
||||
sum +
|
||||
Math.trunc((multiplierToPPM(tier.multiplier) * percentToPPM(tier.probabilityPercent)) / ppmScale)
|
||||
);
|
||||
}, 0);
|
||||
const expectedRTP = expectedRTPPPM / 10_000;
|
||||
const lowerRTPPPM = percentToPPM(value.targetRtpPercent - value.controlBandPercent);
|
||||
|
||||
492
src/features/operations/components/CoinLedgerTable.jsx
Normal file
492
src/features/operations/components/CoinLedgerTable.jsx
Normal file
@ -0,0 +1,492 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { listCoinLedger } from "@/features/operations/api";
|
||||
import {
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListToolbar,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import styles from "@/features/operations/operations.module.css";
|
||||
|
||||
const pageSize = 50;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "user",
|
||||
label: "用户",
|
||||
width: "minmax(260px, 1.35fr)",
|
||||
render: (entry) => <UserCell entry={entry} />,
|
||||
},
|
||||
{
|
||||
key: "amount",
|
||||
label: "数量",
|
||||
width: "minmax(120px, 0.65fr)",
|
||||
render: (entry) => <AmountValue entry={entry} />,
|
||||
},
|
||||
{
|
||||
key: "bizType",
|
||||
label: "类型",
|
||||
width: "minmax(150px, 0.8fr)",
|
||||
render: (entry) => bizTypeLabel(entry.bizType),
|
||||
},
|
||||
{
|
||||
key: "balance",
|
||||
label: "余额",
|
||||
width: "minmax(120px, 0.65fr)",
|
||||
render: (entry) => formatNumber(entry.availableAfter),
|
||||
},
|
||||
{
|
||||
key: "createdAtMs",
|
||||
label: "时间",
|
||||
width: "minmax(170px, 0.85fr)",
|
||||
render: (entry) => <TimeText value={entry.createdAtMs} />,
|
||||
},
|
||||
];
|
||||
|
||||
const hiddenMetadataKeys = new Set(["presentation_json", "resource_snapshot_json"]);
|
||||
|
||||
const metadataLabels = {
|
||||
amount: "数量",
|
||||
app_code: "App",
|
||||
applied_at_ms: "入账时间",
|
||||
asset_type: "资产类型",
|
||||
available_delta: "可用变化",
|
||||
balance_after: "余额",
|
||||
billing_receipt_id: "账单 ID",
|
||||
charge_amount: "扣费数量",
|
||||
charge_asset_type: "扣费资产",
|
||||
coin_amount: "金币数量",
|
||||
coin_price: "单价",
|
||||
coin_spent: "消耗金币",
|
||||
counts_as_seller_recharge: "计入币商充值",
|
||||
cycle_key: "周期",
|
||||
evidence_ref: "凭证",
|
||||
game_id: "游戏 ID",
|
||||
gift_count: "礼物数量",
|
||||
gift_id: "礼物 ID",
|
||||
gift_name: "礼物名称",
|
||||
heat_value: "热度",
|
||||
operator_user_id: "操作人",
|
||||
op_type: "操作类型",
|
||||
paid_amount_micro: "支付金额",
|
||||
paid_currency_code: "支付币种",
|
||||
payment_ref: "支付凭证",
|
||||
platform_code: "游戏平台",
|
||||
price_version: "价格版本",
|
||||
provider_order_id: "游戏订单",
|
||||
provider_round_id: "游戏局号",
|
||||
reason: "原因",
|
||||
recharge_currency_code: "充值币种",
|
||||
recharge_policy_coin_amount: "策略金币",
|
||||
recharge_policy_id: "充值策略 ID",
|
||||
recharge_policy_usd_minor_amount: "策略 USD 金额",
|
||||
recharge_policy_version: "充值策略版本",
|
||||
recharge_usd_minor: "充值 USD 金额",
|
||||
room_id: "房间 ID",
|
||||
seller_asset_type: "币商资产",
|
||||
seller_balance_after: "币商余额",
|
||||
seller_region_id: "币商区域",
|
||||
seller_user_id: "币商用户",
|
||||
sender_user_id: "赠送人",
|
||||
sort_order: "排序",
|
||||
stock_type: "进货类型",
|
||||
target_asset_type: "目标资产",
|
||||
target_balance_after: "接收方余额",
|
||||
target_region_id: "接收方区域",
|
||||
target_user_id: "接收用户",
|
||||
task_id: "任务 ID",
|
||||
task_type: "任务类型",
|
||||
user_id: "用户 ID",
|
||||
};
|
||||
|
||||
export function CoinLedgerTable({ lockedUser = null, userId = "" }) {
|
||||
const fixedUserId = String(userId || lockedUser?.userId || "").trim();
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedEntry, setSelectedEntry] = useState(null);
|
||||
const [userKeyword, setUserKeyword] = useState("");
|
||||
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
|
||||
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
end_at_ms: timeRange.endMs || "",
|
||||
start_at_ms: timeRange.startMs || "",
|
||||
user_keyword: fixedUserId || userKeyword,
|
||||
}),
|
||||
[fixedUserId, timeRange.endMs, timeRange.startMs, userKeyword],
|
||||
);
|
||||
const query = usePaginatedQuery({
|
||||
errorMessage: "获取金币流水失败",
|
||||
fetcher: listCoinLedger,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["coin-ledger", filters, page],
|
||||
});
|
||||
const data = query.data || { items: [], total: 0, page, pageSize };
|
||||
const items = data.items || [];
|
||||
const total = data.total || 0;
|
||||
const hasMutableFilters = Boolean(userKeyword || timeRange.startMs || timeRange.endMs);
|
||||
|
||||
const changeUserKeyword = (value) => {
|
||||
setUserKeyword(value);
|
||||
setPage(1);
|
||||
};
|
||||
const changeTimeRange = (nextRange) => {
|
||||
setTimeRange(nextRange);
|
||||
setPage(1);
|
||||
};
|
||||
const resetFilters = () => {
|
||||
setUserKeyword("");
|
||||
setTimeRange({ endMs: "", startMs: "" });
|
||||
setPage(1);
|
||||
};
|
||||
const closeDetail = () => {
|
||||
setSelectedEntry(null);
|
||||
};
|
||||
|
||||
const tableColumns = columns
|
||||
.filter((column) => !(column.key === "user" && fixedUserId))
|
||||
.map((column) =>
|
||||
column.key === "user"
|
||||
? {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "长 ID / 短 ID / 昵称",
|
||||
value: userKeyword,
|
||||
onChange: changeUserKeyword,
|
||||
}),
|
||||
}
|
||||
: column,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.ledgerTableShell}>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<>
|
||||
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
|
||||
<AdminFilterResetButton disabled={!hasMutableFilters} onClick={resetFilters} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={tableColumns}
|
||||
getRowProps={(entry) => coinLedgerRowProps(entry, setSelectedEntry)}
|
||||
items={items}
|
||||
minWidth={fixedUserId ? "620px" : "900px"}
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
page,
|
||||
pageSize: data.pageSize || pageSize,
|
||||
total,
|
||||
onPageChange: setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={coinLedgerRowKey}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<CoinLedgerDetailDrawer entry={selectedEntry} open={Boolean(selectedEntry)} onClose={closeDetail} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function coinLedgerRowKey(entry) {
|
||||
return entry.entryId || entry.transactionId || `${entry.userId || "user"}-${entry.createdAtMs || "time"}`;
|
||||
}
|
||||
|
||||
function coinLedgerRowProps(entry, onOpen) {
|
||||
const user = entry.user || {};
|
||||
const userLabel = user.username || user.displayUserId || user.userId || entry.userId || "用户";
|
||||
return {
|
||||
"aria-label": `查看 ${userLabel} 的金币流水详情`,
|
||||
className: styles.clickableRow,
|
||||
role: "button",
|
||||
tabIndex: 0,
|
||||
onClick: () => onOpen(entry),
|
||||
onKeyDown: (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onOpen(entry);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function CoinLedgerDetailDrawer({ entry, onClose, open }) {
|
||||
const metadata = metadataObject(entry?.metadata);
|
||||
const contextRows = entry ? businessContextRows(entry, metadata) : [];
|
||||
const usedMetadataKeys = new Set(contextRows.flatMap((row) => row.metadataKeys || []));
|
||||
const metadataRows = metadataDetailRows(metadata, usedMetadataKeys);
|
||||
const user = entry?.user || {};
|
||||
|
||||
return (
|
||||
<SideDrawer open={open} title="流水详情" width="wide" onClose={onClose}>
|
||||
{entry ? (
|
||||
<>
|
||||
<header className={styles.detailHero}>
|
||||
<AdminUserIdentity
|
||||
openInAppUserDetail
|
||||
user={{ ...user, userId: user.userId || entry.userId }}
|
||||
size="large"
|
||||
/>
|
||||
<AmountValue entry={entry} />
|
||||
</header>
|
||||
|
||||
<DetailSection
|
||||
items={[
|
||||
detailRow("交易 ID", entry.transactionId),
|
||||
detailRow("命令 ID", entry.commandId),
|
||||
detailRow("外部单号", entry.externalRef),
|
||||
detailRow("流水 ID", entry.entryId),
|
||||
detailRow("业务类型", bizTypeLabel(entry.bizType)),
|
||||
detailRow("方向", directionLabel(entry.direction)),
|
||||
detailRow("发生时间", <TimeText value={entry.createdAtMs} />),
|
||||
]}
|
||||
title="交易信息"
|
||||
/>
|
||||
|
||||
<DetailSection
|
||||
items={[
|
||||
detailRow("流水数量", formatSignedAmount(entry)),
|
||||
detailRow("可用变化", formatSignedDelta(entry.availableDelta)),
|
||||
detailRow("可用余额", formatNumber(entry.availableAfter)),
|
||||
]}
|
||||
title="金额变化"
|
||||
/>
|
||||
|
||||
<DetailSection items={contextRows} title="业务上下文" />
|
||||
<DetailSection items={metadataRows} title="扩展明细" />
|
||||
</>
|
||||
) : null}
|
||||
</SideDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
function UserCell({ entry }) {
|
||||
const user = entry.user || {};
|
||||
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || entry.userId }} />;
|
||||
}
|
||||
|
||||
function AmountValue({ entry }) {
|
||||
const expense = entry.direction === "expense";
|
||||
return (
|
||||
<span className={`${styles.amount} ${expense ? styles.amountExpense : styles.amountIncome}`}>
|
||||
{formatSignedAmount(entry)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSection({ items, title }) {
|
||||
const visibleItems = items.filter((item) => hasDetailValue(item.value));
|
||||
if (!visibleItems.length) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<section className={styles.detailSection}>
|
||||
<h3 className={styles.detailSectionTitle}>{title}</h3>
|
||||
<div className={styles.detailGrid}>
|
||||
{visibleItems.map((item) => (
|
||||
<DetailItem key={item.label} label={item.label} value={item.value} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailItem({ label, value }) {
|
||||
return (
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>{label}</span>
|
||||
<div className={styles.detailValue}>{displayDetailValue(value)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function businessContextRows(entry, metadata) {
|
||||
if (entry.bizType === "gift_debit") {
|
||||
return compactRows([
|
||||
metadataRow("礼物名称", metadata, ["gift_name", "giftName"]),
|
||||
metadataRow("礼物 ID", metadata, ["gift_id", "giftId"]),
|
||||
metadataRow("礼物数量", metadata, ["gift_count", "giftCount"]),
|
||||
metadataRow("赠送给", metadata, ["target_user_id", "targetUserId"], entry.counterpartyUserId),
|
||||
metadataRow("赠送人", metadata, ["sender_user_id", "senderUserId"], entry.userId),
|
||||
metadataRow("房间 ID", metadata, ["room_id", "roomId"], entry.roomId),
|
||||
metadataRow("消耗金币", metadata, ["coin_spent", "coinSpent", "charge_amount", "chargeAmount"]),
|
||||
metadataRow("热度", metadata, ["heat_value", "heatValue"]),
|
||||
metadataRow("账单 ID", metadata, ["billing_receipt_id", "billingReceiptId"]),
|
||||
metadataRow("价格版本", metadata, ["price_version", "priceVersion"]),
|
||||
]);
|
||||
}
|
||||
|
||||
if (String(entry.bizType || "").startsWith("game_")) {
|
||||
return compactRows([
|
||||
metadataRow("游戏 ID", metadata, ["game_id", "gameId"]),
|
||||
metadataRow("游戏平台", metadata, ["platform_code", "platformCode"]),
|
||||
metadataRow("游戏订单", metadata, ["provider_order_id", "providerOrderId"], entry.externalRef),
|
||||
metadataRow("游戏局号", metadata, ["provider_round_id", "providerRoundId"]),
|
||||
metadataRow("游戏操作", metadata, ["op_type", "opType"]),
|
||||
metadataRow("游戏房间", metadata, ["room_id", "roomId"], entry.roomId),
|
||||
metadataRow("游戏金币", metadata, ["coin_amount", "coinAmount"]),
|
||||
]);
|
||||
}
|
||||
|
||||
if (entry.bizType === "coin_seller_transfer") {
|
||||
return compactRows([
|
||||
metadataRow("币商用户", metadata, ["seller_user_id", "sellerUserId"]),
|
||||
metadataRow("接收用户", metadata, ["target_user_id", "targetUserId"], entry.counterpartyUserId),
|
||||
metadataRow("转账金额", metadata, ["amount"]),
|
||||
metadataRow("原因", metadata, ["reason"]),
|
||||
metadataRow("币商余额", metadata, ["seller_balance_after", "sellerBalanceAfter"]),
|
||||
metadataRow("接收方余额", metadata, ["target_balance_after", "targetBalanceAfter"]),
|
||||
metadataRow("充值策略 ID", metadata, ["recharge_policy_id", "rechargePolicyId"]),
|
||||
metadataRow("充值币种", metadata, ["recharge_currency_code", "rechargeCurrencyCode"]),
|
||||
]);
|
||||
}
|
||||
|
||||
return compactRows([
|
||||
metadataRow(
|
||||
"关联用户",
|
||||
metadata,
|
||||
["target_user_id", "targetUserId", "seller_user_id", "sellerUserId"],
|
||||
entry.counterpartyUserId,
|
||||
),
|
||||
metadataRow("房间 ID", metadata, ["room_id", "roomId"], entry.roomId),
|
||||
metadataRow("任务 ID", metadata, ["task_id", "taskId"]),
|
||||
metadataRow("任务类型", metadata, ["task_type", "taskType"]),
|
||||
metadataRow("周期", metadata, ["cycle_key", "cycleKey"]),
|
||||
metadataRow("原因", metadata, ["reason"]),
|
||||
metadataRow("操作人", metadata, ["operator_user_id", "operatorUserId"]),
|
||||
metadataRow("凭证", metadata, ["evidence_ref", "evidenceRef"]),
|
||||
]);
|
||||
}
|
||||
|
||||
function metadataDetailRows(metadata, usedMetadataKeys) {
|
||||
return Object.entries(metadata)
|
||||
.filter(([key, value]) => !usedMetadataKeys.has(key) && !hiddenMetadataKeys.has(key) && hasDetailValue(value))
|
||||
.map(([key, value]) => detailRow(metadataLabel(key), normalizeMetadataValue(value), [key]));
|
||||
}
|
||||
|
||||
function metadataRow(label, metadata, keys, fallback) {
|
||||
return detailRow(label, firstValue(valueFromMetadata(metadata, keys), fallback), keys);
|
||||
}
|
||||
|
||||
function detailRow(label, value, metadataKeys = []) {
|
||||
return { label, metadataKeys, value };
|
||||
}
|
||||
|
||||
function compactRows(rows) {
|
||||
return rows.filter((row) => hasDetailValue(row.value));
|
||||
}
|
||||
|
||||
function valueFromMetadata(metadata, keys) {
|
||||
for (const key of keys) {
|
||||
if (hasDetailValue(metadata[key])) {
|
||||
return metadata[key];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function firstValue(...values) {
|
||||
return values.find((value) => hasDetailValue(value));
|
||||
}
|
||||
|
||||
function metadataObject(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeMetadataValue(value) {
|
||||
if (typeof value !== "string") {
|
||||
return value;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || (trimmed[0] !== "{" && trimmed[0] !== "[")) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function displayDetailValue(value) {
|
||||
if (!hasDetailValue(value)) {
|
||||
return "-";
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return <pre className={styles.detailCode}>{JSON.stringify(value, null, 2)}</pre>;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function hasDetailValue(value) {
|
||||
return value === 0 || value === false || Boolean(value);
|
||||
}
|
||||
|
||||
function formatSignedAmount(entry) {
|
||||
const sign = entry.direction === "expense" ? "-" : "+";
|
||||
const amount = hasDetailValue(entry.amount) ? entry.amount : Math.abs(Number(entry.availableDelta || 0));
|
||||
return `${sign}${formatNumber(amount)}`;
|
||||
}
|
||||
|
||||
function formatSignedDelta(value) {
|
||||
if (!hasDetailValue(value)) {
|
||||
return "-";
|
||||
}
|
||||
const numberValue = Number(value);
|
||||
if (Number.isNaN(numberValue)) {
|
||||
return String(value);
|
||||
}
|
||||
return `${numberValue > 0 ? "+" : ""}${formatNumber(numberValue)}`;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
if (value === 0 || value) {
|
||||
return Number(value).toLocaleString("zh-CN");
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
function directionLabel(value) {
|
||||
const labels = {
|
||||
expense: "支出",
|
||||
income: "收入",
|
||||
};
|
||||
return labels[value] || value || "-";
|
||||
}
|
||||
|
||||
function metadataLabel(key) {
|
||||
return metadataLabels[key] || key;
|
||||
}
|
||||
|
||||
function bizTypeLabel(value) {
|
||||
const labels = {
|
||||
coin_seller_transfer: "币商转账",
|
||||
game_credit: "游戏入账",
|
||||
game_debit: "游戏扣费",
|
||||
game_refund: "游戏退款",
|
||||
game_reverse: "游戏冲正",
|
||||
gift_debit: "礼物扣费",
|
||||
manual_credit: "人工入账",
|
||||
resource_grant: "资源发放",
|
||||
task_reward: "任务奖励",
|
||||
vip_purchase: "VIP 购买",
|
||||
};
|
||||
return labels[value] || value || "-";
|
||||
}
|
||||
@ -30,8 +30,9 @@ const ledgerTypeLabels = {
|
||||
|
||||
const bizTypeLabels = {
|
||||
coin_seller_coin_compensation: "金币补偿",
|
||||
coin_seller_stock_purchase: "币商进货",
|
||||
coin_seller_stock_purchase: "USDT进货",
|
||||
coin_seller_transfer: "币商转用户",
|
||||
manual_credit: "金币增加",
|
||||
salary_transfer_to_coin_seller: "工资转币商",
|
||||
};
|
||||
|
||||
@ -50,10 +51,22 @@ const baseColumns = [
|
||||
},
|
||||
{
|
||||
key: "amount",
|
||||
label: "转账金额",
|
||||
label: "金币变动",
|
||||
render: (entry) => <AmountValue entry={entry} />,
|
||||
width: "minmax(130px, 0.65fr)",
|
||||
},
|
||||
{
|
||||
key: "transferUsdMinor",
|
||||
label: "转账金额",
|
||||
render: (entry) => <SalaryTransferAmount entry={entry} />,
|
||||
width: "minmax(130px, 0.65fr)",
|
||||
},
|
||||
{
|
||||
key: "paidAmountMicro",
|
||||
label: "USDT数量",
|
||||
render: (entry) => <PaidUSDTAmount entry={entry} />,
|
||||
width: "minmax(130px, 0.65fr)",
|
||||
},
|
||||
{
|
||||
key: "receiver",
|
||||
label: "收款人信息",
|
||||
@ -197,7 +210,7 @@ export function CoinSellerLedgerTable({ lockedSeller = null, sellerUserId = "" }
|
||||
columns={tableColumns}
|
||||
emptyLabel="当前无币商流水"
|
||||
items={items}
|
||||
minWidth={fixedSellerUserId ? "890px" : "1160px"}
|
||||
minWidth={fixedSellerUserId ? "1130px" : "1380px"}
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
@ -247,11 +260,44 @@ function AmountValue({ entry }) {
|
||||
}
|
||||
|
||||
function ledgerTypeLabel(entry) {
|
||||
return (
|
||||
ledgerTypeLabels[entry.ledgerType] || bizTypeLabels[entry.bizType] || entry.ledgerType || entry.bizType || "-"
|
||||
);
|
||||
if (entry.bizType === "manual_credit" && (entry.direction === "expense" || Number(entry.availableDelta || 0) < 0)) {
|
||||
return "金币扣除";
|
||||
}
|
||||
return bizTypeLabels[entry.bizType] || ledgerTypeLabels[entry.ledgerType] || entry.ledgerType || entry.bizType || "-";
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number(value || 0).toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
function SalaryTransferAmount({ entry }) {
|
||||
if (entry.bizType !== "salary_transfer_to_coin_seller" && entry.ledgerType !== "salary_transfer_received") {
|
||||
return "-";
|
||||
}
|
||||
const amountMinor = Number(entry.transferUsdMinor || entry.metadata?.salary_usd_minor || 0);
|
||||
return amountMinor > 0 ? `$${formatUSDMinor(amountMinor)}` : "-";
|
||||
}
|
||||
|
||||
function PaidUSDTAmount({ entry }) {
|
||||
const stockType = entry.stockType || entry.metadata?.stock_type;
|
||||
if (entry.bizType !== "coin_seller_stock_purchase" && stockType !== "usdt_purchase") {
|
||||
return "-";
|
||||
}
|
||||
const amountMicro = Number(entry.paidAmountMicro || entry.metadata?.paid_amount_micro || 0);
|
||||
return amountMicro > 0 ? formatUSDTMicro(amountMicro) : "-";
|
||||
}
|
||||
|
||||
function formatUSDMinor(value) {
|
||||
return (Number(value || 0) / 100).toFixed(2);
|
||||
}
|
||||
|
||||
function formatUSDTMicro(value) {
|
||||
const amount = Number(value || 0);
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
return "0";
|
||||
}
|
||||
const whole = Math.trunc(amount / 1_000_000);
|
||||
const fraction = Math.trunc(Math.abs(amount % 1_000_000));
|
||||
const trimmedFraction = String(fraction).padStart(6, "0").replace(/0+$/, "");
|
||||
return trimmedFraction ? `${whole}.${trimmedFraction}` : String(whole);
|
||||
}
|
||||
|
||||
@ -1,488 +1,10 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { listCoinLedger } from "@/features/operations/api";
|
||||
import {
|
||||
AdminFilterResetButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||
import styles from "@/features/operations/operations.module.css";
|
||||
|
||||
const pageSize = 50;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "user",
|
||||
label: "用户",
|
||||
width: "minmax(260px, 1.35fr)",
|
||||
render: (entry) => <UserCell entry={entry} />,
|
||||
},
|
||||
{
|
||||
key: "amount",
|
||||
label: "数量",
|
||||
width: "minmax(120px, 0.65fr)",
|
||||
render: (entry) => <AmountValue entry={entry} />,
|
||||
},
|
||||
{
|
||||
key: "bizType",
|
||||
label: "类型",
|
||||
width: "minmax(150px, 0.8fr)",
|
||||
render: (entry) => bizTypeLabel(entry.bizType),
|
||||
},
|
||||
{
|
||||
key: "balance",
|
||||
label: "余额",
|
||||
width: "minmax(120px, 0.65fr)",
|
||||
render: (entry) => formatNumber(entry.availableAfter),
|
||||
},
|
||||
{
|
||||
key: "createdAtMs",
|
||||
label: "时间",
|
||||
width: "minmax(170px, 0.85fr)",
|
||||
render: (entry) => <TimeText value={entry.createdAtMs} />,
|
||||
},
|
||||
];
|
||||
|
||||
const hiddenMetadataKeys = new Set(["presentation_json", "resource_snapshot_json"]);
|
||||
|
||||
const metadataLabels = {
|
||||
amount: "数量",
|
||||
app_code: "App",
|
||||
applied_at_ms: "入账时间",
|
||||
asset_type: "资产类型",
|
||||
available_delta: "可用变化",
|
||||
balance_after: "余额",
|
||||
billing_receipt_id: "账单 ID",
|
||||
charge_amount: "扣费数量",
|
||||
charge_asset_type: "扣费资产",
|
||||
coin_amount: "金币数量",
|
||||
coin_price: "单价",
|
||||
coin_spent: "消耗金币",
|
||||
counts_as_seller_recharge: "计入币商充值",
|
||||
cycle_key: "周期",
|
||||
evidence_ref: "凭证",
|
||||
game_id: "游戏 ID",
|
||||
gift_count: "礼物数量",
|
||||
gift_id: "礼物 ID",
|
||||
gift_name: "礼物名称",
|
||||
heat_value: "热度",
|
||||
operator_user_id: "操作人",
|
||||
op_type: "操作类型",
|
||||
paid_amount_micro: "支付金额",
|
||||
paid_currency_code: "支付币种",
|
||||
payment_ref: "支付凭证",
|
||||
platform_code: "游戏平台",
|
||||
price_version: "价格版本",
|
||||
provider_order_id: "游戏订单",
|
||||
provider_round_id: "游戏局号",
|
||||
reason: "原因",
|
||||
recharge_currency_code: "充值币种",
|
||||
recharge_policy_coin_amount: "策略金币",
|
||||
recharge_policy_id: "充值策略 ID",
|
||||
recharge_policy_usd_minor_amount: "策略 USD 金额",
|
||||
recharge_policy_version: "充值策略版本",
|
||||
recharge_usd_minor: "充值 USD 金额",
|
||||
room_id: "房间 ID",
|
||||
seller_asset_type: "币商资产",
|
||||
seller_balance_after: "币商余额",
|
||||
seller_region_id: "币商区域",
|
||||
seller_user_id: "币商用户",
|
||||
sender_user_id: "赠送人",
|
||||
sort_order: "排序",
|
||||
stock_type: "进货类型",
|
||||
target_asset_type: "目标资产",
|
||||
target_balance_after: "接收方余额",
|
||||
target_region_id: "接收方区域",
|
||||
target_user_id: "接收用户",
|
||||
task_id: "任务 ID",
|
||||
task_type: "任务类型",
|
||||
user_id: "用户 ID",
|
||||
};
|
||||
import { CoinLedgerTable } from "@/features/operations/components/CoinLedgerTable.jsx";
|
||||
import { AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
||||
|
||||
export function CoinLedgerPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedEntry, setSelectedEntry] = useState(null);
|
||||
const [userKeyword, setUserKeyword] = useState("");
|
||||
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
|
||||
|
||||
const filters = useMemo(
|
||||
() => ({
|
||||
end_at_ms: timeRange.endMs || "",
|
||||
start_at_ms: timeRange.startMs || "",
|
||||
user_keyword: userKeyword,
|
||||
}),
|
||||
[timeRange.endMs, timeRange.startMs, userKeyword],
|
||||
);
|
||||
const query = usePaginatedQuery({
|
||||
errorMessage: "获取金币流水失败",
|
||||
fetcher: listCoinLedger,
|
||||
filters,
|
||||
page,
|
||||
pageSize,
|
||||
queryKey: ["coin-ledger", filters, page],
|
||||
});
|
||||
const data = query.data || { items: [], total: 0, page, pageSize };
|
||||
const items = data.items || [];
|
||||
const total = data.total || 0;
|
||||
|
||||
const changeFilter = (setter) => (value) => {
|
||||
setter(value);
|
||||
setPage(1);
|
||||
};
|
||||
const changeTimeRange = (nextRange) => {
|
||||
setTimeRange(nextRange);
|
||||
setPage(1);
|
||||
};
|
||||
const resetFilters = () => {
|
||||
setUserKeyword("");
|
||||
setTimeRange({ endMs: "", startMs: "" });
|
||||
setPage(1);
|
||||
};
|
||||
const closeDetail = () => {
|
||||
setSelectedEntry(null);
|
||||
};
|
||||
|
||||
const tableColumns = columns.map((column) =>
|
||||
column.key === "user"
|
||||
? {
|
||||
...column,
|
||||
filter: createTextColumnFilter({
|
||||
placeholder: "长 ID / 短 ID / 昵称",
|
||||
value: userKeyword,
|
||||
onChange: changeFilter(setUserKeyword),
|
||||
}),
|
||||
}
|
||||
: column,
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminListPage>
|
||||
<AdminListToolbar
|
||||
filters={
|
||||
<>
|
||||
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
|
||||
<AdminFilterResetButton
|
||||
disabled={!userKeyword && !timeRange.startMs && !timeRange.endMs}
|
||||
onClick={resetFilters}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
|
||||
<AdminListBody>
|
||||
<DataTable
|
||||
columns={tableColumns}
|
||||
getRowProps={(entry) => coinLedgerRowProps(entry, setSelectedEntry)}
|
||||
items={items}
|
||||
minWidth="900px"
|
||||
pagination={
|
||||
total > 0
|
||||
? {
|
||||
page,
|
||||
pageSize: data.pageSize || pageSize,
|
||||
total,
|
||||
onPageChange: setPage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
rowKey={(entry) => entry.entryId}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
<CoinLedgerDetailDrawer entry={selectedEntry} open={Boolean(selectedEntry)} onClose={closeDetail} />
|
||||
<CoinLedgerTable />
|
||||
</AdminListPage>
|
||||
);
|
||||
}
|
||||
|
||||
function coinLedgerRowProps(entry, onOpen) {
|
||||
const user = entry.user || {};
|
||||
const userLabel = user.username || user.displayUserId || user.userId || entry.userId || "用户";
|
||||
return {
|
||||
"aria-label": `查看 ${userLabel} 的金币流水详情`,
|
||||
className: styles.clickableRow,
|
||||
role: "button",
|
||||
tabIndex: 0,
|
||||
onClick: () => onOpen(entry),
|
||||
onKeyDown: (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onOpen(entry);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function CoinLedgerDetailDrawer({ entry, onClose, open }) {
|
||||
const metadata = metadataObject(entry?.metadata);
|
||||
const contextRows = entry ? businessContextRows(entry, metadata) : [];
|
||||
const usedMetadataKeys = new Set(contextRows.flatMap((row) => row.metadataKeys || []));
|
||||
const metadataRows = metadataDetailRows(metadata, usedMetadataKeys);
|
||||
const user = entry?.user || {};
|
||||
|
||||
return (
|
||||
<SideDrawer open={open} title="流水详情" width="wide" onClose={onClose}>
|
||||
{entry ? (
|
||||
<>
|
||||
<header className={styles.detailHero}>
|
||||
<AdminUserIdentity
|
||||
openInAppUserDetail
|
||||
user={{ ...user, userId: user.userId || entry.userId }}
|
||||
size="large"
|
||||
/>
|
||||
<AmountValue entry={entry} />
|
||||
</header>
|
||||
|
||||
<DetailSection
|
||||
items={[
|
||||
detailRow("交易 ID", entry.transactionId),
|
||||
detailRow("命令 ID", entry.commandId),
|
||||
detailRow("外部单号", entry.externalRef),
|
||||
detailRow("流水 ID", entry.entryId),
|
||||
detailRow("业务类型", bizTypeLabel(entry.bizType)),
|
||||
detailRow("方向", directionLabel(entry.direction)),
|
||||
detailRow("发生时间", <TimeText value={entry.createdAtMs} />),
|
||||
]}
|
||||
title="交易信息"
|
||||
/>
|
||||
|
||||
<DetailSection
|
||||
items={[
|
||||
detailRow("流水数量", formatSignedAmount(entry)),
|
||||
detailRow("可用变化", formatSignedDelta(entry.availableDelta)),
|
||||
detailRow("可用余额", formatNumber(entry.availableAfter)),
|
||||
]}
|
||||
title="金额变化"
|
||||
/>
|
||||
|
||||
<DetailSection items={contextRows} title="业务上下文" />
|
||||
<DetailSection items={metadataRows} title="扩展明细" />
|
||||
</>
|
||||
) : null}
|
||||
</SideDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
function UserCell({ entry }) {
|
||||
const user = entry.user || {};
|
||||
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || entry.userId }} />;
|
||||
}
|
||||
|
||||
function AmountValue({ entry }) {
|
||||
const expense = entry.direction === "expense";
|
||||
return (
|
||||
<span className={`${styles.amount} ${expense ? styles.amountExpense : styles.amountIncome}`}>
|
||||
{formatSignedAmount(entry)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSection({ items, title }) {
|
||||
const visibleItems = items.filter((item) => hasDetailValue(item.value));
|
||||
if (!visibleItems.length) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<section className={styles.detailSection}>
|
||||
<h3 className={styles.detailSectionTitle}>{title}</h3>
|
||||
<div className={styles.detailGrid}>
|
||||
{visibleItems.map((item) => (
|
||||
<DetailItem key={item.label} label={item.label} value={item.value} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailItem({ label, value }) {
|
||||
return (
|
||||
<div className={styles.detailItem}>
|
||||
<span className={styles.detailLabel}>{label}</span>
|
||||
<div className={styles.detailValue}>{displayDetailValue(value)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function businessContextRows(entry, metadata) {
|
||||
if (entry.bizType === "gift_debit") {
|
||||
return compactRows([
|
||||
metadataRow("礼物名称", metadata, ["gift_name", "giftName"]),
|
||||
metadataRow("礼物 ID", metadata, ["gift_id", "giftId"]),
|
||||
metadataRow("礼物数量", metadata, ["gift_count", "giftCount"]),
|
||||
metadataRow("赠送给", metadata, ["target_user_id", "targetUserId"], entry.counterpartyUserId),
|
||||
metadataRow("赠送人", metadata, ["sender_user_id", "senderUserId"], entry.userId),
|
||||
metadataRow("房间 ID", metadata, ["room_id", "roomId"], entry.roomId),
|
||||
metadataRow("消耗金币", metadata, ["coin_spent", "coinSpent", "charge_amount", "chargeAmount"]),
|
||||
metadataRow("热度", metadata, ["heat_value", "heatValue"]),
|
||||
metadataRow("账单 ID", metadata, ["billing_receipt_id", "billingReceiptId"]),
|
||||
metadataRow("价格版本", metadata, ["price_version", "priceVersion"]),
|
||||
]);
|
||||
}
|
||||
|
||||
if (String(entry.bizType || "").startsWith("game_")) {
|
||||
return compactRows([
|
||||
metadataRow("游戏 ID", metadata, ["game_id", "gameId"]),
|
||||
metadataRow("游戏平台", metadata, ["platform_code", "platformCode"]),
|
||||
metadataRow("游戏订单", metadata, ["provider_order_id", "providerOrderId"], entry.externalRef),
|
||||
metadataRow("游戏局号", metadata, ["provider_round_id", "providerRoundId"]),
|
||||
metadataRow("游戏操作", metadata, ["op_type", "opType"]),
|
||||
metadataRow("游戏房间", metadata, ["room_id", "roomId"], entry.roomId),
|
||||
metadataRow("游戏金币", metadata, ["coin_amount", "coinAmount"]),
|
||||
]);
|
||||
}
|
||||
|
||||
if (entry.bizType === "coin_seller_transfer") {
|
||||
return compactRows([
|
||||
metadataRow("币商用户", metadata, ["seller_user_id", "sellerUserId"]),
|
||||
metadataRow("接收用户", metadata, ["target_user_id", "targetUserId"], entry.counterpartyUserId),
|
||||
metadataRow("转账金额", metadata, ["amount"]),
|
||||
metadataRow("原因", metadata, ["reason"]),
|
||||
metadataRow("币商余额", metadata, ["seller_balance_after", "sellerBalanceAfter"]),
|
||||
metadataRow("接收方余额", metadata, ["target_balance_after", "targetBalanceAfter"]),
|
||||
metadataRow("充值策略 ID", metadata, ["recharge_policy_id", "rechargePolicyId"]),
|
||||
metadataRow("充值币种", metadata, ["recharge_currency_code", "rechargeCurrencyCode"]),
|
||||
]);
|
||||
}
|
||||
|
||||
return compactRows([
|
||||
metadataRow(
|
||||
"关联用户",
|
||||
metadata,
|
||||
["target_user_id", "targetUserId", "seller_user_id", "sellerUserId"],
|
||||
entry.counterpartyUserId,
|
||||
),
|
||||
metadataRow("房间 ID", metadata, ["room_id", "roomId"], entry.roomId),
|
||||
metadataRow("任务 ID", metadata, ["task_id", "taskId"]),
|
||||
metadataRow("任务类型", metadata, ["task_type", "taskType"]),
|
||||
metadataRow("周期", metadata, ["cycle_key", "cycleKey"]),
|
||||
metadataRow("原因", metadata, ["reason"]),
|
||||
metadataRow("操作人", metadata, ["operator_user_id", "operatorUserId"]),
|
||||
metadataRow("凭证", metadata, ["evidence_ref", "evidenceRef"]),
|
||||
]);
|
||||
}
|
||||
|
||||
function metadataDetailRows(metadata, usedMetadataKeys) {
|
||||
return Object.entries(metadata)
|
||||
.filter(([key, value]) => !usedMetadataKeys.has(key) && !hiddenMetadataKeys.has(key) && hasDetailValue(value))
|
||||
.map(([key, value]) => detailRow(metadataLabel(key), normalizeMetadataValue(value), [key]));
|
||||
}
|
||||
|
||||
function metadataRow(label, metadata, keys, fallback) {
|
||||
return detailRow(label, firstValue(valueFromMetadata(metadata, keys), fallback), keys);
|
||||
}
|
||||
|
||||
function detailRow(label, value, metadataKeys = []) {
|
||||
return { label, metadataKeys, value };
|
||||
}
|
||||
|
||||
function compactRows(rows) {
|
||||
return rows.filter((row) => hasDetailValue(row.value));
|
||||
}
|
||||
|
||||
function valueFromMetadata(metadata, keys) {
|
||||
for (const key of keys) {
|
||||
if (hasDetailValue(metadata[key])) {
|
||||
return metadata[key];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function firstValue(...values) {
|
||||
return values.find((value) => hasDetailValue(value));
|
||||
}
|
||||
|
||||
function metadataObject(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeMetadataValue(value) {
|
||||
if (typeof value !== "string") {
|
||||
return value;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || (trimmed[0] !== "{" && trimmed[0] !== "[")) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function displayDetailValue(value) {
|
||||
if (!hasDetailValue(value)) {
|
||||
return "-";
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return <pre className={styles.detailCode}>{JSON.stringify(value, null, 2)}</pre>;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function hasDetailValue(value) {
|
||||
return value === 0 || value === false || Boolean(value);
|
||||
}
|
||||
|
||||
function formatSignedAmount(entry) {
|
||||
const sign = entry.direction === "expense" ? "-" : "+";
|
||||
const amount = hasDetailValue(entry.amount) ? entry.amount : Math.abs(Number(entry.availableDelta || 0));
|
||||
return `${sign}${formatNumber(amount)}`;
|
||||
}
|
||||
|
||||
function formatSignedDelta(value) {
|
||||
if (!hasDetailValue(value)) {
|
||||
return "-";
|
||||
}
|
||||
const numberValue = Number(value);
|
||||
if (Number.isNaN(numberValue)) {
|
||||
return String(value);
|
||||
}
|
||||
return `${numberValue > 0 ? "+" : ""}${formatNumber(numberValue)}`;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
if (value === 0 || value) {
|
||||
return Number(value).toLocaleString("zh-CN");
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
function directionLabel(value) {
|
||||
const labels = {
|
||||
expense: "支出",
|
||||
income: "收入",
|
||||
};
|
||||
return labels[value] || value || "-";
|
||||
}
|
||||
|
||||
function metadataLabel(key) {
|
||||
return metadataLabels[key] || key;
|
||||
}
|
||||
|
||||
function bizTypeLabel(value) {
|
||||
const labels = {
|
||||
coin_seller_transfer: "币商转账",
|
||||
game_credit: "游戏入账",
|
||||
game_debit: "游戏扣费",
|
||||
game_refund: "游戏退款",
|
||||
game_reverse: "游戏冲正",
|
||||
gift_debit: "礼物扣费",
|
||||
manual_credit: "人工入账",
|
||||
resource_grant: "资源发放",
|
||||
task_reward: "任务奖励",
|
||||
vip_purchase: "VIP 购买",
|
||||
};
|
||||
return labels[value] || value || "-";
|
||||
}
|
||||
|
||||
@ -30,7 +30,7 @@ export interface RedPacketClaimDto {
|
||||
claimId: string;
|
||||
commandId?: string;
|
||||
packetId: string;
|
||||
userId: number;
|
||||
userId: string;
|
||||
amount: number;
|
||||
walletTransactionId?: string;
|
||||
status?: string;
|
||||
@ -43,7 +43,7 @@ export interface RedPacketDto {
|
||||
appCode?: string;
|
||||
packetId: string;
|
||||
commandId?: string;
|
||||
senderUserId: number;
|
||||
senderUserId: string;
|
||||
roomId: string;
|
||||
regionId: number;
|
||||
packetType: string;
|
||||
@ -139,7 +139,7 @@ function normalizePacket(item: RawPacket): RedPacketDto {
|
||||
appCode: stringValue(item.appCode ?? item.app_code),
|
||||
packetId: stringValue(item.packetId ?? item.packet_id),
|
||||
commandId: stringValue(item.commandId ?? item.command_id),
|
||||
senderUserId: numberValue(item.senderUserId ?? item.sender_user_id),
|
||||
senderUserId: stringValue(item.senderUserId ?? item.sender_user_id),
|
||||
roomId: stringValue(item.roomId ?? item.room_id),
|
||||
regionId: numberValue(item.regionId ?? item.region_id),
|
||||
packetType: stringValue(item.packetType ?? item.packet_type),
|
||||
@ -164,7 +164,7 @@ function normalizeClaim(item: RawClaim): RedPacketClaimDto {
|
||||
claimId: stringValue(item.claimId ?? item.claim_id),
|
||||
commandId: stringValue(item.commandId ?? item.command_id),
|
||||
packetId: stringValue(item.packetId ?? item.packet_id),
|
||||
userId: numberValue(item.userId ?? item.user_id),
|
||||
userId: stringValue(item.userId ?? item.user_id),
|
||||
amount: numberValue(item.amount),
|
||||
walletTransactionId: stringValue(item.walletTransactionId ?? item.wallet_transaction_id),
|
||||
status: stringValue(item.status),
|
||||
|
||||
@ -23,7 +23,7 @@ export interface RegistrationRewardConfigPayload {
|
||||
}
|
||||
|
||||
export interface RegistrationRewardClaimUserDto {
|
||||
userId?: number;
|
||||
userId?: string;
|
||||
displayUserId?: string;
|
||||
prettyDisplayUserId?: string;
|
||||
prettyId?: string;
|
||||
@ -34,7 +34,7 @@ export interface RegistrationRewardClaimUserDto {
|
||||
export interface RegistrationRewardClaimDto {
|
||||
claimId: string;
|
||||
commandId?: string;
|
||||
userId: number;
|
||||
userId: string;
|
||||
user?: RegistrationRewardClaimUserDto;
|
||||
rewardDay?: string;
|
||||
rewardType?: string;
|
||||
@ -109,9 +109,9 @@ function normalizeClaim(item: RawClaim): RegistrationRewardClaimDto {
|
||||
return {
|
||||
claimId: stringValue(item.claimId ?? item.claim_id),
|
||||
commandId: stringValue(item.commandId ?? item.command_id),
|
||||
userId: numberValue(item.userId ?? item.user_id),
|
||||
userId: stringValue(item.userId ?? item.user_id),
|
||||
user: {
|
||||
userId: numberValue(rawUser.userId ?? rawUser.user_id),
|
||||
userId: stringValue(rawUser.userId ?? rawUser.user_id),
|
||||
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
|
||||
prettyDisplayUserId: stringValue(rawUser.prettyDisplayUserId ?? rawUser.pretty_display_user_id),
|
||||
prettyId: stringValue(rawUser.prettyId ?? rawUser.pretty_id),
|
||||
|
||||
@ -166,7 +166,7 @@ test("resource APIs use generated admin paths", async () => {
|
||||
commandId: "resource-group-grant-test",
|
||||
groupId: 22,
|
||||
reason: "manual",
|
||||
targetUserId: 1001,
|
||||
targetUserId: "1001",
|
||||
});
|
||||
|
||||
const [listUrl, listInit] = vi.mocked(fetch).mock.calls[0];
|
||||
@ -258,7 +258,7 @@ test("resource APIs use generated admin paths", async () => {
|
||||
});
|
||||
expect(String(grantGroupUrl)).toContain("/api/v1/admin/resource-grants/group");
|
||||
expect(grantGroupInit?.method).toBe("POST");
|
||||
expect(JSON.parse(String(grantGroupInit?.body))).toMatchObject({ groupId: 22, targetUserId: 1001 });
|
||||
expect(JSON.parse(String(grantGroupInit?.body))).toMatchObject({ groupId: 22, targetUserId: "1001" });
|
||||
});
|
||||
|
||||
test("resource mp4 layout batch API uses narrow update endpoint", async () => {
|
||||
|
||||
@ -209,7 +209,7 @@ export interface ResourceGrantOperatorDto {
|
||||
prettyId?: string;
|
||||
name?: string;
|
||||
source?: string;
|
||||
userId?: number | string;
|
||||
userId?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
@ -218,7 +218,7 @@ export interface ResourceGrantUserDto {
|
||||
displayUserId?: string;
|
||||
prettyDisplayUserId?: string;
|
||||
prettyId?: string;
|
||||
userId?: number | string;
|
||||
userId?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
@ -236,7 +236,7 @@ export interface ResourceGrantDto {
|
||||
reason?: string;
|
||||
status?: string;
|
||||
targetUser?: ResourceGrantUserDto;
|
||||
targetUserId?: number | string;
|
||||
targetUserId?: string;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
|
||||
@ -246,14 +246,14 @@ export interface GrantResourcePayload {
|
||||
quantity: number;
|
||||
reason: string;
|
||||
resourceId: number;
|
||||
targetUserId: number | string;
|
||||
targetUserId: string;
|
||||
}
|
||||
|
||||
export interface GrantResourceGroupPayload {
|
||||
commandId: string;
|
||||
groupId: number;
|
||||
reason: string;
|
||||
targetUserId: number | string;
|
||||
targetUserId: string;
|
||||
}
|
||||
|
||||
export interface ResourceGroupItemPayload {
|
||||
|
||||
@ -11,6 +11,7 @@ const drawerZIndex = 1600;
|
||||
export function GiftSelectField({
|
||||
disabled = false,
|
||||
drawerTitle = "选择礼物",
|
||||
fallbackGift = null,
|
||||
gifts = [],
|
||||
label = "礼物",
|
||||
loading = false,
|
||||
@ -19,11 +20,150 @@ export function GiftSelectField({
|
||||
required = true,
|
||||
value,
|
||||
}) {
|
||||
const picker = useGiftPicker({ fallbackGift, gifts, loading, onChange, value });
|
||||
const displayValue = picker.selectedGift
|
||||
? giftName(picker.selectedGift)
|
||||
: picker.normalizedValue
|
||||
? `礼物 #${picker.normalizedValue}`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
fullWidth
|
||||
className={styles.field}
|
||||
disabled={disabled || loading}
|
||||
label={label}
|
||||
placeholder={loading ? "礼物加载中" : placeholder}
|
||||
required={required}
|
||||
slotProps={{ htmlInput: { "aria-haspopup": "dialog", readOnly: true, role: "button" } }}
|
||||
value={displayValue}
|
||||
onClick={picker.openDrawer}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
picker.openDrawer();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<GiftSelectDrawerContent drawerTitle={drawerTitle} picker={picker} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function GiftPreviewSelectField({
|
||||
badge = "",
|
||||
className = "",
|
||||
disabled = false,
|
||||
drawerTitle = "选择礼物",
|
||||
fallbackGift = null,
|
||||
gifts = [],
|
||||
label = "礼物",
|
||||
loading = false,
|
||||
onChange = () => {},
|
||||
placeholder = "请选择礼物",
|
||||
required = true,
|
||||
value,
|
||||
}) {
|
||||
const picker = useGiftPicker({ fallbackGift, gifts, loading, onChange, value });
|
||||
const selectedGift = picker.selectedGift;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
aria-label={`${label} ${selectedGift ? giftName(selectedGift) : placeholder}`}
|
||||
className={[styles.previewField, className].filter(Boolean).join(" ")}
|
||||
disabled={disabled || loading}
|
||||
type="button"
|
||||
onClick={picker.openDrawer}
|
||||
>
|
||||
<span className={styles.previewTop}>
|
||||
<span className={styles.previewLabel}>
|
||||
{label}
|
||||
{required ? <span className={styles.previewRequired}>*</span> : null}
|
||||
</span>
|
||||
{badge ? <span className={styles.previewBadge}>{badge}</span> : null}
|
||||
</span>
|
||||
<span className={styles.previewBody}>
|
||||
<GiftThumb className={styles.previewThumb} gift={selectedGift} />
|
||||
<span className={styles.previewCopy}>
|
||||
<span className={styles.previewName}>
|
||||
{selectedGift ? giftName(selectedGift) : loading ? "礼物加载中" : placeholder}
|
||||
</span>
|
||||
<span className={styles.previewMeta}>
|
||||
{selectedGift
|
||||
? `${giftIdentity(selectedGift)} · 金币 ${formatNumber(giftCoinPrice(selectedGift))}`
|
||||
: "点击选择礼物"}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<GiftSelectDrawerContent drawerTitle={drawerTitle} picker={picker} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GiftSelectDrawerContent({ drawerTitle, picker }) {
|
||||
return (
|
||||
<SideDrawer
|
||||
className={styles.drawer}
|
||||
contentClassName={styles.drawerBody}
|
||||
drawerProps={{ sx: { zIndex: drawerZIndex } }}
|
||||
open={picker.open}
|
||||
title={drawerTitle}
|
||||
width="wide"
|
||||
onClose={picker.closeDrawer}
|
||||
>
|
||||
<div className={styles.filters}>
|
||||
<TextField
|
||||
className={styles.search}
|
||||
placeholder="搜索礼物名称、礼物 ID、资源编码"
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchOutlined fontSize="small" />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
value={picker.query}
|
||||
onChange={(event) => picker.setQuery(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.grid}>
|
||||
{picker.filteredGifts.map((gift) => {
|
||||
const selected = String(gift.giftId) === picker.normalizedValue;
|
||||
return (
|
||||
<button
|
||||
className={[styles.card, selected ? styles.cardSelected : ""].filter(Boolean).join(" ")}
|
||||
key={gift.giftId}
|
||||
type="button"
|
||||
onClick={() => picker.selectGift(gift)}
|
||||
>
|
||||
<GiftThumb gift={gift} />
|
||||
<span className={styles.name}>{giftName(gift)}</span>
|
||||
<span className={styles.meta}>{giftIdentity(gift)}</span>
|
||||
<span className={styles.tags}>
|
||||
<span className={styles.tag}>{gift.giftTypeCode || "普通礼物"}</span>
|
||||
<span className={styles.tag}>金币 {formatNumber(giftCoinPrice(gift))}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!picker.filteredGifts.length ? <div className={styles.empty}>当前无可选礼物</div> : null}
|
||||
</div>
|
||||
</SideDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
function useGiftPicker({ fallbackGift, gifts, loading, onChange, value }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const normalizedValue = String(value || "");
|
||||
const selectedGift = gifts.find((gift) => String(gift.giftId) === normalizedValue);
|
||||
const displayValue = selectedGift ? giftName(selectedGift) : normalizedValue ? `礼物 #${normalizedValue}` : "";
|
||||
const selectedGift =
|
||||
gifts.find((gift) => String(gift.giftId) === normalizedValue) ||
|
||||
(normalizedValue && fallbackGift && String(fallbackGift.giftId || "") === normalizedValue ? fallbackGift : null);
|
||||
const filteredGifts = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
@ -46,101 +186,58 @@ export function GiftSelectField({
|
||||
}, [gifts, query]);
|
||||
|
||||
const openDrawer = () => {
|
||||
if (!disabled && !loading) {
|
||||
if (!loading) {
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDrawer = () => setOpen(false);
|
||||
|
||||
const selectGift = (gift) => {
|
||||
onChange(String(gift.giftId), gift);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
fullWidth
|
||||
className={styles.field}
|
||||
disabled={disabled || loading}
|
||||
label={label}
|
||||
placeholder={loading ? "礼物加载中" : placeholder}
|
||||
required={required}
|
||||
slotProps={{ htmlInput: { "aria-haspopup": "dialog", readOnly: true, role: "button" } }}
|
||||
value={displayValue}
|
||||
onClick={openDrawer}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
openDrawer();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SideDrawer
|
||||
className={styles.drawer}
|
||||
contentClassName={styles.drawerBody}
|
||||
drawerProps={{ sx: { zIndex: drawerZIndex } }}
|
||||
open={open}
|
||||
title={drawerTitle}
|
||||
width="wide"
|
||||
onClose={() => setOpen(false)}
|
||||
>
|
||||
<div className={styles.filters}>
|
||||
<TextField
|
||||
className={styles.search}
|
||||
placeholder="搜索礼物名称、礼物 ID、资源编码"
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchOutlined fontSize="small" />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.grid}>
|
||||
{filteredGifts.map((gift) => {
|
||||
const selected = String(gift.giftId) === normalizedValue;
|
||||
return (
|
||||
<button
|
||||
className={[styles.card, selected ? styles.cardSelected : ""].filter(Boolean).join(" ")}
|
||||
key={gift.giftId}
|
||||
type="button"
|
||||
onClick={() => selectGift(gift)}
|
||||
>
|
||||
<GiftThumb gift={gift} />
|
||||
<span className={styles.name}>{giftName(gift)}</span>
|
||||
<span className={styles.meta}>
|
||||
{gift.resource?.resourceCode || gift.giftId || `资源 ${gift.resourceId || "-"}`}
|
||||
</span>
|
||||
<span className={styles.tags}>
|
||||
<span className={styles.tag}>{gift.giftTypeCode || "普通礼物"}</span>
|
||||
<span className={styles.tag}>金币 {formatNumber(gift.coinPrice)}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!filteredGifts.length ? <div className={styles.empty}>当前无可选礼物</div> : null}
|
||||
</div>
|
||||
</SideDrawer>
|
||||
</>
|
||||
);
|
||||
return {
|
||||
closeDrawer,
|
||||
filteredGifts,
|
||||
normalizedValue,
|
||||
open,
|
||||
openDrawer,
|
||||
query,
|
||||
selectGift,
|
||||
selectedGift,
|
||||
setQuery,
|
||||
};
|
||||
}
|
||||
|
||||
function GiftThumb({ gift }) {
|
||||
const imageUrl = imageURL(gift.resource?.previewUrl || gift.resource?.assetUrl || gift.resource?.animationUrl);
|
||||
function GiftThumb({ className = "", gift }) {
|
||||
const imageUrl = imageURL(
|
||||
gift?.resource?.previewUrl || gift?.resource?.assetUrl || gift?.resource?.animationUrl || gift?.giftIconUrl,
|
||||
);
|
||||
const [failedUrl, setFailedUrl] = useState("");
|
||||
const displayImageUrl = imageUrl && imageUrl !== failedUrl ? imageUrl : "";
|
||||
return (
|
||||
<span className={styles.thumb}>
|
||||
{imageUrl ? <img alt="" src={imageUrl} /> : <CardGiftcardOutlined fontSize="small" />}
|
||||
<span className={[styles.thumb, className].filter(Boolean).join(" ")}>
|
||||
{displayImageUrl ? (
|
||||
<img alt="" src={displayImageUrl} onError={() => setFailedUrl(displayImageUrl)} />
|
||||
) : (
|
||||
<CardGiftcardOutlined fontSize="small" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function giftName(gift) {
|
||||
return gift?.name || gift?.resource?.name || gift?.giftId || "礼物";
|
||||
return gift?.name || gift?.giftName || gift?.resource?.name || gift?.giftId || "礼物";
|
||||
}
|
||||
|
||||
function giftIdentity(gift) {
|
||||
return gift?.resource?.resourceCode || gift?.giftId || `资源 ${gift?.resourceId || "-"}`;
|
||||
}
|
||||
|
||||
function giftCoinPrice(gift) {
|
||||
return gift?.coinPrice ?? gift?.giftPriceCoin ?? gift?.resource?.coinPrice ?? 0;
|
||||
}
|
||||
|
||||
function imageURL(value) {
|
||||
|
||||
@ -2,6 +2,115 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.previewField {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
transition:
|
||||
border-color var(--motion-fast) var(--ease-standard),
|
||||
background var(--motion-fast) var(--ease-standard),
|
||||
box-shadow var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.previewField:hover {
|
||||
border-color: var(--primary-border);
|
||||
background: var(--primary-surface);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.previewField:focus-visible {
|
||||
outline: 3px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.previewField:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.previewTop {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.previewLabel {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.previewRequired {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.previewBadge {
|
||||
display: inline-flex;
|
||||
height: var(--admin-tag-height);
|
||||
align-items: center;
|
||||
padding: 0 var(--space-2);
|
||||
border-radius: var(--admin-tag-radius);
|
||||
background: var(--primary-surface);
|
||||
color: var(--primary);
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.previewBody {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: 54px minmax(0, 1fr);
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.previewThumb {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
}
|
||||
|
||||
.previewCopy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.previewName {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-weight: 750;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.previewMeta {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.drawer.drawer {
|
||||
width: min(720px, 100vw);
|
||||
height: 100%;
|
||||
@ -159,7 +268,8 @@
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card {
|
||||
.card,
|
||||
.previewField {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
|
||||
@ -389,13 +389,13 @@ export const resourceGrantFormSchema = z
|
||||
targetUserId: z.union([z.string(), z.number()]),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
const targetUserId = Number(value.targetUserId);
|
||||
const targetUserId = String(value.targetUserId || "").trim();
|
||||
const resourceIds = grantResourceIds(value);
|
||||
const groupId = Number(value.groupId || 0);
|
||||
const quantity = Number(value.quantity || 1);
|
||||
const durationDays = Number(value.durationDays || 0);
|
||||
|
||||
if (!Number.isInteger(targetUserId) || targetUserId <= 0) {
|
||||
if (!/^[1-9]\d*$/.test(targetUserId)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "请输入用户 ID",
|
||||
|
||||
@ -39,7 +39,7 @@ export interface RoomTurnoverRewardConfigPayload {
|
||||
export interface RoomTurnoverRewardSettlementDto {
|
||||
settlementId: string;
|
||||
roomId: string;
|
||||
ownerUserId: number;
|
||||
ownerUserId: string;
|
||||
periodStartMs: number;
|
||||
periodEndMs: number;
|
||||
coinSpent: number;
|
||||
@ -140,7 +140,7 @@ function normalizeSettlement(item: RawSettlement): RoomTurnoverRewardSettlementD
|
||||
return {
|
||||
settlementId: stringValue(item.settlementId ?? item.settlement_id),
|
||||
roomId: stringValue(item.roomId ?? item.room_id),
|
||||
ownerUserId: numberValue(item.ownerUserId ?? item.owner_user_id),
|
||||
ownerUserId: stringValue(item.ownerUserId ?? item.owner_user_id),
|
||||
periodStartMs: numberValue(item.periodStartMs ?? item.period_start_ms),
|
||||
periodEndMs: numberValue(item.periodEndMs ?? item.period_end_ms),
|
||||
coinSpent: numberValue(item.coinSpent ?? item.coin_spent),
|
||||
|
||||
@ -26,7 +26,7 @@ export interface SevenDayCheckInConfigPayload {
|
||||
}
|
||||
|
||||
export interface SevenDayCheckInClaimUserDto {
|
||||
userId?: number;
|
||||
userId?: string;
|
||||
displayUserId?: string;
|
||||
prettyDisplayUserId?: string;
|
||||
prettyId?: string;
|
||||
@ -37,7 +37,7 @@ export interface SevenDayCheckInClaimUserDto {
|
||||
export interface SevenDayCheckInClaimDto {
|
||||
claimId: string;
|
||||
commandId?: string;
|
||||
userId: number;
|
||||
userId: string;
|
||||
user?: SevenDayCheckInClaimUserDto;
|
||||
cycleNo: number;
|
||||
checkinDay?: string;
|
||||
@ -121,9 +121,9 @@ function normalizeClaim(item: RawClaim): SevenDayCheckInClaimDto {
|
||||
return {
|
||||
claimId: stringValue(item.claimId ?? item.claim_id),
|
||||
commandId: stringValue(item.commandId ?? item.command_id),
|
||||
userId: numberValue(item.userId ?? item.user_id),
|
||||
userId: stringValue(item.userId ?? item.user_id),
|
||||
user: {
|
||||
userId: numberValue(rawUser.userId ?? rawUser.user_id),
|
||||
userId: stringValue(rawUser.userId ?? rawUser.user_id),
|
||||
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
|
||||
prettyDisplayUserId: stringValue(rawUser.prettyDisplayUserId ?? rawUser.pretty_display_user_id),
|
||||
prettyId: stringValue(rawUser.prettyId ?? rawUser.pretty_id),
|
||||
|
||||
@ -41,7 +41,7 @@ export interface WeeklyStarCyclePayload {
|
||||
|
||||
export interface WeeklyStarEntryDto {
|
||||
rankNo: number;
|
||||
userId: number;
|
||||
userId: string;
|
||||
score: number;
|
||||
firstScoredAtMs: number;
|
||||
lastScoredAtMs: number;
|
||||
@ -51,7 +51,7 @@ export interface WeeklyStarSettlementDto {
|
||||
settlementId: string;
|
||||
cycleId: string;
|
||||
rankNo: number;
|
||||
userId: number;
|
||||
userId: string;
|
||||
score: number;
|
||||
resourceGroupId: number;
|
||||
walletCommandId: string;
|
||||
@ -161,7 +161,7 @@ function normalizeReward(item: Raw): WeeklyStarRewardDto {
|
||||
function normalizeEntry(item: Raw): WeeklyStarEntryDto {
|
||||
return {
|
||||
rankNo: numberValue(item.rankNo ?? item.rank_no),
|
||||
userId: numberValue(item.userId ?? item.user_id),
|
||||
userId: stringValue(item.userId ?? item.user_id),
|
||||
score: numberValue(item.score),
|
||||
firstScoredAtMs: numberValue(item.firstScoredAtMs ?? item.first_scored_at_ms),
|
||||
lastScoredAtMs: numberValue(item.lastScoredAtMs ?? item.last_scored_at_ms),
|
||||
@ -173,7 +173,7 @@ function normalizeSettlement(item: Raw): WeeklyStarSettlementDto {
|
||||
settlementId: stringValue(item.settlementId ?? item.settlement_id),
|
||||
cycleId: stringValue(item.cycleId ?? item.cycle_id),
|
||||
rankNo: numberValue(item.rankNo ?? item.rank_no),
|
||||
userId: numberValue(item.userId ?? item.user_id),
|
||||
userId: stringValue(item.userId ?? item.user_id),
|
||||
score: numberValue(item.score),
|
||||
resourceGroupId: numberValue(item.resourceGroupId ?? item.resource_group_id),
|
||||
walletCommandId: stringValue(item.walletCommandId ?? item.wallet_command_id),
|
||||
|
||||
@ -13,7 +13,6 @@ export interface ApiEndpoint {
|
||||
}
|
||||
|
||||
export const API_OPERATIONS = {
|
||||
adjustDicePool: "adjustDicePool",
|
||||
appBanUser: "appBanUser",
|
||||
appGetUser: "appGetUser",
|
||||
appListBannedUsers: "appListBannedUsers",
|
||||
@ -272,13 +271,6 @@ export const API_OPERATIONS = {
|
||||
export type ApiOperationId = (typeof API_OPERATIONS)[keyof typeof API_OPERATIONS];
|
||||
|
||||
export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
||||
adjustDicePool: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.adjustDicePool,
|
||||
path: "/v1/admin/game/self-games/{game_id}/pool/adjust",
|
||||
permission: "game:update",
|
||||
permissions: ["game:update"],
|
||||
},
|
||||
appBanUser: {
|
||||
method: "POST",
|
||||
operationId: API_OPERATIONS.appBanUser,
|
||||
|
||||
40
src/shared/api/generated/schema.d.ts
vendored
40
src/shared/api/generated/schema.d.ts
vendored
@ -1188,22 +1188,6 @@ export interface paths {
|
||||
patch: operations["updateDiceConfig"];
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/game/self-games/{game_id}/pool/adjust": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["adjustDicePool"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/game/robots": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -3021,8 +3005,8 @@ export interface components {
|
||||
joinEnabled?: boolean;
|
||||
maxHosts?: number;
|
||||
name?: string;
|
||||
ownerUserId?: number;
|
||||
parentBdUserId?: number;
|
||||
ownerUserId?: string;
|
||||
parentBdUserId?: string;
|
||||
regionId?: number;
|
||||
status?: string;
|
||||
updatedAtMs?: number;
|
||||
@ -3030,12 +3014,12 @@ export interface components {
|
||||
BDProfile: {
|
||||
createdAtMs?: number;
|
||||
createdByUserId?: number;
|
||||
parentLeaderUserId?: number;
|
||||
parentLeaderUserId?: string;
|
||||
regionId?: number;
|
||||
role?: string;
|
||||
status?: string;
|
||||
updatedAtMs?: number;
|
||||
userId: number;
|
||||
userId: string;
|
||||
};
|
||||
CreateUserResult: {
|
||||
initialPassword?: string;
|
||||
@ -3057,7 +3041,7 @@ export interface components {
|
||||
source?: string;
|
||||
status?: string;
|
||||
updatedAtMs?: number;
|
||||
userId: number;
|
||||
userId: string;
|
||||
};
|
||||
Log: {
|
||||
[key: string]: unknown;
|
||||
@ -4793,20 +4777,6 @@ export interface operations {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
adjustDicePool: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
game_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: components["responses"]["EmptyResponse"];
|
||||
};
|
||||
};
|
||||
listDiceRobots: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@ -228,10 +228,15 @@ export interface CoinSellerLedgerDto {
|
||||
metadata?: Record<string, unknown>;
|
||||
operator?: CoinAdjustmentOperatorDto;
|
||||
operatorUserId?: string;
|
||||
paidAmountMicro?: number;
|
||||
paidCurrencyCode?: string;
|
||||
receiver: CoinLedgerUserDto;
|
||||
reason?: string;
|
||||
seller: CoinLedgerUserDto;
|
||||
sellerBalanceAfter: number;
|
||||
stockType?: string;
|
||||
transactionId: string;
|
||||
transferUsdMinor?: number;
|
||||
}
|
||||
|
||||
export interface CoinAdjustmentOperatorDto {
|
||||
@ -272,6 +277,64 @@ export interface CoinAdjustmentCreateDto {
|
||||
user: CoinLedgerUserDto;
|
||||
}
|
||||
|
||||
export interface SalaryWalletDto {
|
||||
assetType: string;
|
||||
availableAmount: number;
|
||||
frozenAmount?: number;
|
||||
role: string;
|
||||
updatedAtMs?: number;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface SalaryWalletOperatorDto {
|
||||
adminId?: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface SalaryWalletHistoryDto {
|
||||
amountMinor: number;
|
||||
assetType: string;
|
||||
availableAfter: number;
|
||||
availableDelta: number;
|
||||
bizType?: string;
|
||||
commandId?: string;
|
||||
counterpartyUserId?: string;
|
||||
createdAtMs?: number;
|
||||
direction: "income" | "expense" | string;
|
||||
entryId: number;
|
||||
externalRef?: string;
|
||||
frozenAfter?: number;
|
||||
frozenDelta?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
operator?: SalaryWalletOperatorDto;
|
||||
operatorUserId?: string;
|
||||
reason?: string;
|
||||
role: string;
|
||||
roomId?: string;
|
||||
transactionId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface SalaryWalletAdjustPayload {
|
||||
amountMinor: number;
|
||||
commandId?: string;
|
||||
reason: string;
|
||||
role: "host" | "agency" | "bd" | "bd_leader" | string;
|
||||
targetUserId: EntityId;
|
||||
type: "increase" | "decrease";
|
||||
}
|
||||
|
||||
export interface SalaryWalletAdjustResultDto {
|
||||
amountMinor: number;
|
||||
assetType: string;
|
||||
balance: SalaryWalletDto;
|
||||
reason?: string;
|
||||
role: string;
|
||||
transactionId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface ReportUserDto {
|
||||
avatar?: string;
|
||||
defaultDisplayUserId?: string;
|
||||
@ -324,13 +387,13 @@ export interface RechargeBillDto {
|
||||
policyVersion?: string;
|
||||
rechargeType?: string;
|
||||
sellerRegionId?: number;
|
||||
sellerUserId?: number;
|
||||
sellerUserId?: string;
|
||||
status?: string;
|
||||
targetRegionId?: number;
|
||||
transactionId: string;
|
||||
usdMinorAmount: number;
|
||||
user?: RechargeBillUserDto;
|
||||
userId: number;
|
||||
userId: string;
|
||||
seller?: RechargeBillUserDto;
|
||||
}
|
||||
|
||||
@ -441,7 +504,7 @@ export interface BDProfileDto {
|
||||
displayUserId?: string;
|
||||
prettyDisplayUserId?: string;
|
||||
prettyId?: string;
|
||||
parentLeaderUserId?: number;
|
||||
parentLeaderUserId?: string;
|
||||
parentLeaderDisplayUserId?: string;
|
||||
parentLeaderUsername?: string;
|
||||
parentLeaderAvatar?: string;
|
||||
@ -449,10 +512,11 @@ export interface BDProfileDto {
|
||||
regionId?: number;
|
||||
regionName?: string;
|
||||
role?: string;
|
||||
salaryWallet?: SalaryWalletDto;
|
||||
status?: string;
|
||||
subBdCount?: number;
|
||||
updatedAtMs?: number;
|
||||
userId: number;
|
||||
userId: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
@ -466,13 +530,14 @@ export interface AgencyDto {
|
||||
ownerAvatar?: string;
|
||||
ownerDisplayUserId?: string;
|
||||
ownerUsername?: string;
|
||||
ownerUserId?: number;
|
||||
ownerUserId?: string;
|
||||
parentBdAvatar?: string;
|
||||
parentBdDisplayUserId?: string;
|
||||
parentBdUsername?: string;
|
||||
parentBdUserId?: number;
|
||||
parentBdUserId?: string;
|
||||
regionId?: number;
|
||||
regionName?: string;
|
||||
salaryWallet?: SalaryWalletDto;
|
||||
status?: string;
|
||||
updatedAtMs?: number;
|
||||
}
|
||||
@ -484,7 +549,7 @@ export interface HostProfileDto {
|
||||
currentAgencyName?: string;
|
||||
currentAgencyOwnerAvatar?: string;
|
||||
currentAgencyOwnerDisplayUserId?: string;
|
||||
currentAgencyOwnerUserId?: number;
|
||||
currentAgencyOwnerUserId?: string;
|
||||
currentAgencyOwnerUsername?: string;
|
||||
currentMembershipId?: number;
|
||||
diamond?: number;
|
||||
@ -494,10 +559,11 @@ export interface HostProfileDto {
|
||||
firstBecameHostAtMs?: number;
|
||||
regionId?: number;
|
||||
regionName?: string;
|
||||
salaryWallet?: SalaryWalletDto;
|
||||
source?: string;
|
||||
status?: string;
|
||||
updatedAtMs?: number;
|
||||
userId: number;
|
||||
userId: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
@ -516,7 +582,7 @@ export interface CoinSellerDto {
|
||||
status?: string;
|
||||
totalRechargeUsdtMicro?: number;
|
||||
updatedAtMs?: number;
|
||||
userId: number;
|
||||
userId: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
@ -540,6 +606,21 @@ export interface CoinSellerStockCreditDto {
|
||||
transactionId: string;
|
||||
}
|
||||
|
||||
export interface CoinSellerStockDebitPayload {
|
||||
coinAmount: number;
|
||||
commandId: string;
|
||||
evidenceRef?: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface CoinSellerStockDebitDto {
|
||||
availableDelta: number;
|
||||
balanceAfter: number;
|
||||
coinAmount: number;
|
||||
sellerUserId: string;
|
||||
transactionId: string;
|
||||
}
|
||||
|
||||
export interface CoinSellerSalaryRateTierDto {
|
||||
coinPerUsd: number;
|
||||
enabled?: boolean;
|
||||
@ -1038,7 +1119,7 @@ export interface HostCommandPayload {
|
||||
|
||||
export interface CreateBDLeaderPayload extends HostCommandPayload {
|
||||
positionAlias?: string;
|
||||
targetUserId: number;
|
||||
targetUserId: string;
|
||||
}
|
||||
|
||||
export interface BDLeaderPositionAliasPayload extends HostCommandPayload {
|
||||
@ -1046,8 +1127,8 @@ export interface BDLeaderPositionAliasPayload extends HostCommandPayload {
|
||||
}
|
||||
|
||||
export interface CreateBDPayload extends HostCommandPayload {
|
||||
parentLeaderUserId: number;
|
||||
targetUserId: number;
|
||||
parentLeaderUserId: string;
|
||||
targetUserId: string;
|
||||
}
|
||||
|
||||
export interface BDStatusPayload extends HostCommandPayload {
|
||||
@ -1056,7 +1137,7 @@ export interface BDStatusPayload extends HostCommandPayload {
|
||||
|
||||
export interface CreateCoinSellerPayload extends HostCommandPayload {
|
||||
contact?: string;
|
||||
targetUserId: number;
|
||||
targetUserId: string;
|
||||
}
|
||||
|
||||
export interface CoinSellerStatusPayload extends BDStatusPayload {
|
||||
@ -1066,8 +1147,8 @@ export interface CoinSellerStatusPayload extends BDStatusPayload {
|
||||
export interface CreateAgencyPayload extends HostCommandPayload {
|
||||
joinEnabled: boolean;
|
||||
name: string;
|
||||
ownerUserId: number;
|
||||
parentBdUserId: number;
|
||||
ownerUserId: string;
|
||||
parentBdUserId: string;
|
||||
}
|
||||
|
||||
export interface AgencyJoinEnabledPayload extends HostCommandPayload {
|
||||
|
||||
@ -230,6 +230,7 @@
|
||||
}
|
||||
|
||||
.paper {
|
||||
margin: 12px !important;
|
||||
width: calc(100vw - 24px) !important;
|
||||
max-width: calc(100vw - 24px) !important;
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ import MenuList from "@mui/material/MenuList";
|
||||
import Popover from "@mui/material/Popover";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||
@ -30,6 +30,7 @@ export function DataTable({
|
||||
minWidth = "980px",
|
||||
onColumnWidthsChange,
|
||||
pagination,
|
||||
renderRowDetail,
|
||||
resizableColumns = true,
|
||||
rowKey,
|
||||
tableClassName = "",
|
||||
@ -79,6 +80,9 @@ export function DataTable({
|
||||
);
|
||||
|
||||
const gridTemplate = preparedColumns.map((column) => column.width).join(" ");
|
||||
const tableContentWidth = `${Math.ceil(
|
||||
preparedColumns.reduce((total, column) => total + readMinWidth(column.width), 0)
|
||||
)}px`;
|
||||
const activeFilterColumn = preparedColumns.find((column) => column.key === filterMenu.columnKey && column.filter) || null;
|
||||
const activeFilter = activeFilterColumn?.filter || null;
|
||||
const activeFilterIsText = activeFilter?.type === "text";
|
||||
@ -266,7 +270,11 @@ export function DataTable({
|
||||
<div className="table-scroll" ref={scrollRef}>
|
||||
<div
|
||||
className={["admin-table", resizeSession ? "admin-table--resizing" : "", tableClassName].filter(Boolean).join(" ")}
|
||||
style={{ "--admin-table-columns": gridTemplate, "--admin-table-min-width": minWidth }}
|
||||
style={{
|
||||
"--admin-table-columns": gridTemplate,
|
||||
"--admin-table-content-width": tableContentWidth,
|
||||
"--admin-table-min-width": minWidth
|
||||
}}
|
||||
>
|
||||
<div className="admin-row admin-row--head">
|
||||
{preparedColumns.map((column, index) => {
|
||||
@ -304,15 +312,19 @@ export function DataTable({
|
||||
{safeItems.map((item, index) => {
|
||||
const rowProps = getRowProps ? getRowProps(item, index) : {};
|
||||
const { className: rowClassName = "", ...restRowProps } = rowProps;
|
||||
const key = rowKey(item);
|
||||
|
||||
return (
|
||||
<div className={["admin-row", rowClassName].filter(Boolean).join(" ")} key={rowKey(item)} {...restRowProps}>
|
||||
{preparedColumns.map((column) => (
|
||||
<div className={cellClassName(column)} key={column.key}>
|
||||
{column.render ? column.render(item, index, renderContext) : displayValue(item[column.key])}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Fragment key={key}>
|
||||
<div className={["admin-row", rowClassName].filter(Boolean).join(" ")} {...restRowProps}>
|
||||
{preparedColumns.map((column) => (
|
||||
<div className={cellClassName(column)} key={column.key}>
|
||||
{column.render ? column.render(item, index, renderContext) : displayValue(item[column.key])}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{renderRowDetail ? renderRowDetail(item, index, renderContext) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{infiniteScroll || paginationHasMore || paginationLoadingMore ? (
|
||||
|
||||
@ -61,6 +61,22 @@ test("only shows the table filter reset action when filters are active", () => {
|
||||
expect(screen.getByRole("button", { name: "重置筛选" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("expands table background to the minimum width required by columns", () => {
|
||||
const columns = [
|
||||
{ key: "user", label: "用户", width: "minmax(220px, 1fr)" },
|
||||
{ key: "createdAt", label: "创建时间", width: "minmax(180px, 1fr)" },
|
||||
{ key: "actions", label: "操作" }
|
||||
];
|
||||
|
||||
render(
|
||||
<TimeZoneProvider>
|
||||
<DataTable columns={columns} items={[{ createdAt: "2026-06-15", id: 1, user: "admin" }]} minWidth="300px" rowKey={(item) => item.id} />
|
||||
</TimeZoneProvider>
|
||||
);
|
||||
|
||||
expect(document.querySelector(".admin-table")?.style.getPropertyValue("--admin-table-content-width")).toBe("512px");
|
||||
});
|
||||
|
||||
function TimezoneTableFixture() {
|
||||
const { setTimeZone } = useTimeZone();
|
||||
const columns = [
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
import InputBase from "@mui/material/InputBase";
|
||||
import Search from "@mui/icons-material/Search";
|
||||
|
||||
export function SearchBox({ autoFocus = false, className = "", onChange, placeholder, value }) {
|
||||
export function SearchBox({ autoFocus = false, className = "", label = "搜索", onChange, placeholder, value }) {
|
||||
const hasValue = value !== undefined && value !== null && String(value).length > 0;
|
||||
|
||||
return (
|
||||
<label className={["search-box", className].filter(Boolean).join(" ")}>
|
||||
<Search fontSize="small" />
|
||||
<label className={["search-box", hasValue ? "search-box--active" : "", className].filter(Boolean).join(" ")}>
|
||||
<span className="search-box__label">
|
||||
<Search fontSize="inherit" />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
<InputBase
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
|
||||
@ -28,6 +28,7 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
|
||||
const open = Boolean(anchorEl);
|
||||
const days = useMemo(() => calendarDays(viewDate), [viewDate]);
|
||||
const selectedDate = dateForField(draft, activeField, viewDate);
|
||||
const hasRange = Boolean(range.startMs || range.endMs);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@ -110,14 +111,17 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
|
||||
<>
|
||||
<Button
|
||||
className={[styles.trigger, className].filter(Boolean).join(" ")}
|
||||
endIcon={<KeyboardArrowDownOutlined fontSize="small" />}
|
||||
startIcon={<AccessTimeOutlined fontSize="small" />}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
disabled={disabled}
|
||||
onClick={openPopover}
|
||||
>
|
||||
<span className={styles.triggerText}>{displayLabel}</span>
|
||||
<span className={styles.triggerMeta}>
|
||||
<AccessTimeOutlined fontSize="inherit" />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
<span className={styles.triggerText}>{hasRange ? displayLabel : "不限"}</span>
|
||||
<KeyboardArrowDownOutlined className={styles.triggerChevron} fontSize="small" />
|
||||
</Button>
|
||||
<Popover
|
||||
anchorEl={anchorEl}
|
||||
|
||||
@ -1,9 +1,15 @@
|
||||
.trigger {
|
||||
position: relative;
|
||||
display: inline-grid !important;
|
||||
grid-template-columns: minmax(0, 1fr) 18px;
|
||||
flex: 0 0 auto;
|
||||
justify-content: flex-start !important;
|
||||
align-items: end !important;
|
||||
justify-content: stretch !important;
|
||||
gap: 0 !important;
|
||||
width: auto;
|
||||
min-width: 132px !important;
|
||||
max-width: min(420px, 100%);
|
||||
padding: 7px 28px 3px var(--control-padding-x) !important;
|
||||
border-color: var(--border) !important;
|
||||
background: var(--bg-input-strong) !important;
|
||||
box-shadow: none !important;
|
||||
@ -21,24 +27,50 @@
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
.trigger :global(.MuiButton-startIcon) {
|
||||
margin-right: var(--space-2);
|
||||
margin-left: 0;
|
||||
.triggerMeta {
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
left: var(--space-2);
|
||||
display: inline-flex;
|
||||
max-width: calc(100% - var(--space-4));
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
padding: 0 var(--space-1);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
line-height: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trigger :global(.MuiButton-endIcon) {
|
||||
margin-right: 0;
|
||||
margin-left: var(--space-4);
|
||||
.trigger:hover .triggerMeta,
|
||||
.trigger:focus-visible .triggerMeta {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.triggerText {
|
||||
flex: 0 1 auto;
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--control-font-size);
|
||||
line-height: var(--control-line-height);
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.triggerChevron {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: var(--space-2);
|
||||
color: var(--text-tertiary);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.paper {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
|
||||
@ -1,16 +1,17 @@
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Select from "@mui/material/Select";
|
||||
import TextField from "@mui/material/TextField";
|
||||
|
||||
const ranges = ["近 1 小时", "近 24 小时", "近 7 天"];
|
||||
|
||||
export function TimeRangeSelect({ className = "", onChange, value }) {
|
||||
export function TimeRangeSelect({ className = "", label = "时间", onChange, value }) {
|
||||
return (
|
||||
<Select
|
||||
<TextField
|
||||
className={["time-select", className].filter(Boolean).join(" ")}
|
||||
label={label}
|
||||
select
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
size="small"
|
||||
sx={{ width: 116, height: 36, fontSize: "var(--admin-font-size)" }}
|
||||
variant="outlined"
|
||||
>
|
||||
{ranges.map((range) => (
|
||||
@ -18,6 +19,6 @@ export function TimeRangeSelect({ className = "", onChange, value }) {
|
||||
{range}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</TextField>
|
||||
);
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ export function ToastProvider({ children }) {
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
const showToast = useCallback((message, severity = "info") => {
|
||||
setToast({ message, severity });
|
||||
setToast(normalizeToast(message, severity));
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ showToast }), [showToast]);
|
||||
@ -39,3 +39,16 @@ export function useToast() {
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
function normalizeToast(message, severity) {
|
||||
if (message && typeof message === "object") {
|
||||
return {
|
||||
message: String(message.message || "操作失败"),
|
||||
severity: message.severity || severity || "info"
|
||||
};
|
||||
}
|
||||
return {
|
||||
message: String(message || "操作失败"),
|
||||
severity: severity || "info"
|
||||
};
|
||||
}
|
||||
|
||||
36
src/shared/ui/ToastProvider.test.jsx
Normal file
36
src/shared/ui/ToastProvider.test.jsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { expect, test } from "vitest";
|
||||
import { ToastProvider, useToast } from "./ToastProvider.jsx";
|
||||
|
||||
function ToastTrigger({ payload, severity }) {
|
||||
const { showToast } = useToast();
|
||||
return (
|
||||
<button type="button" onClick={() => showToast(payload, severity)}>
|
||||
show
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
test("renders object-style toast payloads as text", () => {
|
||||
render(
|
||||
<ToastProvider>
|
||||
<ToastTrigger payload={{ message: "工资结算失败", severity: "error" }} />
|
||||
</ToastProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "show" }));
|
||||
|
||||
expect(screen.getByText("工资结算失败")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps tuple-style toast payloads working", () => {
|
||||
render(
|
||||
<ToastProvider>
|
||||
<ToastTrigger payload="区域已更新" severity="success" />
|
||||
</ToastProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "show" }));
|
||||
|
||||
expect(screen.getByText("区域已更新")).toBeInTheDocument();
|
||||
});
|
||||
@ -165,11 +165,11 @@
|
||||
}
|
||||
|
||||
.app-switch {
|
||||
flex: 0 0 156px;
|
||||
flex: 0 0 164px;
|
||||
}
|
||||
|
||||
.timezone-switch {
|
||||
flex: 0 0 124px;
|
||||
flex: 0 0 140px;
|
||||
}
|
||||
|
||||
.app-switch .MuiOutlinedInput-root,
|
||||
@ -202,14 +202,15 @@
|
||||
}
|
||||
|
||||
.search-box {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1 1 420px;
|
||||
min-width: 180px;
|
||||
max-width: 520px;
|
||||
height: var(--control-height);
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: 0 var(--space-3);
|
||||
align-items: flex-end;
|
||||
gap: 0;
|
||||
padding: 7px var(--control-padding-x) 3px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-input);
|
||||
@ -222,31 +223,58 @@
|
||||
transform var(--motion-base) var(--ease-emphasized);
|
||||
}
|
||||
|
||||
.search-box__label {
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
left: var(--space-2);
|
||||
display: inline-flex;
|
||||
max-width: calc(100% - var(--space-4));
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
padding: 0 var(--space-1);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
line-height: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-box:focus-within {
|
||||
border-color: var(--primary-border-active);
|
||||
background: var(--bg-input-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.search-box svg {
|
||||
.search-box__label svg {
|
||||
flex: 0 0 auto;
|
||||
transition:
|
||||
color var(--motion-base) var(--ease-standard),
|
||||
transform var(--motion-base) var(--ease-emphasized);
|
||||
}
|
||||
|
||||
.search-box:focus-within svg {
|
||||
.search-box:focus-within .search-box__label,
|
||||
.search-box--active .search-box__label {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.search-box:focus-within .search-box__label svg {
|
||||
color: var(--primary);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.search-trigger {
|
||||
display: flex;
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: min(360px, 34vw);
|
||||
min-width: 260px;
|
||||
height: var(--control-height);
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: 0 var(--space-3);
|
||||
align-items: end;
|
||||
gap: 0;
|
||||
padding: 7px var(--control-padding-x) 3px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-input);
|
||||
@ -262,8 +290,36 @@
|
||||
transform var(--motion-base) var(--ease-emphasized);
|
||||
}
|
||||
|
||||
.search-trigger span {
|
||||
.search-trigger__label {
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
left: var(--space-2);
|
||||
display: inline-flex;
|
||||
max-width: calc(100% - var(--space-4));
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
padding: 0 var(--space-1);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
line-height: 14px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-trigger__label svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.search-trigger__value {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--control-font-size);
|
||||
font-weight: 650;
|
||||
line-height: var(--control-line-height);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@ -276,16 +332,22 @@
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.search-trigger:hover .search-trigger__label,
|
||||
.search-trigger:focus-visible .search-trigger__label {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
height: var(--control-line-height);
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--admin-font-size);
|
||||
line-height: 20px;
|
||||
font-size: var(--control-font-size);
|
||||
font-weight: 650;
|
||||
line-height: var(--control-line-height);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
@ -293,15 +355,10 @@
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.time-select {
|
||||
width: 116px;
|
||||
height: var(--control-height);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-input);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
color: var(--text-secondary);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
.time-select.MuiTextField-root,
|
||||
.time-select.MuiFormControl-root {
|
||||
flex: 0 0 124px;
|
||||
width: 124px;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
|
||||
@ -108,6 +108,10 @@
|
||||
--admin-control-height: var(--control-height);
|
||||
--admin-control-font-size: var(--control-font-size);
|
||||
--admin-control-line-height: var(--control-line-height);
|
||||
--admin-floating-label-font-size: 11px;
|
||||
--admin-floating-label-line-height: 14px;
|
||||
--admin-floating-label-offset-x: 9px;
|
||||
--admin-floating-label-offset-y: -7px;
|
||||
}
|
||||
|
||||
.region-select.MuiTextField-root,
|
||||
@ -219,12 +223,19 @@
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-control-font-size);
|
||||
line-height: var(--admin-control-line-height);
|
||||
transform: translate(12px, 6px) scale(1);
|
||||
letter-spacing: 0;
|
||||
transform: translate(var(--control-padding-x), calc((var(--admin-control-height) - var(--admin-control-line-height)) / 2)) scale(1);
|
||||
}
|
||||
|
||||
.MuiTextField-root .MuiInputLabel-outlined.MuiInputLabel-shrink,
|
||||
.MuiFormControl-root .MuiInputLabel-outlined.MuiInputLabel-shrink {
|
||||
transform: translate(12px, -7px) scale(0.75);
|
||||
max-width: calc((100% - 18px) / 0.78);
|
||||
padding: 0 var(--space-1);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-card);
|
||||
font-size: var(--admin-floating-label-font-size);
|
||||
line-height: var(--admin-floating-label-line-height);
|
||||
transform: translate(var(--admin-floating-label-offset-x), var(--admin-floating-label-offset-y)) scale(0.78);
|
||||
}
|
||||
|
||||
.MuiTextField-root .MuiFormLabel-root.Mui-focused,
|
||||
@ -1535,9 +1546,9 @@
|
||||
--admin-table-cell-x: var(--space-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: max(100%, var(--admin-table-min-width, 900px));
|
||||
width: max(100%, var(--admin-table-min-width, 900px), var(--admin-table-content-width, 0px));
|
||||
min-height: 100%;
|
||||
min-width: max(100%, var(--admin-table-min-width, 900px));
|
||||
min-width: max(100%, var(--admin-table-min-width, 900px), var(--admin-table-content-width, 0px));
|
||||
}
|
||||
|
||||
.admin-row {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user