Compare commits

...

21 Commits

Author SHA1 Message Date
zhx
64cfa472b8 工资相关 2026-06-15 10:43:53 +08:00
zhx
1447de8563 币商转账 2026-06-15 01:28:58 +08:00
zhx
aa7ce50e4f 表格工资相关 2026-06-15 00:37:46 +08:00
zhx
79da033dc9 主播工资 2026-06-14 23:41:21 +08:00
zhx
001eb0d4dc 用户列表流水 2026-06-13 15:14:46 +08:00
zhx
1beca72cb1 fix: remove lucky gift tier id copy 2026-06-12 23:33:57 +08:00
zhx
3900866df0 fix: auto generate lucky gift tier ids 2026-06-12 23:28:04 +08:00
zhx
282c9f1734 fix: prevent duplicate lucky gift tier ids 2026-06-12 23:22:45 +08:00
zhx
3983e355fb 1 2026-06-12 20:25:54 +08:00
zhx
ee4da04c61 黑色必胜 2026-06-12 20:12:52 +08:00
zhx
a8614398a4 邀请相关 2026-06-12 19:11:00 +08:00
zhx
fd8c5bbd78 fix(app-users): allow short id for pretty id grant 2026-06-12 15:50:22 +08:00
zhx
480b6e0825 口径统计更新 2026-06-12 12:07:15 +08:00
zhx
2ebacae7c2 修复报错 2026-06-12 11:20:02 +08:00
zhx
ac5d3a53d9 样式 2026-06-12 03:43:55 +08:00
zhx
5400f1dd57 修复databi 2026-06-12 03:39:36 +08:00
zhx
300d53a0cd databi 2026-06-12 03:14:57 +08:00
zhx
764da8affc Clarify robot account language label 2026-06-12 01:27:33 +08:00
zhx
087e379910 1 2026-06-12 00:34:45 +08:00
zhx
91498b7557 修改接口地址 2026-06-12 00:03:58 +08:00
zhx
4b16f93930 不写死 2026-06-11 21:29:37 +08:00
87 changed files with 5869 additions and 1420 deletions

1
.env.production Normal file
View File

@ -0,0 +1 @@
VITE_API_BASE_URL=/api

View File

@ -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"
}
}
},

View File

@ -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>
);
}

View File

@ -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) {

View File

@ -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 }),

View File

@ -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>

View File

@ -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

View 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 "--";
}

View File

@ -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)

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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" />

View File

@ -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;

View File

@ -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,

View File

@ -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 || "-";
}

View 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",
};
}

View File

@ -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 })}

View File

@ -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),

View File

@ -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/reservedassigned 号码必须通过释放或覆盖链路改变。

View File

@ -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),

View File

@ -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),

View File

@ -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),

View File

@ -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 {

View File

@ -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>

View File

@ -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) {

View File

@ -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),
})),
};

View File

@ -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,

View File

@ -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>额度案例默认最大下注 500040000/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]+/)

View File

@ -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),

View File

@ -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,
];
// hasFiltersBD + +
const hasFilters =
policyType !== "bd" || triggerMode !== "automatic" || cycleKey !== defaultCycleKey() || countryCode || regionId;
// hasFiltersBD + +
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 /

View File

@ -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" });

View File

@ -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>(

View 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>
);
}

View 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()}`;
}

View File

@ -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,
};
}

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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"),
};
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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;
}

View 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",
};
}

View File

@ -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;
}

View File

@ -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",
},
};

View File

@ -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;
}

View 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",
};
}

View File

@ -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),
};

View File

@ -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>;

View File

@ -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);
}

View File

@ -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({

View File

@ -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)}

View File

@ -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}`;
}

View File

@ -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);
}
}

View File

@ -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) {

View File

@ -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)}

View File

@ -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));
}

View File

@ -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", () => {

View File

@ -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);
});
});

View File

@ -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);

View 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 || "-";
}

View File

@ -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);
}

View File

@ -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 || "-";
}

View File

@ -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),

View File

@ -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),

View File

@ -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 () => {

View File

@ -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 {

View File

@ -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) {

View File

@ -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;
}

View File

@ -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",

View File

@ -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),

View File

@ -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),

View File

@ -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),

View File

@ -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,

View File

@ -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;

View File

@ -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 {

View File

@ -230,6 +230,7 @@
}
.paper {
margin: 12px !important;
width: calc(100vw - 24px) !important;
max-width: calc(100vw - 24px) !important;
}

View File

@ -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 ? (

View File

@ -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 = [

View File

@ -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}

View File

@ -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}

View File

@ -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);

View File

@ -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>
);
}

View File

@ -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"
};
}

View 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();
});

View File

@ -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 {

View File

@ -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 {