Compare commits

...

24 Commits

Author SHA1 Message Date
zhx
4b4054a4d0 cp功能 2026-06-08 13:15:35 +08:00
zhx
66ffb3647c 比例 2026-06-08 01:20:00 +08:00
zhx
62d88d178f 完善数据大屏 2026-06-08 00:53:55 +08:00
zhx
e2c0565dbe 时区问题 2026-06-07 14:51:01 +08:00
zhx
2118a2ee4d 交互优化 2026-06-07 14:45:51 +08:00
zhx
fd80e18aff 活动相关配置 2026-06-06 13:42:26 +08:00
zhx
945e2e7047 火箭,bi,币商相关 2026-06-06 00:24:58 +08:00
zhx
cf062aab5d Merge branch 'test' 2026-06-05 20:31:02 +08:00
zhx
825fe47367 火箭重新修改 2026-06-05 19:53:39 +08:00
zhx
af9da06615 币商流水增加 2026-06-05 19:09:16 +08:00
zhx
cf83d334e7 bd leader 别名 2026-06-05 18:24:47 +08:00
zhx
dcf5ea2045 修改一下礼物相关列显示 2026-06-05 14:57:44 +08:00
zhx
39d7e527cb feat: update admin platform resource and user tables 2026-06-05 13:58:54 +08:00
zhx
a33474ec5f fix: remove gift heat from admin resources 2026-06-05 13:57:36 +08:00
zhx
58be420ae9 主播相关页面改动 2026-06-05 13:19:29 +08:00
zhx
0f92efea87 公会相关 2026-06-04 23:05:10 +08:00
zhx
ca194e46a3 数据大屏 2026-06-04 19:29:35 +08:00
zhx
a1c7803af8 过期跳转 2026-06-04 15:31:20 +08:00
zhx
e3ab3cba1e 批量上传和databi 2026-06-04 14:56:02 +08:00
zhx
d4eaf20c8e 添加相关功能 2026-06-04 11:04:48 +08:00
zhx
a0b141c31b 修改路由 2026-06-02 21:07:29 +08:00
zhx
0c65edfa6f 修改礼物不支持 2026-06-02 18:22:36 +08:00
zhx
c0c81cd97b 去掉礼物相关 2026-06-02 18:00:12 +08:00
zhx
333659f80c feat: expose game safe height 2026-06-02 17:43:03 +08:00
183 changed files with 15605 additions and 1886 deletions

View File

@ -132,7 +132,7 @@
"x-permissions": ["user-leaderboard:view"]
}
},
"/resident-activity/voice-room-red-packet/records": {
"/admin/activity/red-packets": {
"get": {
"operationId": "listRedPackets",
"responses": {
@ -144,7 +144,7 @@
"x-permissions": ["red-packet:view"]
}
},
"/resident-activity/voice-room-red-packet/records/{packet_id}": {
"/admin/activity/red-packets/{packet_id}": {
"get": {
"operationId": "getRedPacket",
"responses": {
@ -166,7 +166,7 @@
"x-permissions": ["red-packet:view"]
}
},
"/resident-activity/voice-room-red-packet/config": {
"/admin/activity/red-packets/config": {
"get": {
"operationId": "getRedPacketConfig",
"responses": {
@ -177,7 +177,7 @@
"x-permission": "red-packet:view",
"x-permissions": ["red-packet:view"]
},
"post": {
"put": {
"operationId": "updateRedPacketConfig",
"responses": {
"200": {
@ -270,6 +270,122 @@
"x-permissions": ["first-recharge-reward:view"]
}
},
"/admin/activity/cumulative-recharge-reward/grants": {
"get": {
"operationId": "listCumulativeRechargeRewardGrants",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "cumulative-recharge-reward:view",
"x-permissions": ["cumulative-recharge-reward:view"]
}
},
"/admin/activity/room-turnover-reward/settlements": {
"get": {
"operationId": "listRoomTurnoverRewardSettlements",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "room-turnover-reward:view",
"x-permissions": ["room-turnover-reward:view"]
}
},
"/admin/activity/room-turnover-reward/settlements/{settlement_id}/retry": {
"post": {
"operationId": "retryRoomTurnoverRewardSettlement",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "room-turnover-reward:retry",
"x-permissions": ["room-turnover-reward:retry"]
}
},
"/admin/activity/weekly-star/cycles": {
"get": {
"operationId": "listWeeklyStarCycles",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "weekly-star:view",
"x-permissions": ["weekly-star:view"]
},
"post": {
"operationId": "createWeeklyStarCycle",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "weekly-star:create",
"x-permissions": ["weekly-star:create"]
}
},
"/admin/activity/weekly-star/cycles/{cycle_id}": {
"get": {
"operationId": "getWeeklyStarCycle",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "weekly-star:view",
"x-permissions": ["weekly-star:view"]
},
"put": {
"operationId": "updateWeeklyStarCycle",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "weekly-star:update",
"x-permissions": ["weekly-star:update"]
}
},
"/admin/activity/weekly-star/cycles/{cycle_id}/status": {
"patch": {
"operationId": "setWeeklyStarCycleStatus",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "weekly-star:update",
"x-permissions": ["weekly-star:update"]
}
},
"/admin/activity/weekly-star/cycles/{cycle_id}/leaderboard": {
"get": {
"operationId": "listWeeklyStarLeaderboard",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "weekly-star:view",
"x-permissions": ["weekly-star:view"]
}
},
"/admin/activity/weekly-star/cycles/{cycle_id}/settlements": {
"get": {
"operationId": "listWeeklyStarSettlements",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "weekly-star:settle",
"x-permissions": ["weekly-star:settle"]
}
},
"/admin/activity/registration-reward/claims": {
"get": {
"operationId": "listRegistrationRewardClaims",
@ -278,6 +394,52 @@
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "query",
"name": "page",
"schema": {
"type": "integer"
}
},
{
"in": "query",
"name": "page_size",
"schema": {
"type": "integer"
}
},
{
"in": "query",
"name": "keyword",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "status",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "claimed_start_ms",
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"in": "query",
"name": "claimed_end_ms",
"schema": {
"type": "integer",
"format": "int64"
}
}
],
"x-permission": "registration-reward:view",
"x-permissions": ["registration-reward:view"]
}
@ -304,6 +466,50 @@
"x-permissions": ["first-recharge-reward:update"]
}
},
"/admin/activity/cumulative-recharge-reward/config": {
"get": {
"operationId": "getCumulativeRechargeRewardConfig",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "cumulative-recharge-reward:view",
"x-permissions": ["cumulative-recharge-reward:view"]
},
"put": {
"operationId": "updateCumulativeRechargeRewardConfig",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "cumulative-recharge-reward:update",
"x-permissions": ["cumulative-recharge-reward:update"]
}
},
"/admin/activity/room-turnover-reward/config": {
"get": {
"operationId": "getRoomTurnoverRewardConfig",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "room-turnover-reward:view",
"x-permissions": ["room-turnover-reward:view"]
},
"put": {
"operationId": "updateRoomTurnoverRewardConfig",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "room-turnover-reward:update",
"x-permissions": ["room-turnover-reward:update"]
}
},
"/admin/activity/registration-reward/config": {
"get": {
"operationId": "getRegistrationRewardConfig",
@ -326,26 +532,26 @@
"x-permissions": ["registration-reward:update"]
}
},
"/admin/activity/room-treasure/config": {
"/admin/activity/room-rocket/config": {
"get": {
"operationId": "getRoomTreasureConfig",
"operationId": "getRoomRocketConfig",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "room-treasure:view",
"x-permissions": ["room-treasure:view"]
"x-permission": "room-rocket:view",
"x-permissions": ["room-rocket:view"]
},
"put": {
"operationId": "updateRoomTreasureConfig",
"operationId": "updateRoomRocketConfig",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "room-treasure:update",
"x-permissions": ["room-treasure:update"]
"x-permission": "room-rocket:update",
"x-permissions": ["room-rocket:update"]
}
},
"/admin/activity/seven-day-checkin/claims": {
@ -446,6 +652,28 @@
"x-permissions": ["agency:status"]
}
},
"/admin/agencies/{agency_id}/delete": {
"post": {
"operationId": "deleteAgency",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "agency_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "agency:delete",
"x-permissions": ["agency:delete"]
}
},
"/admin/agencies/{agency_id}/join-enabled": {
"post": {
"operationId": "setAgencyJoinEnabled",
@ -957,6 +1185,48 @@
"x-permissions": ["coin-seller:stock-credit"]
}
},
"/admin/coin-seller-salary-rates/{region_id}": {
"get": {
"operationId": "getCoinSellerSalaryRates",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "region_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "coin-seller:exchange-rate",
"x-permissions": ["coin-seller:exchange-rate"]
},
"put": {
"operationId": "replaceCoinSellerSalaryRates",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "region_id",
"required": true,
"schema": {
"type": "integer"
}
}
],
"x-permission": "coin-seller:exchange-rate",
"x-permissions": ["coin-seller:exchange-rate"]
}
},
"/admin/countries": {
"get": {
"operationId": "listCountries",
@ -1645,6 +1915,18 @@
"x-permissions": ["coin-ledger:view"]
}
},
"/admin/operations/coin-seller-ledger": {
"get": {
"operationId": "listCoinSellerLedger",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "coin-seller-ledger:view",
"x-permissions": ["coin-seller-ledger:view"]
}
},
"/admin/operations/reports": {
"get": {
"operationId": "listReports",
@ -1897,6 +2179,18 @@
"x-permissions": ["resource-grant:create"]
}
},
"/admin/resource-grants/target": {
"get": {
"operationId": "lookupResourceGrantTarget",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "resource-grant:create",
"x-permissions": ["resource-grant:create"]
}
},
"/admin/resource-shop/items": {
"get": {
"operationId": "listResourceShopItems",

View File

@ -1,94 +1,263 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchStatisticsOverview, getCurrentAppCode } from "./api.js";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { fetchFilterOptions, fetchStatisticsOverview, getCurrentAppCode, setCurrentAppCode } from "./api.js";
import { CountryPerformanceTable } from "./components/CountryPerformanceTable.jsx";
import { DatabiHeader } from "./components/DatabiHeader.jsx";
import { FunnelPanel } from "./components/FunnelPanel.jsx";
import { GlobalOverviewPanel } from "./components/GlobalOverviewPanel.jsx";
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 { createDashboardModel } from "./data/createDashboardModel.js";
import { sampleOverview } from "./data/sampleOverview.js";
import { endOfDay, lastDaysRange, startOfDay } from "./utils/time.js";
import { lastDaysRange, rangeEndMs, rangeStartMs, readStoredTimeZone, sameRange, thisMonthRange, TIME_ZONE_OPTIONS, todayRange, writeStoredTimeZone } from "./utils/time.js";
const initialFilterOptions = {
appOptions: [],
countryOptions: [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }],
regionOptions: [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }]
};
export function DatabiApp() {
const initialRange = useMemo(() => lastDaysRange(7), []);
const initialTimeZone = useMemo(() => readStoredTimeZone(), []);
const initialRange = useMemo(() => todayRange(initialTimeZone), [initialTimeZone]);
const [timeZone, setTimeZone] = useState(initialTimeZone);
const [range, setRange] = useState(initialRange);
const [countryId, setCountryId] = useState(0);
const [overview, setOverview] = useState(sampleOverview);
const [loading, setLoading] = useState(false);
const [regionId, setRegionId] = useState("all");
const [appCode, setAppCode] = useState(() => getCurrentAppCode());
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 [preview, setPreview] = useState(true);
const appCode = getCurrentAppCode();
const [poolModalOpen, setPoolModalOpen] = useState(false);
const [trendModalItem, setTrendModalItem] = useState(null);
const overviewRequestIdRef = useRef(0);
const overviewRef = useRef(null);
const scopedCountries = useMemo(() => {
return filterOptions.countryOptions.filter((country) => Number(country.id) > 0 && countryBelongsToRegion(country, regionId));
}, [filterOptions.countryOptions, regionId]);
const loadOverview = useCallback(async () => {
const handleAppChange = useCallback((value) => {
setAppCode(setCurrentAppCode(value));
}, []);
const handleRegionChange = useCallback((value) => {
setRegionId(String(value || "all"));
setCountryId(0);
}, []);
const handleTimeZoneChange = useCallback((value) => {
const nextTimeZone = writeStoredTimeZone(value);
setRange((currentRange) => {
const currentPreset = resolveRangePresetValue(currentRange, timeZone);
return currentPreset ? rangeForPreset(currentPreset, nextTimeZone) : currentRange;
});
setTimeZone(nextTimeZone);
}, [timeZone]);
const loadFilterOptions = useCallback(async () => {
setFiltersLoading(true);
try {
const options = await fetchFilterOptions({ appCode });
setFilterOptions(options);
const nextAppCode = resolveSelectedAppCode(options.appOptions, appCode);
if (nextAppCode !== appCode) {
setAppCode(setCurrentAppCode(nextAppCode));
}
setRegionId((current) => (options.regionOptions.some((item) => String(item.id) === String(current)) ? current : "all"));
setCountryId((current) => (options.countryOptions.some((item) => Number(item.id) === Number(current)) ? current : 0));
} catch (err) {
setError(err.message || "筛选项加载失败");
} finally {
setFiltersLoading(false);
}
}, [appCode]);
useEffect(() => {
overviewRef.current = overview;
}, [overview]);
const loadOverview = useCallback(async ({ preserveData = false } = {}) => {
const requestId = overviewRequestIdRef.current + 1;
overviewRequestIdRef.current = requestId;
const keepCurrentOverview = preserveData && overviewRef.current;
setLoading(true);
setError("");
if (!keepCurrentOverview) {
setOverview(null);
}
try {
const startMs = rangeStartMs(range, timeZone);
const endMs = rangeEndMs(range, timeZone);
const seriesRange = sameRange(range, todayRange(timeZone)) ? lastDaysRange(7, timeZone) : range;
const data = await fetchStatisticsOverview({
appCode,
countryId,
endMs: endOfDay(range.end),
startMs: startOfDay(range.start)
endMs,
regionId,
seriesEndMs: rangeEndMs(seriesRange, timeZone),
seriesStartMs: rangeStartMs(seriesRange, timeZone),
startMs
});
setOverview(data || sampleOverview);
setPreview(false);
const nextOverview = enrichCountryBreakdown(data || {}, scopedCountries);
if (requestId === overviewRequestIdRef.current) {
setOverview(nextOverview);
}
} catch (err) {
setOverview(sampleOverview);
setPreview(true);
setError(err.message || "请求失败");
if (requestId === overviewRequestIdRef.current) {
if (!keepCurrentOverview) {
setOverview(null);
}
setError(err.message || "请求失败");
}
} finally {
setLoading(false);
if (requestId === overviewRequestIdRef.current) {
setLoading(false);
}
}
}, [appCode, countryId, range.end, range.start]);
}, [appCode, countryId, range, regionId, scopedCountries, timeZone]);
useEffect(() => {
void loadFilterOptions();
}, [loadFilterOptions]);
useEffect(() => {
void loadOverview();
const timer = window.setInterval(() => void loadOverview(), 60_000);
const timer = window.setInterval(() => void loadOverview({ preserveData: true }), 60_000);
return () => window.clearInterval(timer);
}, [loadOverview]);
const model = useMemo(() => createDashboardModel(overview, { appCode, countryId, preview }), [appCode, countryId, overview, preview]);
const handleRefresh = useCallback(() => {
void loadOverview({ preserveData: true });
}, [loadOverview]);
const model = useMemo(() => createDashboardModel(overview, { appCode, countryId, range, timeZone }), [appCode, countryId, overview, range, timeZone]);
const showSkeleton = loading && !overview;
const openLuckyGiftPools = useCallback(() => {
setPoolModalOpen(true);
}, []);
return (
<main className="databi-shell">
<section className="databi-screen">
<DatabiHeader
appCode={appCode}
appOptions={filterOptions.appOptions}
countryOptions={filterOptions.countryOptions}
countryId={countryId}
error={error}
filtersLoading={filtersLoading}
loading={loading}
onAppChange={handleAppChange}
onCountryChange={setCountryId}
onRefresh={loadOverview}
onRefresh={handleRefresh}
onRangeChange={setRange}
preview={preview}
onRegionChange={handleRegionChange}
onTimeZoneChange={handleTimeZoneChange}
range={range}
regionId={regionId}
regionOptions={filterOptions.regionOptions}
timeZone={timeZone}
timeZoneOptions={TIME_ZONE_OPTIONS}
updatedAt={model.updatedAt}
/>
<section className="metric-grid" aria-label="核心指标">
{model.kpis.map((item) => (
<MetricCard key={item.label} item={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} />
<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} topCountries={model.topCountries} />
<RevenueTrendPanel revenueSeries={model.revenueSeries} />
<FunnelPanel funnel={model.funnel} sideMetrics={model.sideMetrics} />
<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} />
<footer className="databi-footer">
<span>所有数据按 UTC 展示实时更新</span>
<strong>数据最多可能延迟 60 </strong>
</footer>
<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}
</main>
);
}
function resolveSelectedAppCode(options, current) {
const normalized = String(current || "").trim().toLowerCase();
if (options.some((item) => String(item.code || item.value || "").trim().toLowerCase() === normalized)) {
return normalized;
}
return String(options[0]?.code || options[0]?.value || normalized || "lalu").trim().toLowerCase();
}
function resolveRangePresetValue(range, timeZone) {
const presets = [
{ range: todayRange(timeZone), value: "today" },
{ range: lastDaysRange(7, timeZone), value: "7d" },
{ range: lastDaysRange(14, timeZone), value: "14d" },
{ range: lastDaysRange(30, timeZone), value: "30d" },
{ range: thisMonthRange(timeZone), value: "month" }
];
return presets.find((preset) => sameRange(preset.range, range))?.value || "";
}
function rangeForPreset(value, timeZone) {
switch (value) {
case "today":
return todayRange(timeZone);
case "7d":
return lastDaysRange(7, timeZone);
case "14d":
return lastDaysRange(14, timeZone);
case "30d":
return lastDaysRange(30, timeZone);
case "month":
return thisMonthRange(timeZone);
default:
return null;
}
}
function countryBelongsToRegion(country, regionId) {
if (!regionId || regionId === "all") {
return true;
}
if (Array.isArray(country.regionIds) && country.regionIds.length) {
return country.regionIds.includes(String(regionId));
}
return String(country.regionId || "") === String(regionId);
}
function enrichCountryBreakdown(data, countries) {
if (!Array.isArray(data?.country_breakdown) || !data.country_breakdown.length) {
return data;
}
const countryById = new Map(countries.map((country) => [Number(country.id), country]));
return {
...data,
country_breakdown: data.country_breakdown.map((row) => {
const country = countryById.get(Number(row.country_id ?? row.countryId));
if (!country) {
return row;
}
return {
...row,
country: row.country || country.name || country.label,
country_code: row.country_code || country.countryCode,
flag: row.flag || country.flag
};
})
};
}

View File

@ -0,0 +1,60 @@
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";
vi.mock("./api.js", () => ({
fetchFilterOptions: vi.fn(),
fetchStatisticsOverview: vi.fn(),
getCurrentAppCode: vi.fn(() => "lalu"),
setCurrentAppCode: vi.fn((value) => String(value || "lalu").toLowerCase())
}));
vi.mock("./charts/EChart.jsx", () => ({
EChart: () => <div data-testid="databi-chart" />
}));
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-06T08:00:00Z"));
fetchFilterOptions.mockResolvedValue({
appOptions: [{ code: "lalu", label: "Lalu", value: "lalu" }],
countryOptions: [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }],
regionOptions: [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }]
});
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
test("keeps current data visible during scheduled refresh", async () => {
const overview = { active_users: 10, paid_users: 2, recharge_usd_minor: 100, updated_at_ms: 1 };
fetchStatisticsOverview
.mockResolvedValueOnce(overview)
.mockResolvedValueOnce(overview)
.mockImplementationOnce(() => new Promise(() => {}));
render(<DatabiApp />);
await flushEffects();
expect(screen.getAllByText("$1").length).toBeGreaterThan(0);
expect(document.querySelector(".metric-card.is-loading")).toBeNull();
await act(async () => {
vi.advanceTimersByTime(60_000);
});
expect(fetchStatisticsOverview).toHaveBeenCalledTimes(3);
expect(screen.getAllByText("$1").length).toBeGreaterThan(0);
expect(document.querySelector(".metric-card.is-loading")).toBeNull();
});
async function flushEffects() {
await act(async () => {
for (let index = 0; index < 10; index += 1) {
await Promise.resolve();
}
});
}

View File

@ -1,4 +1,7 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "/api";
import { API_OPERATIONS, apiEndpointPath } from "../../src/shared/api/generated/endpoints.ts";
import { countryFlag, stripCountryFlagPrefix } from "./data/countryMeta.js";
const API_BASE_URL = import.meta.env?.VITE_API_BASE_URL || "/api";
const TOKEN_KEY = "hyapp-admin.access-token";
const APP_CODE_KEY = "hyapp-admin.app-code";
const APP_CODE_HEADER = "X-App-Code";
@ -7,30 +10,74 @@ export function getCurrentAppCode() {
return normalizeAppCode(window.localStorage.getItem(APP_CODE_KEY) || "lalu");
}
export async function fetchStatisticsOverview({ appCode, countryId, endMs, startMs }) {
const query = new URLSearchParams();
query.set("app_code", normalizeAppCode(appCode) || "lalu");
export function setCurrentAppCode(value) {
const appCode = normalizeAppCode(value) || "lalu";
window.localStorage.setItem(APP_CODE_KEY, appCode);
return appCode;
}
export async function fetchStatisticsOverview({ appCode, countryId, endMs, regionId, seriesEndMs, seriesStartMs, startMs }) {
const query = { app_code: normalizeAppCode(appCode) || "lalu" };
if (startMs) {
query.set("start_ms", String(startMs));
query.start_ms = String(startMs);
}
if (endMs) {
query.set("end_ms", String(endMs));
query.end_ms = String(endMs);
}
if (seriesStartMs) {
query.series_start_ms = String(seriesStartMs);
}
if (seriesEndMs) {
query.series_end_ms = String(seriesEndMs);
}
if (countryId) {
query.set("country_id", String(countryId));
query.country_id = String(countryId);
}
if (regionId && regionId !== "all") {
query.region_id = String(regionId);
}
const response = await fetch(`${API_BASE_URL}/v1/statistics/overview?${query.toString()}`, {
return fetchDatabiData("/v1/statistics/overview", { appCode, query });
}
export async function fetchFilterOptions({ appCode } = {}) {
const [apps, regions, countries] = await Promise.all([
fetchDatabiData(apiEndpointPath(API_OPERATIONS.listApps), { appCode }),
fetchDatabiData(apiEndpointPath(API_OPERATIONS.listRegions), { appCode, query: { status: "active" } }),
fetchDatabiData(apiEndpointPath(API_OPERATIONS.listCountries), { appCode, query: { enabled: true } })
]);
return normalizeFilterOptions({ apps, countries, regions });
}
async function fetchDatabiData(path, { appCode, query } = {}) {
const response = await fetch(buildURL(path, query), {
credentials: "include",
headers: requestHeaders(appCode)
});
const payload = await readJSON(response);
if (isAuthExpired(response, payload)) {
redirectToLogin(window);
throw new Error(payload.message || "访问凭证已失效");
}
if (!response.ok || payload.code !== 0) {
throw new Error(payload.message || response.statusText || "请求失败");
}
return payload.data;
}
function buildURL(path, query) {
const url = new URL(`${API_BASE_URL}${path}`, window.location.origin);
if (query) {
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== "") {
url.searchParams.set(key, String(value));
}
});
}
return url.toString();
}
function requestHeaders(appCode) {
const headers = {};
const token = window.localStorage.getItem(TOKEN_KEY);
@ -56,6 +103,117 @@ async function readJSON(response) {
}
}
export function isAuthExpired(response, payload) {
return response.status === 401 || Number(payload?.code) === 40100;
}
export function redirectToLogin(targetWindow = window) {
targetWindow.localStorage.removeItem(TOKEN_KEY);
if (targetWindow.location.pathname !== "/login") {
targetWindow.location.replace("/login");
}
}
function normalizeAppCode(value) {
return String(value || "").trim().toLowerCase();
}
function normalizeFilterOptions({ apps, countries, regions }) {
const regionItems = normalizeList(regions);
const regionOptions = [
{ countryCodes: [], id: "all", label: "全部区域", value: "all" },
...regionItems
.filter((region) => String(region.regionCode || "").toUpperCase() !== "GLOBAL")
.map((region) => {
const id = String(region.regionId ?? region.id ?? region.regionCode ?? "");
return {
countryCodes: normalizeCodes(region.countries),
id,
label: [region.name, region.regionCode].filter(Boolean).join(" · ") || `区域 ${id}`,
value: id
};
})
.filter((region) => region.id)
];
const countryRegionIds = buildCountryRegionIndex(regionOptions);
const countryOptions = [
{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 },
...normalizeList(countries)
.map((country) => normalizeCountryOption(country, countryRegionIds))
.filter(Boolean)
];
const appOptions = normalizeList(apps)
.map((app) => {
const code = normalizeAppCode(app.appCode ?? app.app_code ?? app.code);
return {
code,
label: app.appName || app.app_name || app.name || code,
value: code
};
})
.filter((app) => app.code);
return {
appOptions,
countryOptions,
regionOptions
};
}
function normalizeList(value) {
if (Array.isArray(value)) {
return value;
}
if (Array.isArray(value?.items)) {
return value.items;
}
if (Array.isArray(value?.list)) {
return value.list;
}
return [];
}
function normalizeCodes(value) {
return Array.isArray(value) ? value.map((item) => String(item || "").trim().toUpperCase()).filter(Boolean) : [];
}
function buildCountryRegionIndex(regions) {
const index = new Map();
regions.forEach((region) => {
if (region.id === "all") {
return;
}
region.countryCodes.forEach((code) => {
const ids = index.get(code) || [];
ids.push(region.id);
index.set(code, ids);
});
});
return index;
}
function normalizeCountryOption(country, countryRegionIds) {
const countryCode = String(country.countryCode ?? country.country_code ?? country.code ?? "").trim().toUpperCase();
const id = Number(country.countryId ?? country.country_id ?? country.id ?? 0);
if (!countryCode && !id) {
return null;
}
const rawName = country.countryDisplayName || country.country_display_name || country.countryName || country.country_name || country.name || countryCode;
const name = stripCountryFlagPrefix(rawName);
const flag = country.flag || countryFlag(countryCode);
const label = [flag, name].filter(Boolean).join(" ");
const searchText = [flag, rawName, name, countryCode, country.isoAlpha3, country.iso_alpha3, country.phoneCountryCode, country.phone_country_code]
.filter(Boolean)
.join(" ")
.toLowerCase();
return {
countryCode,
flag,
id,
label,
name,
regionIds: countryRegionIds.get(countryCode) || [],
searchText,
value: id
};
}

View File

@ -3,8 +3,10 @@ import { formatMoney } from "../../utils/format.js";
export function createGeoOption(items) {
const points = items
.filter((item) => Array.isArray(item.geo_point) && item.geo_point.length === 2)
.map((item) => [...item.geo_point, item.recharge_usd_minor, item.country]);
.filter((item) => Number(item.recharge_usd_minor) > 0)
.map((item) => [...item.geo_point, Number(item.recharge_usd_minor) || 0, item.country]);
const max = Math.max(...points.map((item) => item[2]), 1);
const min = Math.min(...points.map((item) => item[2]), max);
return {
animationDuration: 650,
geo: {
@ -27,7 +29,11 @@ export function createGeoOption(items) {
{
coordinateSystem: "geo",
data: points,
itemStyle: { color: "rgba(40, 228, 245, 0.18)", shadowBlur: 26, shadowColor: "#23e4ff" },
itemStyle: {
color: ({ value }) => colorByValue(value[2], min, max, 0.2),
shadowBlur: 28,
shadowColor: "rgba(39, 228, 245, 0.6)"
},
silent: true,
symbolSize: (value) => 14 + (value[2] / max) * 32,
type: "scatter",
@ -36,7 +42,13 @@ export function createGeoOption(items) {
{
coordinateSystem: "geo",
data: points,
itemStyle: { borderColor: "#9ffcff", borderWidth: 1.5, color: "#28e4f5", shadowBlur: 22, shadowColor: "#23e4ff" },
itemStyle: {
borderColor: "rgba(232, 255, 255, 0.9)",
borderWidth: 1.5,
color: ({ value }) => colorByValue(value[2], min, max, 1),
shadowBlur: 24,
shadowColor: "rgba(39, 228, 245, 0.74)"
},
rippleEffect: { brushType: "stroke", number: 3, scale: 3.6 },
symbolSize: (value) => 6 + (value[2] / max) * 15,
type: "effectScatter",
@ -51,3 +63,22 @@ export function createGeoOption(items) {
}
};
}
function colorByValue(value, min, max, alpha = 1) {
const strength = normalizeStrength(value, min, max);
const low = [66, 129, 255];
const mid = [39, 228, 245];
const high = [36, 215, 159];
const from = strength < 0.55 ? low : mid;
const to = strength < 0.55 ? mid : high;
const local = strength < 0.55 ? strength / 0.55 : (strength - 0.55) / 0.45;
const [red, green, blue] = from.map((channel, index) => Math.round(channel + (to[index] - channel) * local));
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
function normalizeStrength(value, min, max) {
if (max <= min) {
return 1;
}
return Math.min(Math.max((Math.sqrt(value) - Math.sqrt(min)) / (Math.sqrt(max) - Math.sqrt(min)), 0), 1);
}

View File

@ -0,0 +1,62 @@
import { compactMoneyAxis, formatCoin } from "../../utils/format.js";
export function createMetricTrendOption(item) {
const lines = Array.isArray(item?.trend) ? item.trend.filter((line) => Array.isArray(line.data) && line.data.length) : [];
const labels = lines[0]?.data.map((point) => point.label) || [];
const values = lines.flatMap((line) => line.data.map((point) => Number(point.value) || 0));
const axis = niceAxis(values);
return {
animationDuration: 650,
color: ["#27e4f5", "#24d79f", "#4f8cff"],
grid: { bottom: 32, left: 58, right: 24, top: 42 },
legend: { icon: "roundRect", itemGap: 18, itemHeight: 8, itemWidth: 14, textStyle: { color: "#a8bdd8", fontSize: 12 }, top: 0 },
tooltip: {
backgroundColor: "#08243a",
borderColor: "#1a5f82",
formatter: (params) => {
const rows = Array.isArray(params) ? params : [params];
return rows.map((row, index) => `${index === 0 ? `${row.axisValue}<br/>` : ""}${row.marker}${row.seriesName}: ${formatTrendValue(row.data, item.trendFormat)}`).join("<br/>");
},
textStyle: { color: "#e9f6ff" },
trigger: "axis"
},
xAxis: { axisLabel: { color: "#90a8c4", margin: 10 }, axisLine: { lineStyle: { color: "#1a3a56" } }, axisTick: { show: false }, boundaryGap: false, data: labels, type: "category" },
yAxis: { axisLabel: { color: "#90a8c4", formatter: (value) => formatTrendValue(value, item.trendFormat, true) }, axisTick: { show: false }, interval: axis.interval, max: axis.max, min: 0, splitLine: { lineStyle: { color: "rgba(111, 166, 212, 0.16)", type: "dashed" } }, type: "value" },
series: lines.map((line) => ({
areaStyle: { color: "rgba(39, 228, 245, 0.08)" },
data: line.data.map((point) => point.value),
lineStyle: { width: 3 },
name: line.name,
smooth: 0.38,
symbol: "circle",
symbolSize: 7,
type: "line"
}))
};
}
export function niceAxis(values) {
const maxValue = Math.max(...values, 0);
if (maxValue <= 0) {
return { interval: 1, max: 4 };
}
const rawStep = maxValue / 4;
const magnitude = 10 ** Math.floor(Math.log10(rawStep));
const normalized = rawStep / magnitude;
const step = (normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10) * magnitude;
return {
interval: step,
max: step * Math.ceil(maxValue / step)
};
}
function formatTrendValue(value, format, axis = false) {
const numeric = Number(value) || 0;
if (format === "money") {
return axis ? compactMoneyAxis(numeric) : `$${numeric.toLocaleString("en-US", { maximumFractionDigits: 2 })}`;
}
if (format === "coin") {
return axis ? compactMoneyAxis(numeric) : formatCoin(numeric);
}
return axis ? compactMoneyAxis(numeric) : numeric.toLocaleString("en-US");
}

View File

@ -1,27 +1,30 @@
import { compactMoneyAxis, moneyMajor } from "../../utils/format.js";
import { niceAxis } from "./createMetricTrendOption.js";
export function createRevenueOption(series) {
const labels = series.map((item) => item.label);
const leftMax = Math.max(2_000_000, ...series.map((item) => moneyMajor(item.google) + moneyMajor(item.mifapay) + moneyMajor(item.coin_seller)));
const rightMax = Math.max(8_000_000, ...series.map((item) => moneyMajor(item.lineTotal ?? item.total)));
const leftValues = series.map((item) => moneyMajor(item.google) + moneyMajor(item.mifapay) + moneyMajor(item.coin_seller));
const rightValues = series.map((item) => moneyMajor(item.lineTotal ?? item.total));
const leftAxis = niceAxis(leftValues);
const rightAxis = niceAxis(rightValues);
return {
animationDuration: 650,
color: ["#1bd8f2", "#20c9aa", "#4f8cff", "#f2f6ff"],
grid: { bottom: 30, left: 54, right: 54, top: 44 },
legend: [
{ data: ["Google", "MifaPay", "币商"], icon: "roundRect", itemGap: 16, itemHeight: 8, itemWidth: 12, left: 22, textStyle: { color: "#a8bdd8", fontSize: 12 }, top: 1 },
{ data: ["Google", "MifaPay", "币商"], icon: "roundRect", itemGap: 16, itemHeight: 8, itemWidth: 12, left: 22, textStyle: { color: "#a8bdd8", fontSize: 12 }, top: 1 },
{ data: ["总计 (USD)"], itemGap: 8, itemHeight: 8, itemWidth: 18, right: 18, textStyle: { color: "#d8e8f8", fontSize: 12 }, top: 1 }
],
tooltip: { backgroundColor: "#08243a", borderColor: "#1a5f82", extraCssText: "box-shadow: 0 10px 26px rgba(0,0,0,.32);", textStyle: { color: "#e9f6ff" }, trigger: "axis" },
xAxis: { axisLabel: { color: "#90a8c4", margin: 10 }, axisLine: { lineStyle: { color: "#1a3a56" } }, axisTick: { show: false }, boundaryGap: true, data: labels, type: "category" },
yAxis: [
{ axisLabel: { color: "#90a8c4", formatter: compactMoneyAxis }, axisTick: { show: false }, interval: leftMax <= 2_000_000 ? 500_000 : undefined, max: roundAxisMax(leftMax), splitLine: { lineStyle: { color: "rgba(111, 166, 212, 0.16)", type: "dashed" } }, type: "value" },
{ axisLabel: { color: "#90a8c4", formatter: compactMoneyAxis }, axisTick: { show: false }, interval: rightMax <= 8_000_000 ? 2_000_000 : undefined, max: roundAxisMax(rightMax), splitLine: { show: false }, type: "value" }
{ axisLabel: { color: "#90a8c4", formatter: compactMoneyAxis }, axisTick: { show: false }, interval: leftAxis.interval, max: leftAxis.max, min: 0, splitLine: { lineStyle: { color: "rgba(111, 166, 212, 0.16)", type: "dashed" } }, type: "value" },
{ axisLabel: { color: "#90a8c4", formatter: compactMoneyAxis }, axisTick: { show: false }, interval: rightAxis.interval, max: rightAxis.max, min: 0, splitLine: { show: false }, type: "value" }
],
series: [
{ barGap: "-100%", barWidth: 28, data: series.map((item) => moneyMajor(item.google)), emphasis: { focus: "series" }, name: "Google", stack: "recharge", type: "bar" },
{ barWidth: 28, data: series.map((item) => moneyMajor(item.mifapay)), emphasis: { focus: "series" }, name: "MifaPay", stack: "recharge", type: "bar" },
{ barWidth: 28, data: series.map((item) => moneyMajor(item.coin_seller)), emphasis: { focus: "series" }, name: "币商", stack: "recharge", type: "bar" },
{ barWidth: 28, data: series.map((item) => moneyMajor(item.coin_seller)), emphasis: { focus: "series" }, name: "币商", stack: "recharge", type: "bar" },
{
data: series.map((item) => moneyMajor(item.lineTotal ?? item.total)),
lineStyle: { color: "#f2f6ff", shadowBlur: 10, shadowColor: "rgba(242,246,255,.42)", width: 3 },
@ -37,14 +40,3 @@ export function createRevenueOption(series) {
]
};
}
function roundAxisMax(value) {
if (value <= 2_000_000) {
return 2_000_000;
}
if (value <= 8_000_000) {
return 8_000_000;
}
const step = value > 10_000_000 ? 5_000_000 : 1_000_000;
return Math.ceil(value / step) * step;
}

View File

@ -1,22 +1,114 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { formatCoin, formatMoney, formatMoneyFull, formatNumber, formatPercent } from "../utils/format.js";
import { Panel } from "./Panel.jsx";
const textCollator = new Intl.Collator("zh-Hans", { numeric: true, sensitivity: "base" });
const tabs = [
{ key: "country", label: "国家表现" },
{ key: "gift", label: "礼物排行" },
{ key: "game", label: "游戏排行" }
];
export function CountryPerformanceTable({ gameRanking, giftRanking, items }) {
const defaultSortByTab = {
country: { direction: "desc", key: "recharge_usd_minor", type: "number" },
gift: { direction: "desc", key: "value", type: "number" },
game: { direction: "desc", key: "turnover_coin", type: "number" }
};
const countryColumns = [
{ key: "country", label: "国家", type: "text", align: "left" },
{ key: "visitors", label: "访客" },
{ key: "active_users", label: "活跃用户" },
{ key: "paid_users", label: "付费用户" },
{ key: "recharge_users", label: "充值用户" },
{ key: "recharge_usd_minor", label: "总充值 (USD)" },
{ key: "new_user_recharge_usd_minor", label: "新用户充值 (USD)" },
{ key: "arpu_usd_minor", label: "ARPU (USD)" },
{ key: "arppu_usd_minor", label: "ARPPU (USD)" },
{ key: "payer_rate", label: "付费转化率" },
{ key: "recharge_conversion_rate", label: "充值转化率" },
{ key: "avg_recharge_usd_minor", label: "平均充值 (USD)" },
{ key: "trend_rate", label: "近 7 日" }
];
const countrySortAccessors = {
active_users: (item) => item.active_users,
arppu_usd_minor: (item) => item.arppu_usd_minor,
arpu_usd_minor: (item) => item.arpu_usd_minor,
avg_recharge_usd_minor: (item) => item.avg_recharge_usd_minor,
country: (item) => item.country,
new_user_recharge_usd_minor: (item) => item.new_user_recharge_usd_minor,
paid_users: (item) => item.paid_users,
payer_rate: (item) => item.payer_rate,
recharge_conversion_rate: (item) => item.recharge_conversion_rate,
recharge_users: (item) => item.recharge_users,
recharge_usd_minor: (item) => item.recharge_usd_minor,
trend_rate: (item) => item.trend_rate,
visitors: (item) => item.visitors
};
const giftColumns = [
{ key: "name", label: "礼物", type: "text", align: "left" },
{ key: "value", label: "金币消费" },
{ key: "share", label: "占比" },
{ key: "trend_rate", label: "近 7 日" }
];
const gameColumns = [
{ key: "game_id", label: "游戏", type: "text", align: "left" },
{ key: "turnover_coin", label: "流水 (金币)" },
{ key: "profit_rate", label: "利润率" },
{ key: "share", label: "占比" }
];
export function CountryPerformanceTable({ gameRanking, giftRanking, items, loading = false }) {
const [activeTab, setActiveTab] = useState("country");
const [sortByTab, setSortByTab] = useState(defaultSortByTab);
const handleSort = (tabKey, columnKey, type = "number") => {
setSortByTab((previous) => {
const current = previous[tabKey];
const nextDirection = current?.key === columnKey && current.direction === "desc" ? "asc" : "desc";
return {
...previous,
[tabKey]: {
direction: type === "text" && current?.key !== columnKey ? "asc" : nextDirection,
key: columnKey,
type
}
};
});
};
return (
<Panel className="panel--table" title={<TableTabs activeTab={activeTab} onChange={setActiveTab} />}>
<div className="country-table-wrap">
{activeTab === "country" ? <CountryTable items={items} /> : null}
{activeTab === "gift" ? <GiftTable items={giftRanking} /> : null}
{activeTab === "game" ? <GameTable items={gameRanking} /> : null}
{activeTab === "country" ? (
<CountryTable
items={items}
loading={loading}
onSort={(columnKey, type) => handleSort("country", columnKey, type)}
sortState={sortByTab.country}
/>
) : null}
{activeTab === "gift" ? (
<GiftTable
items={giftRanking}
loading={loading}
onSort={(columnKey, type) => handleSort("gift", columnKey, type)}
sortState={sortByTab.gift}
/>
) : null}
{activeTab === "game" ? (
<GameTable
items={gameRanking}
loading={loading}
onSort={(columnKey, type) => handleSort("game", columnKey, type)}
sortState={sortByTab.game}
/>
) : null}
</div>
</Panel>
);
@ -34,29 +126,23 @@ function TableTabs({ activeTab, onChange }) {
);
}
function CountryTable({ items }) {
function CountryTable({ items, loading, onSort, sortState }) {
const sortedItems = useMemo(() => sortItems(items, sortState, countrySortAccessors), [items, sortState]);
return (
<table className="country-table country-table--wide">
<thead>
<tr>
<th>#</th>
<th>国家</th>
<th>访客</th>
<th>活跃用户</th>
<th>付费用户</th>
<th>充值用户</th>
<th>总充值 (USD)</th>
<th>新用户充值 (USD)</th>
<th>ARPU (USD)</th>
<th>ARPPU (USD)</th>
<th>付费转化率</th>
<th>充值转化率</th>
<th>平均充值 (USD)</th>
<th> 7 </th>
{countryColumns.map((column) => (
<SortableHeader column={column} key={column.key} onSort={onSort} sortState={sortState} />
))}
</tr>
</thead>
<tbody>
{items.map((item, index) => (
{loading ? <SkeletonRows colSpan={14} rows={6} /> : null}
{!loading && !items.length ? <EmptyRow colSpan={14} /> : null}
{!loading && sortedItems.map((item, index) => (
<tr key={item.country}>
<td>{index + 1}</td>
<td>{item.flag ? <i className="table-flag">{item.flag}</i> : null}{item.country}</td>
@ -79,27 +165,36 @@ function CountryTable({ items }) {
);
}
function GiftTable({ items }) {
function GiftTable({ items, loading, onSort, sortState }) {
const total = items.reduce((sum, item) => sum + item.value, 0) || 1;
const giftSortAccessors = useMemo(() => ({
name: (item) => item.name,
share: (item) => item.value / total,
trend_rate: (item) => item.trend_rate,
value: (item) => item.value
}), [total]);
const sortedItems = useMemo(() => sortItems(items, sortState, giftSortAccessors), [giftSortAccessors, items, sortState]);
return (
<table className="country-table">
<thead>
<tr>
<th>#</th>
<th>礼物</th>
<th>金币消费</th>
<th>占比</th>
<th> 7 </th>
{giftColumns.map((column) => (
<SortableHeader column={column} key={column.key} onSort={onSort} sortState={sortState} />
))}
</tr>
</thead>
<tbody>
{items.map((item, index) => (
{loading ? <SkeletonRows colSpan={5} rows={5} /> : null}
{!loading && !items.length ? <EmptyRow colSpan={5} /> : null}
{!loading && sortedItems.map((item, index) => (
<tr key={item.name}>
<td>{index + 1}</td>
<td>{item.name}</td>
<td>{formatCoin(item.value)}</td>
<td>{formatPercent(item.value / total)}</td>
<td className="positive">+{formatPercent(0.168 - index * 0.017)}</td>
<td className={item.trend_rate >= 0 ? "positive" : "negative"}>{formatSignedPercent(item.trend_rate)}</td>
</tr>
))}
</tbody>
@ -107,24 +202,33 @@ function GiftTable({ items }) {
);
}
function GameTable({ items }) {
function GameTable({ items, loading, onSort, sortState }) {
const total = items.reduce((sum, item) => sum + item.turnover_coin, 0) || 1;
const gameSortAccessors = useMemo(() => ({
game_id: (item) => item.game_label || item.game_id,
profit_rate: (item) => item.profit_rate,
share: (item) => item.turnover_coin / total,
turnover_coin: (item) => item.turnover_coin
}), [total]);
const sortedItems = useMemo(() => sortItems(items, sortState, gameSortAccessors), [gameSortAccessors, items, sortState]);
return (
<table className="country-table">
<thead>
<tr>
<th>#</th>
<th>游戏</th>
<th>流水 (USD)</th>
<th>利润率</th>
<th>占比</th>
{gameColumns.map((column) => (
<SortableHeader column={column} key={column.key} onSort={onSort} sortState={sortState} />
))}
</tr>
</thead>
<tbody>
{items.map((item, index) => (
{loading ? <SkeletonRows colSpan={5} rows={5} /> : null}
{!loading && !items.length ? <EmptyRow colSpan={5} /> : null}
{!loading && sortedItems.map((item, index) => (
<tr key={`${item.platform_code || "game"}-${item.game_id}`}>
<td>{index + 1}</td>
<td>{item.game_id}</td>
<td>{item.game_label || item.game_id}</td>
<td>{formatCoin(item.turnover_coin)}</td>
<td className="positive">{formatPercent(item.profit_rate)}</td>
<td>{formatPercent(item.turnover_coin / total)}</td>
@ -134,3 +238,85 @@ function GameTable({ items }) {
</table>
);
}
function SortableHeader({ column, onSort, sortState }) {
const isActive = sortState?.key === column.key;
const direction = isActive ? sortState.direction : "none";
const ariaSort = isActive ? (sortState.direction === "asc" ? "ascending" : "descending") : "none";
return (
<th aria-sort={ariaSort} className="is-sortable" scope="col">
<button
className={`sort-header-button ${column.align === "left" ? "sort-header-button--left" : ""} ${isActive ? "is-active" : ""}`}
type="button"
onClick={() => onSort(column.key, column.type || "number")}
>
<span>{column.label}</span>
<span aria-hidden="true" className="sort-indicator">{direction === "asc" ? "↑" : direction === "desc" ? "↓" : "↕"}</span>
</button>
</th>
);
}
function SkeletonRows({ colSpan, rows }) {
return Array.from({ length: rows }).map((_, index) => (
<tr className="table-skeleton-row" key={index}>
<td colSpan={colSpan}><span className="skeleton-block skeleton-table-line" /></td>
</tr>
));
}
function EmptyRow({ colSpan }) {
return (
<tr className="table-empty-row">
<td colSpan={colSpan}>暂无数据</td>
</tr>
);
}
function formatSignedPercent(value) {
if (value === undefined || value === null || Number.isNaN(Number(value))) {
return "";
}
return `${Number(value) >= 0 ? "+" : ""}${formatPercent(value)}`;
}
function sortItems(items, sortState, accessors) {
if (!sortState?.key || !accessors[sortState.key]) {
return items;
}
const directionMultiplier = sortState.direction === "asc" ? 1 : -1;
return items
.map((item, index) => ({ index, item }))
.sort((left, right) => {
const leftValue = accessors[sortState.key](left.item);
const rightValue = accessors[sortState.key](right.item);
const result = compareValues(leftValue, rightValue, sortState.type);
return result === 0 ? left.index - right.index : result * directionMultiplier;
})
.map(({ item }) => item);
}
function compareValues(leftValue, rightValue, type) {
if (type === "text") {
return textCollator.compare(String(leftValue || ""), String(rightValue || ""));
}
const leftNumber = Number(leftValue);
const rightNumber = Number(rightValue);
if (!Number.isFinite(leftNumber) && !Number.isFinite(rightNumber)) {
return 0;
}
if (!Number.isFinite(leftNumber)) {
return -1;
}
if (!Number.isFinite(rightNumber)) {
return 1;
}
return leftNumber - rightNumber;
}

View File

@ -0,0 +1,69 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test } from "vitest";
import { CountryPerformanceTable } from "./CountryPerformanceTable.jsx";
const countryItems = [
createCountryRow({
country: "阿富汗",
flag: "🇦🇫",
recharge_usd_minor: 50000,
visitors: 10
}),
createCountryRow({
country: "巴西",
flag: "🇧🇷",
recharge_usd_minor: 10000,
visitors: 30
}),
createCountryRow({
country: "中国",
flag: "🇨🇳",
recharge_usd_minor: 30000,
visitors: 20
})
];
test("sorts country table headers ascending and descending", async () => {
const user = userEvent.setup();
render(<CountryPerformanceTable gameRanking={[]} giftRanking={[]} items={countryItems} />);
expect(firstCountryCell()).toHaveTextContent("阿富汗");
await user.click(screen.getByRole("button", { name: /访客/ }));
expect(screen.getByRole("columnheader", { name: /访客/ })).toHaveAttribute("aria-sort", "descending");
expect(firstCountryCell()).toHaveTextContent("巴西");
await user.click(screen.getByRole("button", { name: /访客/ }));
expect(screen.getByRole("columnheader", { name: /访客/ })).toHaveAttribute("aria-sort", "ascending");
expect(firstCountryCell()).toHaveTextContent("阿富汗");
});
function createCountryRow(overrides) {
return {
active_users: 0,
arppu_usd_minor: 0,
arpu_usd_minor: 0,
avg_recharge_usd_minor: 0,
country: "",
flag: "",
new_user_recharge_usd_minor: 0,
paid_users: 0,
payer_rate: 0,
recharge_conversion_rate: 0,
recharge_users: 0,
recharge_usd_minor: 0,
trend_rate: 0,
visitors: 0,
...overrides
};
}
function firstCountryCell() {
const [, firstDataRow] = screen.getAllByRole("row");
return within(firstDataRow).getAllByRole("cell")[1];
}

View File

@ -1,35 +1,440 @@
import { useEffect, useMemo, useRef, useState } from "react";
import CalendarMonthOutlined from "@mui/icons-material/CalendarMonthOutlined";
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
import PublicOutlined from "@mui/icons-material/PublicOutlined";
import { countryOptions } from "../config/options.js";
import { getTimeZoneLabel, lastDaysRange, rangeEndMs, rangeStartMs, sameRange, thisMonthRange, TIME_ZONE_OPTIONS, todayRange } from "../utils/time.js";
const defaultCountryOptions = [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }];
const defaultRegionOptions = [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }];
const rangeOptions = [
{ label: "今日", value: "today", getRange: (timeZone) => todayRange(timeZone) },
{ label: "近 7 日", value: "7d", getRange: (timeZone) => lastDaysRange(7, timeZone) },
{ label: "近 14 日", value: "14d", getRange: (timeZone) => lastDaysRange(14, timeZone) },
{ label: "近 30 日", value: "30d", getRange: (timeZone) => lastDaysRange(30, timeZone) },
{ label: "本月", value: "month", getRange: (timeZone) => thisMonthRange(timeZone) }
];
const weekLabels = ["一", "二", "三", "四", "五", "六", "日"];
const hours = Array.from({ length: 24 }, (_, index) => pad2(index));
const minutes = Array.from({ length: 60 }, (_, index) => pad2(index));
const seconds = minutes;
export function DatabiHeader({
appCode,
appOptions = [],
countryId,
countryOptions = defaultCountryOptions,
filtersLoading = false,
onAppChange,
onCountryChange,
onRangeChange,
onRegionChange,
onTimeZoneChange,
range,
regionId,
regionOptions = defaultRegionOptions,
timeZone,
timeZoneOptions = TIME_ZONE_OPTIONS
}) {
const [openControl, setOpenControl] = useState("");
const headerRef = useRef(null);
const appLabel = appOptions.find((item) => item.code === appCode || item.value === appCode)?.label || appCode;
const regionLabel = regionOptions.find((item) => String(item.id) === String(regionId) || String(item.value) === String(regionId))?.label || "全部区域";
const timeZoneLabel = getTimeZoneLabel(timeZone);
const filteredCountries = useMemo(() => {
if (regionId === "all" || !regionOptions.some((item) => item.countryCodes?.length)) {
return countryOptions;
}
return countryOptions.filter((item) => Number(item.id) === 0 || item.regionIds?.includes(String(regionId)) || item.regionId === regionId);
}, [countryOptions, regionId, regionOptions]);
const countryLabel = filteredCountries.find((item) => Number(item.id) === Number(countryId))?.label || "全部国家";
const activeRange = rangeOptions.find((item) => {
const next = item.getRange(timeZone);
return sameRange(next, range);
});
useEffect(() => {
const closeOnOutsideClick = (event) => {
if (!headerRef.current?.contains(event.target)) {
setOpenControl("");
}
};
document.addEventListener("mousedown", closeOnOutsideClick);
return () => document.removeEventListener("mousedown", closeOnOutsideClick);
}, []);
const toggleControl = (name) => {
setOpenControl((current) => (current === name ? "" : name));
};
const selectItem = (callback, value) => {
callback(value);
setOpenControl("");
};
export function DatabiHeader({ countryId, onCountryChange, onRangeChange, range }) {
return (
<header className="databi-header">
<header className="databi-header" ref={headerRef}>
<div className="brand-lockup">
<div className="brand-emblem" aria-hidden="true" />
<div>
<h1>全球运营指挥中心</h1>
<h1>App Data</h1>
</div>
</div>
<div className="header-controls">
<div className="range-control">
<input aria-label="开始日期" type="text" value={range.start} onChange={(event) => onRangeChange({ ...range, start: event.target.value })} />
<span>~</span>
<input aria-label="结束日期" type="text" value={range.end} onChange={(event) => onRangeChange({ ...range, end: event.target.value })} />
<CalendarMonthOutlined fontSize="small" />
</div>
<label className="region-control">
<PublicOutlined fontSize="small" />
<select aria-label="国家区域" value={countryId} onChange={(event) => onCountryChange(Number(event.target.value))}>
{countryOptions.map((item) => (
<option key={item.id} value={item.id}>
{item.label}
</option>
))}
</select>
</label>
<FilterMenu
className="filter-control--timezone"
icon={<span className="control-glyph">TZ</span>}
isOpen={openControl === "timezone"}
label="时区"
onSelect={(value) => selectItem(onTimeZoneChange, value)}
onToggle={() => toggleControl("timezone")}
options={timeZoneOptions}
value={timeZone}
valueLabel={timeZoneLabel}
/>
<FilterMenu
className="filter-control--app"
icon={<span className="control-glyph">APP</span>}
isOpen={openControl === "app"}
label="App"
loading={filtersLoading}
onSelect={(value) => selectItem(onAppChange, value)}
onToggle={() => toggleControl("app")}
options={appOptions.map((item) => ({ label: item.label, value: item.code }))}
value={appCode}
valueLabel={appLabel}
/>
<RangeMenu
activeValue={activeRange?.value}
isOpen={openControl === "range"}
onSelect={(nextRange) => selectItem(onRangeChange, nextRange)}
onToggle={() => toggleControl("range")}
range={range}
timeZone={timeZone}
/>
<FilterMenu
icon={<PublicOutlined fontSize="small" />}
isOpen={openControl === "region"}
label="区域"
loading={filtersLoading}
onSelect={(value) => selectItem(onRegionChange, value)}
onToggle={() => toggleControl("region")}
options={regionOptions}
value={regionId}
valueLabel={regionLabel}
/>
<SearchableFilterMenu
icon={<span className="control-glyph">CN</span>}
isOpen={openControl === "country"}
label="国家"
loading={filtersLoading}
onSelect={(value) => selectItem(onCountryChange, Number(value))}
onToggle={() => toggleControl("country")}
options={filteredCountries}
value={countryId}
valueLabel={countryLabel}
/>
<span className="live-dot">实时</span>
</div>
</header>
);
}
function FilterMenu({ className = "", icon, isOpen, label, loading = false, onSelect, onToggle, options, value, valueLabel }) {
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>
<KeyboardArrowDownOutlined className="filter-chevron" fontSize="small" />
</button>
{isOpen ? (
<div className="filter-popover" role="listbox">
{loading ? <span className="filter-empty">加载中...</span> : null}
{!loading && !options.length ? <span className="filter-empty">暂无选项</span> : null}
{!loading
? options.map((item) => (
<button className={item.value === value || item.id === value ? "is-active" : ""} key={item.value ?? item.id} onClick={() => onSelect(item.value ?? item.id)} role="option" type="button">
{item.label}
</button>
))
: null}
</div>
) : null}
</div>
);
}
function SearchableFilterMenu({ icon, isOpen, label, loading = false, onSelect, onToggle, options, value, valueLabel }) {
const [query, setQuery] = useState("");
const filteredOptions = useMemo(() => {
const keyword = query.trim().toLowerCase();
if (!keyword) {
return options;
}
return options.filter((item) => `${item.label || ""} ${item.countryCode || ""} ${item.searchText || ""}`.toLowerCase().includes(keyword));
}, [options, query]);
useEffect(() => {
if (isOpen) {
setQuery("");
}
}, [isOpen]);
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>
<KeyboardArrowDownOutlined className="filter-chevron" fontSize="small" />
</button>
{isOpen ? (
<div className="filter-popover filter-popover--searchable" role="listbox">
<label className="filter-search">
<span>搜索国家</span>
<input placeholder="国家 / Code" type="text" value={query} onChange={(event) => setQuery(event.target.value)} />
</label>
{loading ? <span className="filter-empty">加载中...</span> : null}
{!loading && !filteredOptions.length ? <span className="filter-empty">没有匹配国家</span> : null}
{!loading && filteredOptions.length ? (
<div className="filter-options-scroll">
{filteredOptions.map((item) => (
<button className={Number(item.id) === Number(value) ? "is-active" : ""} key={item.value ?? item.id} onClick={() => onSelect(item.value ?? item.id)} role="option" type="button">
<span>{item.label}</span>
{item.countryCode ? <small>{item.countryCode}</small> : null}
</button>
))}
</div>
) : null}
</div>
) : null}
</div>
);
}
function RangeMenu({ activeValue, isOpen, onSelect, onToggle, range, timeZone }) {
const options = rangeOptions.map((item) => ({ ...item, range: item.getRange(timeZone) }));
const activeOption = options.find((item) => item.value === activeValue);
const [draft, setDraft] = useState(() => normalizeRange(range));
const [activeField, setActiveField] = useState("start");
const [viewMonth, setViewMonth] = useState(() => monthDate(range.start));
const [error, setError] = useState("");
const calendarDays = useMemo(() => createCalendarDays(viewMonth), [viewMonth]);
const activeTime = splitTime(draft[`${activeField}Time`]);
useEffect(() => {
if (isOpen) {
const normalized = normalizeRange(range);
setDraft(normalized);
setActiveField("start");
setViewMonth(monthDate(normalized.start));
setError("");
}
}, [isOpen, range]);
const changeMonth = (offset) => {
setViewMonth((current) => new Date(current.getFullYear(), current.getMonth() + offset, 1));
};
const changeYear = (offset) => {
setViewMonth((current) => new Date(current.getFullYear() + offset, current.getMonth(), 1));
};
const selectQuickRange = (nextRange) => {
const normalized = normalizeRange(nextRange);
setDraft(normalized);
setActiveField("start");
setViewMonth(monthDate(normalized.start));
setError("");
};
const activateField = (field) => {
setActiveField(field);
setViewMonth(monthDate(draft[field]));
setError("");
};
const selectDate = (date) => {
const selectedValue = dateValue(date);
setDraft((current) => ({ ...current, [activeField]: selectedValue }));
if (activeField === "start") {
setActiveField("end");
setViewMonth(monthDate(selectedValue));
}
setError("");
};
const selectTime = (part, value) => {
setDraft((current) => ({ ...current, [`${activeField}Time`]: updateTimePart(current[`${activeField}Time`], part, value) }));
setError("");
};
const applyDraft = () => {
const normalized = normalizeRange(draft);
if (rangeStartMs(normalized, timeZone) > rangeEndMs(normalized, timeZone)) {
setError("结束时间必须晚于开始时间");
return;
}
onSelect(normalized);
};
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>
<KeyboardArrowDownOutlined className="date-range-chevron" fontSize="small" />
</button>
{isOpen ? (
<div className="date-time-popover" role="dialog">
<div className="date-time-shortcuts" role="listbox">
{options.map((item) => (
<button aria-selected={sameRange(item.range, draft)} className={sameRange(item.range, draft) ? "is-active" : ""} key={item.value} onClick={() => selectQuickRange(item.range)} role="option" type="button">
{item.label}
</button>
))}
</div>
<div className="date-time-fields" role="tablist">
<button aria-selected={activeField === "start"} className={activeField === "start" ? "is-active" : ""} onClick={() => activateField("start")} role="tab" type="button">
<span>开始时间</span>
<b>{formatDateTimePart(draft.start, draft.startTime)}</b>
</button>
<button aria-selected={activeField === "end"} className={activeField === "end" ? "is-active" : ""} onClick={() => activateField("end")} role="tab" type="button">
<span>结束时间</span>
<b>{formatDateTimePart(draft.end, draft.endTime)}</b>
</button>
</div>
<div className="date-time-picker">
<div className="calendar-pane">
<div className="calendar-toolbar">
<button aria-label="上一年" onClick={() => changeYear(-1)} type="button">«</button>
<button aria-label="上个月" onClick={() => changeMonth(-1)} type="button"></button>
<strong>{viewMonth.getFullYear()} {viewMonth.getMonth() + 1}</strong>
<button aria-label="下个月" onClick={() => changeMonth(1)} type="button"></button>
<button aria-label="下一年" onClick={() => changeYear(1)} type="button">»</button>
</div>
<div className="calendar-grid">
{weekLabels.map((label) => (
<span className="calendar-weekday" key={label}>{label}</span>
))}
{calendarDays.map((date) => {
const value = dateValue(date);
const className = [
"calendar-day",
date.getMonth() !== viewMonth.getMonth() ? "is-muted" : "",
value === draft.start ? "is-start" : "",
value === draft.end ? "is-end" : "",
isBetween(value, draft.start, draft.end) ? "is-between" : "",
value === draft[activeField] ? "is-active" : ""
].filter(Boolean).join(" ");
return (
<button className={className} key={value} onClick={() => selectDate(date)} type="button">
{date.getDate()}
</button>
);
})}
</div>
</div>
<div className="time-pane" aria-label="时间">
<TimeColumn label="时" onSelect={(value) => selectTime("hour", value)} options={hours} value={activeTime.hour} />
<TimeColumn label="分" onSelect={(value) => selectTime("minute", value)} options={minutes} value={activeTime.minute} />
<TimeColumn label="秒" onSelect={(value) => selectTime("second", value)} options={seconds} value={activeTime.second} />
</div>
</div>
<div className="date-time-footer">
{error ? <span>{error}</span> : <span>{formatRange(draft)}</span>}
<button onClick={applyDraft} type="button">确定</button>
</div>
</div>
) : null}
</div>
);
}
function TimeColumn({ label, onSelect, options, value }) {
return (
<div className="time-column">
<span>{label}</span>
<div>
{options.map((item) => (
<button className={item === value ? "is-active" : ""} key={item} onClick={() => onSelect(item)} type="button">
{item}
</button>
))}
</div>
</div>
);
}
function dateValue(date) {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
}
function formatRange(range) {
return `${formatDateTimePart(range.start, range.startTime)} ~ ${formatDateTimePart(range.end, range.endTime)}`;
}
function formatDateTimePart(date, time) {
return `${date} ${normalizeTime(time)}`;
}
function normalizeRange(range) {
return {
end: range?.end || dateValue(new Date()),
endTime: normalizeTime(range?.endTime || "23:59:59"),
start: range?.start || dateValue(new Date()),
startTime: normalizeTime(range?.startTime || "00:00:00")
};
}
function monthDate(value) {
const [year, month] = String(value || dateValue(new Date())).split("-").map(Number);
return new Date(year || new Date().getFullYear(), (month || 1) - 1, 1);
}
function createCalendarDays(viewMonth) {
const firstDay = new Date(viewMonth.getFullYear(), viewMonth.getMonth(), 1);
const mondayIndex = (firstDay.getDay() + 6) % 7;
const start = new Date(firstDay);
start.setDate(firstDay.getDate() - mondayIndex);
return Array.from({ length: 42 }, (_, index) => {
const date = new Date(start);
date.setDate(start.getDate() + index);
return date;
});
}
function isBetween(value, start, end) {
return value > start && value < end;
}
function splitTime(value) {
const [hour, minute, second] = normalizeTime(value).split(":");
return { hour, minute, second };
}
function updateTimePart(value, part, nextValue) {
const parts = splitTime(value);
return [part === "hour" ? nextValue : parts.hour, part === "minute" ? nextValue : parts.minute, part === "second" ? nextValue : parts.second].join(":");
}
function normalizeTime(value) {
const [hour = "00", minute = "00", second = "00"] = String(value || "00:00:00").split(":");
return `${pad2(hour)}:${pad2(minute)}:${pad2(second)}`;
}
function pad2(value) {
return String(value).padStart(2, "0");
}

View File

@ -0,0 +1,36 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { DatabiHeader } from "./DatabiHeader.jsx";
const noop = vi.fn();
test("switches to end time after selecting a start date", async () => {
const user = userEvent.setup();
render(
<DatabiHeader
appCode="lalu"
appOptions={[{ code: "lalu", label: "Lalu", value: "lalu" }]}
countryId={0}
onAppChange={noop}
onCountryChange={noop}
onRangeChange={noop}
onRegionChange={noop}
onTimeZoneChange={noop}
range={{ end: "2026-06-08", endTime: "23:59:59", start: "2026-06-08", startTime: "00:00:00" }}
regionId="all"
timeZone="UTC"
/>
);
await user.click(screen.getByRole("button", { name: /2026-06-08\s*~\s*2026-06-08/ }));
const dialog = screen.getByRole("dialog");
const calendar = dialog.querySelector(".calendar-pane");
expect(within(dialog).getByRole("tab", { name: /开始时间/ })).toHaveAttribute("aria-selected", "true");
await user.click(within(calendar).getByRole("button", { name: "22" }));
expect(within(dialog).getByRole("tab", { name: /结束时间/ })).toHaveAttribute("aria-selected", "true");
});

View File

@ -2,25 +2,29 @@ import { formatNumber, formatPercent } from "../utils/format.js";
import { MiniStat } from "./MiniStat.jsx";
import { Panel } from "./Panel.jsx";
export function FunnelPanel({ funnel, sideMetrics }) {
export function FunnelPanel({ funnel, loading = false, sideMetrics }) {
return (
<Panel title="ARPU 转化漏斗">
<div className="funnel-layout">
<div className={["funnel-layout", loading ? "skeleton-panel" : ""].filter(Boolean).join(" ")} aria-busy={loading || undefined}>
<div className="funnel-stack">
{funnel.map((item, index) => (
<div className="funnel-stage-row" key={item.name}>
<div className={`funnel-stage funnel-stage--${index + 1}`}>
<span>{item.name}</span>
<strong>{formatNumber(item.value)}</strong>
</div>
<b>{formatPercent(item.rate)}</b>
</div>
))}
{loading
? Array.from({ length: 3 }).map((_, index) => <span className={`skeleton-block skeleton-funnel-stage skeleton-funnel-stage--${index + 1}`} key={index} />)
: funnel.map((item, index) => (
<div className="funnel-stage-row" key={item.name}>
<div className={`funnel-stage funnel-stage--${index + 1}`}>
<span>{item.name}</span>
<strong>{formatNumber(item.value)}</strong>
</div>
<b>{formatPercent(item.rate)}</b>
</div>
))}
</div>
<div className="side-metrics">
{sideMetrics.map((item) => (
<MiniStat key={item.label} item={item} />
))}
{loading
? Array.from({ length: 3 }).map((_, index) => <span className="skeleton-block skeleton-mini-stat" key={index} />)
: sideMetrics.map((item) => (
<MiniStat key={item.label} item={item} />
))}
</div>
</div>
</Panel>

View File

@ -24,7 +24,7 @@ function GameRankTable({ items }) {
{items.map((item, index) => (
<div className="game-row" key={`${item.platform_code || "game"}-${item.game_id}`}>
<span>{index + 1}</span>
<em>{item.game_id || "-"}</em>
<em>{item.game_label || item.game_id || "-"}</em>
<div className="bar-track">
<i style={{ width: `${Math.max(5, (item.turnover_coin / max) * 100)}%` }} />
</div>

View File

@ -1,27 +1,114 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { EChart } from "../charts/EChart.jsx";
import { createGeoOption } from "../charts/options/createGeoOption.js";
import { formatMoney, moneyMajor } from "../utils/format.js";
import { GlobeMap } from "./GlobeMap.jsx";
import { Panel } from "./Panel.jsx";
import { RankList } from "./RankList.jsx";
export function GlobalOverviewPanel({ countryBreakdown, topCountries }) {
const MODAL_TRANSITION_MS = 180;
export function GlobalOverviewPanel({ countryBreakdown, loading = false }) {
const [globeOpen, setGlobeOpen] = useState(false);
const rechargeCountries = useMemo(() => countryBreakdown.filter(hasRecharge), [countryBreakdown]);
const rankedCountries = useMemo(() => rechargeCountries.slice(0, 4), [rechargeCountries]);
return (
<Panel className="panel--map" title="全球概览">
<div className="geo-layout">
<div className="geo-map">
<EChart className="geo-chart" option={createGeoOption(countryBreakdown)} />
<div className="geo-scale">
<span></span>
<i />
<span></span>
<>
<Panel className="panel--map" title="全球概览">
{loading ? <GlobalOverviewSkeleton /> : (
<div className="geo-layout">
<div className="geo-map">
{rankedCountries.length ? <EChart className="geo-chart" option={createGeoOption(rankedCountries)} /> : <div className="panel-empty">暂无国家数据</div>}
<div className="geo-scale">
<span></span>
<i />
<span></span>
</div>
</div>
<div className="rank-block">
<RankList items={rankedCountries} title="充值国家排行" valueFormatter={formatRankMoney} />
<button className="all-countries-button" disabled={!rechargeCountries.length} type="button" onClick={() => setGlobeOpen(true)}>
查看全部国家
</button>
</div>
</div>
</div>
<div className="rank-block">
<RankList items={topCountries} title="充值国家排行" valueFormatter={formatRankMoney} />
<button className="all-countries-button" type="button">查看全部国家</button>
</div>
)}
</Panel>
{globeOpen ? <CountryGlobeDialog items={rechargeCountries} onClose={() => setGlobeOpen(false)} /> : null}
</>
);
}
function GlobalOverviewSkeleton() {
return (
<div className="geo-layout skeleton-panel" aria-busy="true">
<div className="geo-map skeleton-map">
<span className="skeleton-block skeleton-map-glow" />
</div>
</Panel>
<div className="rank-block">
<span className="skeleton-block skeleton-rank-title" />
{Array.from({ length: 4 }).map((_, index) => (
<span className="skeleton-block skeleton-rank-row" key={index} />
))}
<span className="skeleton-block skeleton-button" />
</div>
</div>
);
}
function CountryGlobeDialog({ items, onClose }) {
const [closing, setClosing] = useState(false);
const closeTimerRef = useRef(null);
const requestClose = useCallback(() => {
if (closing) {
return;
}
setClosing(true);
closeTimerRef.current = window.setTimeout(onClose, MODAL_TRANSITION_MS);
}, [closing, onClose]);
useEffect(() => () => {
if (closeTimerRef.current) {
window.clearTimeout(closeTimerRef.current);
}
}, []);
useEffect(() => {
const closeOnEscape = (event) => {
if (event.key === "Escape") {
requestClose();
}
};
window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape);
}, [requestClose]);
return (
<div
className={["country-globe-modal", closing ? "is-closing" : ""].filter(Boolean).join(" ")}
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
requestClose();
}
}}
>
<section aria-label="全部国家地球仪" aria-modal="true" className="country-globe-dialog" role="dialog">
<div className="country-globe-head">
<div>
<h2>全部国家</h2>
<span>{items.length} 个国家 / 按充值规模展示</span>
</div>
<button aria-label="关闭" type="button" onClick={requestClose}>
<span aria-hidden="true" className="modal-close-mark" />
</button>
</div>
<div className="country-globe-body">
<GlobeMap items={items} />
</div>
</section>
</div>
);
}
@ -32,3 +119,7 @@ function formatRankMoney(value) {
}
return formatMoney(value);
}
function hasRecharge(item) {
return Number(item?.recharge_usd_minor) > 0;
}

View File

@ -16,6 +16,20 @@ export function GlobeMap({ items }) {
const [rotation, setRotation] = useState(INITIAL_ROTATION);
const [size, setSize] = useState({ height: 0, width: 0 });
const points = useMemo(() => normalizePoints(items), [items]);
const featuredPoint = points[0] || null;
const featuredTooltip = useMemo(() => pointTooltip(featuredPoint, size, rotation), [featuredPoint, rotation, size]);
const visibleTooltip = dragging ? null : hovered || featuredTooltip;
useEffect(() => {
if (!featuredPoint?.point) {
return;
}
setHovered(null);
setRotation({
lat: clamp(featuredPoint.point[1], -MAX_LATITUDE, MAX_LATITUDE),
lon: normalizeLongitude(featuredPoint.point[0])
});
}, [featuredPoint]);
useEffect(() => {
const element = wrapRef.current;
@ -95,12 +109,12 @@ export function GlobeMap({ items }) {
role="img"
>
<canvas className="globe-canvas" ref={canvasRef} />
{hovered ? (
<div className="globe-tooltip" style={{ left: hovered.x, top: hovered.y }}>
<strong>{hovered.item.country}</strong>
<span>充值 {formatMoney(hovered.item.value)}</span>
<span>活跃 {formatNumber(hovered.item.activeUsers)}</span>
<span>付费 {formatNumber(hovered.item.paidUsers)}</span>
{visibleTooltip ? (
<div className="globe-tooltip" style={{ left: visibleTooltip.x, top: visibleTooltip.y }}>
<strong>{visibleTooltip.item.country}</strong>
<span>充值 {formatMoney(visibleTooltip.item.value)}</span>
<span>活跃 {formatNumber(visibleTooltip.item.activeUsers)}</span>
<span>付费 {formatNumber(visibleTooltip.item.paidUsers)}</span>
</div>
) : null}
</div>
@ -237,34 +251,35 @@ function drawRoutes(context, points, rotation, cx, cy, radius) {
function drawPoints(context, points, rotation, cx, cy, radius) {
const max = Math.max(...points.map((item) => item.value), 1);
const min = Math.min(...points.map((item) => item.value), max);
for (const item of points) {
const point = project(item.point, rotation, cx, cy, radius);
if (!point) {
continue;
}
const strength = Math.sqrt(item.value / max);
const strength = normalizeStrength(item.value, min, max);
const halo = 10 + strength * 24;
const core = 3.2 + strength * 5.4;
const glow = context.createRadialGradient(point.x, point.y, 1, point.x, point.y, halo);
glow.addColorStop(0, "rgba(129, 252, 255, 0.86)");
glow.addColorStop(0.2, "rgba(39, 228, 245, 0.42)");
glow.addColorStop(1, "rgba(39, 228, 245, 0)");
glow.addColorStop(0, colorForStrength(strength, 0.92));
glow.addColorStop(0.25, colorForStrength(strength, 0.42));
glow.addColorStop(1, colorForStrength(strength, 0));
context.fillStyle = glow;
context.beginPath();
context.arc(point.x, point.y, halo, 0, Math.PI * 2);
context.fill();
context.strokeStyle = "rgba(126, 252, 255, 0.42)";
context.strokeStyle = colorForStrength(strength, 0.62);
context.lineWidth = 1;
context.beginPath();
context.arc(point.x, point.y, core * 2.2, 0, Math.PI * 2);
context.stroke();
context.fillStyle = "#35f0f3";
context.fillStyle = colorForStrength(strength, 1);
context.shadowBlur = 16;
context.shadowColor = "rgba(53, 240, 243, 0.78)";
context.shadowColor = colorForStrength(strength, 0.82);
context.beginPath();
context.arc(point.x, point.y, core, 0, Math.PI * 2);
context.fill();
@ -397,6 +412,7 @@ function project(coordinate, rotation, cx, cy, radius) {
function normalizePoints(items = []) {
return items
.filter((item) => Array.isArray(item.geo_point) && item.geo_point.length === 2)
.filter((item) => Number(item.recharge_usd_minor) > 0)
.map((item) => ({
activeUsers: Number(item.active_users) || 0,
country: item.country,
@ -407,6 +423,24 @@ function normalizePoints(items = []) {
.sort((left, right) => right.value - left.value);
}
function pointTooltip(item, size, rotation) {
if (!item || !size.width || !size.height) {
return null;
}
const cx = size.width / 2;
const cy = size.height / 2;
const radius = Math.max(1, Math.min(size.width * 0.48, size.height * 0.47));
const projected = project(item.point, rotation, cx, cy, radius);
if (!projected) {
return null;
}
return {
item,
x: clamp(projected.x + 14, 8, size.width - 136),
y: clamp(projected.y - 76, 8, size.height - 96)
};
}
function findHoveredPoint(event, element, size, rotation, points) {
if (!element || !size.width || !size.height || !points.length) {
return null;
@ -442,6 +476,24 @@ function findHoveredPoint(event, element, size, rotation, points) {
};
}
function colorForStrength(strength, alpha = 1) {
const low = [66, 129, 255];
const mid = [39, 228, 245];
const high = [36, 215, 159];
const from = strength < 0.55 ? low : mid;
const to = strength < 0.55 ? mid : high;
const local = strength < 0.55 ? strength / 0.55 : (strength - 0.55) / 0.45;
const [red, green, blue] = from.map((channel, index) => Math.round(channel + (to[index] - channel) * local));
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
function normalizeStrength(value, min, max) {
if (max <= min) {
return 1;
}
return clamp((Math.sqrt(value) - Math.sqrt(min)) / (Math.sqrt(max) - Math.sqrt(min)), 0, 1);
}
function extractRings(features = []) {
const rings = [];

View File

@ -0,0 +1,111 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { formatCoin, formatPercent } from "../utils/format.js";
const MODAL_TRANSITION_MS = 180;
export function LuckyGiftPoolModal({ items, onClose }) {
const [closing, setClosing] = useState(false);
const closeTimerRef = useRef(null);
const totals = items.reduce((summary, item) => ({
payout: summary.payout + item.payout,
profit: summary.profit + item.profit,
turnover: summary.turnover + item.turnover
}), { payout: 0, profit: 0, turnover: 0 });
const requestClose = useCallback(() => {
if (closing) {
return;
}
setClosing(true);
closeTimerRef.current = window.setTimeout(onClose, MODAL_TRANSITION_MS);
}, [closing, onClose]);
useEffect(() => () => {
if (closeTimerRef.current) {
window.clearTimeout(closeTimerRef.current);
}
}, []);
useEffect(() => {
const closeOnEscape = (event) => {
if (event.key === "Escape") {
requestClose();
}
};
window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape);
}, [requestClose]);
return (
<div
className={["pool-detail-modal", closing ? "is-closing" : ""].filter(Boolean).join(" ")}
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
requestClose();
}
}}
>
<div aria-labelledby="lucky-gift-pool-title" aria-modal="true" className="pool-detail-dialog" role="dialog">
<header className="pool-detail-head">
<div>
<h2 id="lucky-gift-pool-title">幸运礼物奖池明细</h2>
<span> pool_id 汇总流水返奖和利润</span>
</div>
<button aria-label="关闭幸运礼物奖池明细" type="button" onClick={requestClose}>
<span aria-hidden="true" className="modal-close-mark" />
</button>
</header>
<section className="pool-detail-summary" aria-label="幸运礼物奖池汇总">
<PoolSummary label="总流水" value={totals.turnover} />
<PoolSummary label="总返奖" value={totals.payout} />
<PoolSummary label="总利润" value={totals.profit} tone={totals.profit < 0 ? "negative" : "positive"} />
</section>
<div className="pool-detail-table-wrap">
<table className="pool-detail-table">
<thead>
<tr>
<th>#</th>
<th>Pool ID</th>
<th>流水(金币)</th>
<th>返奖(金币)</th>
<th>利润(金币)</th>
<th>返奖率</th>
<th>利润率</th>
</tr>
</thead>
<tbody>
{items.length ? items.map((item, index) => (
<tr key={item.pool_id}>
<td>{index + 1}</td>
<td>{item.pool_id}</td>
<td>{formatCoin(item.turnover)}</td>
<td>{formatCoin(item.payout)}</td>
<td className={item.profit < 0 ? "negative" : "positive"}>{formatCoin(item.profit)}</td>
<td>{formatPercent(item.payout_rate)}</td>
<td>{formatPercent(item.profit_rate)}</td>
</tr>
)) : (
<tr>
<td colSpan={7}>暂无 pool 数据</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
function PoolSummary({ label, tone = "", value }) {
return (
<article className="pool-summary-card">
<span>{label}</span>
<strong className={tone}>{formatCoin(value)}</strong>
<small>金币</small>
</article>
);
}

View File

@ -0,0 +1,23 @@
import { render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { LuckyGiftPoolModal } from "./LuckyGiftPoolModal.jsx";
test("renders lucky gift pool turnover payout and profit", () => {
render(
<LuckyGiftPoolModal
items={[
{ payout: 300, payout_rate: 0.3, pool_id: "lucky_100", profit: 700, profit_rate: 0.7, turnover: 1_000 },
{ payout: 50, payout_rate: 0.1, pool_id: "super_lucky", profit: 450, profit_rate: 0.9, turnover: 500 }
]}
onClose={vi.fn()}
/>
);
expect(screen.getByRole("dialog", { name: /幸运礼物奖池明细/ })).toBeInTheDocument();
expect(screen.getByText("总流水")).toBeInTheDocument();
expect(screen.getByText("1,500")).toBeInTheDocument();
expect(screen.getByText("lucky_100")).toBeInTheDocument();
expect(screen.getByText("super_lucky")).toBeInTheDocument();
expect(screen.getByText("700")).toBeInTheDocument();
expect(screen.getByText("450")).toBeInTheDocument();
});

View File

@ -1,16 +1,104 @@
export function MetricCard({ item }) {
export function MetricCard({ item, loading = false, onClick }) {
const Icon = item.icon;
return (
<article className={["metric-card", Icon ? "" : "metric-card--no-icon"].filter(Boolean).join(" ")}>
{Icon ? <div className="metric-icon"><Icon /></div> : null}
<div className="metric-content">
<span>{item.label}</span>
<strong>{item.value}</strong>
<div className={["metric-delta", item.deltaTone === "down" ? "metric-delta--down" : ""].join(" ")}>
<span>{item.caption}</span>
<b>{item.delta ? `${item.deltaTone === "down" ? "↓" : "↑"} ${item.delta.replace(/^[-+]/, "")}` : ""}</b>
const UnitIcon = item.unitIcon;
const clickable = Boolean(onClick);
if (loading) {
return (
<article className={["metric-card", Icon ? "" : "metric-card--no-icon", "is-loading"].filter(Boolean).join(" ")} aria-busy="true">
{Icon ? <div className="metric-icon metric-icon--skeleton"><span className="skeleton-block skeleton-icon" /></div> : null}
<div className="metric-content">
<span className="skeleton-block skeleton-label" />
<span className="skeleton-block skeleton-value" />
<span className="skeleton-block skeleton-caption" />
</div>
</article>
);
}
return (
<article
className={["metric-card", Icon ? "" : "metric-card--no-icon", clickable ? "metric-card--clickable" : ""].filter(Boolean).join(" ")}
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={onClick}
onKeyDown={clickable ? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onClick();
}
} : undefined}
>
<div className="metric-content">
<div className="metric-label-row">
{Icon ? <i className="metric-title-icon"><Icon /></i> : null}
<span>{item.label}</span>
{UnitIcon ? <i className="metric-unit-icon"><UnitIcon /></i> : null}
{item.unit ? <em className="metric-unit">{item.unit}</em> : null}
</div>
{Array.isArray(item.subMetrics) && item.subMetrics.length ? <MetricSubValues items={item.subMetrics} /> : <MetricMainValue item={item} />}
<MetricSparkline series={item.trend} />
</div>
</article>
);
}
function MetricMainValue({ item }) {
return (
<>
<strong>{item.value}</strong>
<MetricDelta item={item} />
</>
);
}
function MetricSubValues({ items }) {
return (
<div className="metric-sub-grid">
{items.map((subItem) => (
<div className="metric-sub-item" key={subItem.label}>
<span>{subItem.label}</span>
<strong>{subItem.value}</strong>
<MetricDelta item={subItem} compact />
</div>
))}
</div>
);
}
function MetricDelta({ compact = false, item }) {
const tone = item.deltaTone || (String(item.delta || "").trim().startsWith("-") ? "down" : "up");
return (
<div className={["metric-delta", compact ? "metric-delta--compact" : "", tone === "down" ? "metric-delta--down" : ""].filter(Boolean).join(" ")}>
<span>{item.caption}</span>
<b className={item.delta ? "" : "metric-delta-empty"}>{item.delta ? `${tone === "down" ? "↓" : "↑"} ${item.delta.replace(/^[-+]/, "")}` : "--"}</b>
</div>
);
}
function MetricSparkline({ series }) {
const lines = Array.isArray(series) ? series.filter((item) => Array.isArray(item.data) && item.data.length > 1) : [];
if (!lines.length) {
return <span className="metric-sparkline metric-sparkline--empty" />;
}
const values = lines.flatMap((line) => line.data.map((point) => Number(point.value) || 0));
const min = Math.min(...values);
const max = Math.max(...values);
return (
<svg className="metric-sparkline" preserveAspectRatio="none" viewBox="0 0 100 24" aria-hidden="true">
{lines.slice(0, 2).map((line, index) => (
<polyline className={`metric-sparkline-line metric-sparkline-line--${index + 1}`} key={line.name || index} points={sparklinePoints(line.data, min, max)} />
))}
</svg>
);
}
function sparklinePoints(points, min, max) {
const width = 100;
const height = 24;
const count = points.length;
return points.map((point, index) => {
const x = count <= 1 ? width : (index / (count - 1)) * width;
const normalized = max <= min ? 0.5 : ((Number(point.value) || 0) - min) / (max - min);
const y = height - normalized * 18 - 3;
return `${x.toFixed(2)},${y.toFixed(2)}`;
}).join(" ");
}

View File

@ -0,0 +1,10 @@
import { render, screen } from "@testing-library/react";
import { expect, test } from "vitest";
import { MetricCard } from "./MetricCard.jsx";
test("renders a down arrow when delta text is negative", () => {
render(<MetricCard item={{ caption: "与昨日相比", delta: "-39.27%", label: "活跃用户", value: "116" }} />);
expect(screen.getByText("↓ 39.27%")).toBeInTheDocument();
});

View File

@ -0,0 +1,64 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { EChart } from "../charts/EChart.jsx";
import { createMetricTrendOption } from "../charts/options/createMetricTrendOption.js";
const MODAL_TRANSITION_MS = 180;
export function MetricTrendModal({ item, onClose }) {
const [closing, setClosing] = useState(false);
const closeTimerRef = useRef(null);
const requestClose = useCallback(() => {
if (closing) {
return;
}
setClosing(true);
closeTimerRef.current = window.setTimeout(onClose, MODAL_TRANSITION_MS);
}, [closing, onClose]);
useEffect(() => () => {
if (closeTimerRef.current) {
window.clearTimeout(closeTimerRef.current);
}
}, []);
useEffect(() => {
const closeOnEscape = (event) => {
if (event.key === "Escape") {
requestClose();
}
};
window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape);
}, [requestClose]);
return (
<div
className={["metric-trend-modal", closing ? "is-closing" : ""].filter(Boolean).join(" ")}
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
requestClose();
}
}}
>
<section aria-label={`${item.label} 每日趋势`} aria-modal="true" className="metric-trend-dialog" role="dialog">
<header className="metric-trend-head">
<div>
<h2>{item.label}</h2>
<span>每日折线图</span>
</div>
<button aria-label="关闭每日趋势" type="button" onClick={requestClose}>
<span aria-hidden="true" className="modal-close-mark" />
</button>
</header>
<div className="metric-trend-body">
{hasTrend(item) ? <EChart className="metric-trend-chart" option={createMetricTrendOption(item)} /> : <div className="panel-empty">暂无趋势数据</div>}
</div>
</section>
</div>
);
}
function hasTrend(item) {
return Array.isArray(item?.trend) && item.trend.some((line) => Array.isArray(line.data) && line.data.length);
}

View File

@ -2,6 +2,7 @@ export function RankList({ items, title, valueFormatter }) {
return (
<div className="rank-list">
<div className="rank-title">{title}</div>
{!items.length ? <div className="rank-empty">暂无数据</div> : null}
{items.map((item, index) => (
<div className="rank-row" key={item.country}>
<span>{index + 1}</span>

View File

@ -2,10 +2,19 @@ import { EChart } from "../charts/EChart.jsx";
import { createRevenueOption } from "../charts/options/createRevenueOption.js";
import { Panel } from "./Panel.jsx";
export function RevenueTrendPanel({ revenueSeries }) {
export function RevenueTrendPanel({ loading = false, revenueSeries }) {
return (
<Panel title="充值趋势">
<EChart className="trend-chart" option={createRevenueOption(revenueSeries)} />
{loading ? (
<div className="trend-chart chart-skeleton" aria-busy="true">
<span className="skeleton-block skeleton-chart-legend" />
<span className="skeleton-block skeleton-chart-body" />
</div>
) : revenueSeries.length ? (
<EChart className="trend-chart" option={createRevenueOption(revenueSeries)} />
) : (
<div className="trend-chart panel-empty">暂无趋势数据</div>
)}
</Panel>
);
}

View File

@ -1,8 +0,0 @@
export const countryOptions = [
{ id: 0, label: "全部区域" },
{ id: 101, label: "印度尼西亚" },
{ id: 102, label: "印度" },
{ id: 103, label: "美国" },
{ id: 104, label: "巴西" },
{ id: 105, label: "土耳其" }
];

View File

@ -37,6 +37,10 @@ export function countryFlag(code) {
.join("");
}
export function stripCountryFlagPrefix(value) {
return String(value || "").replace(/^(?:\s*[\u{1F1E6}-\u{1F1FF}]{2})+\s*/u, "").trim();
}
function normalizeKey(value) {
return String(value || "").trim().toLowerCase();
}

View File

@ -1,116 +1,215 @@
import { CoinIcon, ActiveUsersIcon, CrownIcon, StarUserIcon, TrendIcon, UserPlusIcon } from "../components/MetricIcons.jsx";
import { countryFlag, resolveCountryMeta } from "./countryMeta.js";
import { formatMoney, formatMoneyFull, formatNumber, formatPercent, formatWholeMoney } from "../utils/format.js";
import { countryFlag, resolveCountryMeta, stripCountryFlagPrefix } from "./countryMeta.js";
import { formatCoin, formatMoney, formatMoneyFull, formatNumber, formatPercent, formatWholeMoney } from "../utils/format.js";
import { numberValue, ratio } from "../utils/number.js";
import { formatDateTime } from "../utils/time.js";
import { sampleOverview } from "./sampleOverview.js";
import { formatDateTime, sameRange, todayRange } from "../utils/time.js";
export function createDashboardModel(overview, { appCode, countryId, preview }) {
const source = overview || sampleOverview;
const recharge = numberValue(source.recharge_usd_minor);
const activeUsers = numberValue(source.active_users);
const paidUsers = numberValue(source.paid_users);
const newUsers = numberValue(source.new_users);
export function createDashboardModel(overview, { appCode, countryId, range, timeZone }) {
const source = overview || {};
const comparisonCaption = isTodayRange(range, timeZone) ? "与昨日相比" : "近 7 日";
const recharge = effectiveRechargeUSDMinor(source);
const userRecharge = effectiveUserRechargeUSDMinor(source);
const newUserRecharge = readNumber(source, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor");
const newPaidUsers = readNumber(source, "new_paid_users", "newPaidUsers");
const activeUsers = readNumber(source, "active_users", "activeUsers");
const paidUsers = readNumber(source, "paid_users", "paidUsers");
const rechargeUsers = readNumber(source, "recharge_users", "rechargeUsers", "paid_users", "paidUsers");
const newUsers = readNumber(source, "new_users", "newUsers", "visitors");
const arpu = readNumber(source, "arpu_usd_minor", "arpuUsdMinor");
const arppu = readNumber(source, "arppu_usd_minor", "arppuUsdMinor");
const giftCoinSpent = readNumber(source, "gift_coin_spent", "giftCoinSpent");
const coinSellerTransferCoin = readNumber(source, "coin_seller_transfer_coin", "coinSellerTransferCoin");
const luckyGiftPools = normalizeLuckyGiftPools(source);
const luckyGiftPoolTotals = summarizeLuckyGiftPools(luckyGiftPools);
const luckyGiftTurnover = luckyGiftPools.length ? luckyGiftPoolTotals.turnover : readNumber(source, "room_lucky_gift_turnover", "roomLuckyGiftTurnover", "lucky_gift_turnover", "luckyGiftTurnover");
const luckyGiftPayout = luckyGiftPools.length ? luckyGiftPoolTotals.payout : readNumber(source, "lucky_gift_payout", "luckyGiftPayout");
const luckyGiftProfit = luckyGiftPools.length ? luckyGiftPoolTotals.profit : readNumber(source, "lucky_gift_profit", "luckyGiftProfit");
const gameTurnover = readNumber(source, "game_turnover", "gameTurnover");
const gameProfit = readNumber(source, "game_profit", "gameProfit");
const countryBreakdown = normalizeCountries(source, countryId);
const revenueSeries = normalizeRevenueSeries(source);
const dailySeries = normalizeDailySeries(source);
const revenueSeries = dailySeries.length ? dailySeries : normalizeRevenueSeries(source);
const payoutDistribution = normalizeDistribution(source);
const gameRanking = normalizeGameRanking(source);
return {
appCode,
businessKpis: [
{ caption: "近 7 日", delta: preview ? "+16.80%" : "", label: "礼物消费", value: formatWholeMoney(source.gift_coin_spent) },
{ caption: "近 7 日", delta: preview ? "+12.93%" : "", label: "幸运礼物流水", value: formatWholeMoney(source.room_lucky_gift_turnover ?? source.lucky_gift_turnover) },
{ caption: "近 7 日", delta: preview ? "+8.15%" : "", label: "幸运礼物返奖", value: formatWholeMoney(source.lucky_gift_payout) },
{ caption: "近 7 日", delta: preview ? "+4.66%" : "", label: "幸运礼物利润", value: formatWholeMoney(source.lucky_gift_profit) },
{ caption: "近 7 日", delta: preview ? "+14.20%" : "", label: "游戏流水", value: formatWholeMoney(source.game_turnover) },
{ caption: "近 7 日", delta: preview ? "+18.18%" : "", label: "游戏利润", value: formatWholeMoney(source.game_profit) }
coinMetric("礼物消费", giftCoinSpent, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "gift_coin_spent", "礼物消费", "coin") }),
coinMetric("币商转账金币", coinSellerTransferCoin, readDelta(source, "coin_seller_transfer_coin_delta_rate", "coinSellerTransferCoinDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "coin_seller_transfer_coin", "币商转账金币", "coin") }),
coinMetric("幸运礼物流水", luckyGiftTurnover, readDelta(source, "room_lucky_gift_turnover_delta_rate", "lucky_gift_turnover_delta_rate", "roomLuckyGiftTurnoverDeltaRate", "luckyGiftTurnoverDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools", trend: metricTrend(dailySeries, "lucky_gift_turnover", "幸运礼物流水", "coin") }),
coinMetric("幸运礼物返奖", luckyGiftPayout, readDelta(source, "lucky_gift_payout_delta_rate", "luckyGiftPayoutDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools", trend: metricTrend(dailySeries, "lucky_gift_payout", "幸运礼物返奖", "coin") }),
coinMetric("幸运礼物利润", luckyGiftProfit, readDelta(source, "lucky_gift_profit_delta_rate", "luckyGiftProfitDeltaRate"), comparisonCaption, { detailKey: "luckyGiftPools", trend: metricTrend(dailySeries, "lucky_gift_profit", "幸运礼物利润", "coin") }),
coinMetric("游戏流水", gameTurnover, readDelta(source, "game_turnover_delta_rate", "gameTurnoverDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "game_turnover", "游戏流水", "coin") }),
coinMetric("游戏利润", gameProfit, readDelta(source, "game_profit_delta_rate", "gameProfitDeltaRate"), comparisonCaption, { trend: metricTrend(dailySeries, "game_profit", "游戏利润", "coin") })
],
countryBreakdown,
funnel: [
{ name: "访客", rate: 1, value: newUsers },
{ name: "活跃用户", rate: ratio(activeUsers, newUsers), value: activeUsers },
{ name: "活跃用户", rate: 1, value: activeUsers },
{ name: "付费用户", rate: ratio(paidUsers, activeUsers), value: paidUsers },
{ name: "充值用户", rate: ratio(numberValue(source.lucky_gift_payers), activeUsers), value: numberValue(source.lucky_gift_payers) }
{ name: "充值用户", rate: ratio(rechargeUsers, activeUsers), value: rechargeUsers }
],
gameMetrics: [
{ caption: "近 7 日 +14.20%", label: "流水", value: formatWholeMoney(source.game_turnover) },
{ caption: "近 7 日 +9.63%", label: "派奖", value: formatWholeMoney(source.game_payout) },
{ caption: "近 7 日 -3.58%", label: "退款", value: formatWholeMoney(source.game_refund) },
{ caption: "近 7 日 +18.18%", label: "利润", value: formatWholeMoney(source.game_profit) }
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "game_turnover_delta_rate", "gameTurnoverDeltaRate")), label: "流水", value: formatWholeMoney(source.game_turnover) },
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "game_payout_delta_rate", "gamePayoutDeltaRate")), label: "派奖", value: formatWholeMoney(source.game_payout) },
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "game_refund_delta_rate", "gameRefundDeltaRate")), label: "退款", value: formatWholeMoney(source.game_refund) },
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "game_profit_delta_rate", "gameProfitDeltaRate")), label: "利润", value: formatWholeMoney(source.game_profit) }
],
gameRanking,
giftRanking: normalizeGiftRanking(source),
kpis: [
{ caption: "近 7 日", delta: preview ? "+18.10%" : "", icon: CoinIcon, label: "总充值", value: formatMoneyFull(recharge) },
{ caption: "近 7 日", delta: preview ? "+16.80%" : "", icon: UserPlusIcon, label: "新用户充值", value: formatMoneyFull(source.new_user_recharge_usd_minor) },
{ caption: "近 7 日", delta: preview ? "+10.77%" : "", icon: ActiveUsersIcon, label: "活跃用户", value: formatNumber(activeUsers) },
{ caption: "近 7 日", delta: preview ? "+12.92%" : "", icon: CrownIcon, label: "付费用户", value: formatNumber(paidUsers) },
{ caption: "近 7 日", delta: preview ? "-0.80%" : "", deltaTone: "down", icon: TrendIcon, label: "ARPU", value: formatMoney(source.arpu_usd_minor) },
{ caption: "近 7 日", delta: preview ? "+3.06%" : "", icon: StarUserIcon, label: "ARPPU", value: formatMoney(source.arppu_usd_minor) }
{ caption: comparisonCaption, delta: readDelta(source, "recharge_delta_rate", "rechargeDeltaRate", "total_recharge_delta_rate", "totalRechargeDeltaRate"), icon: CoinIcon, label: "总充值", trend: metricTrend(dailySeries, "recharge_usd_minor", "总充值", "money"), trendFormat: "money", value: formatMoneyFull(recharge) },
{
icon: UserPlusIcon,
label: "用户充值 / 新用户充值",
subMetrics: [
{ caption: comparisonCaption, delta: readDelta(source, "user_recharge_delta_rate", "userRechargeDeltaRate", "recharge_delta_rate", "rechargeDeltaRate"), label: "用户充值", value: formatMoneyFull(userRecharge) },
{ caption: comparisonCaption, delta: readDelta(source, "new_user_recharge_delta_rate", "newUserRechargeDeltaRate"), label: "新用户充值", value: formatMoneyFull(newUserRecharge) }
],
trend: [
...metricTrend(dailySeries, "user_recharge_usd_minor", "用户充值", "money"),
...metricTrend(dailySeries, "new_user_recharge_usd_minor", "新用户充值", "money")
],
trendFormat: "money"
},
{ caption: comparisonCaption, delta: readDelta(source, "new_users_delta_rate", "newUsersDeltaRate"), icon: UserPlusIcon, label: "新增用户数", trend: metricTrend(dailySeries, "new_users", "新增用户数", "number"), trendFormat: "number", value: formatNumber(newUsers) },
{ caption: comparisonCaption, delta: readDelta(source, "active_users_delta_rate", "activeUsersDeltaRate"), icon: ActiveUsersIcon, label: "活跃用户", trend: metricTrend(dailySeries, "active_users", "活跃用户", "number"), trendFormat: "number", value: formatNumber(activeUsers) },
{
icon: CrownIcon,
label: "付费用户 / 新增付费用户",
subMetrics: [
{ caption: comparisonCaption, delta: readDelta(source, "paid_users_delta_rate", "paidUsersDeltaRate"), label: "付费用户", value: formatNumber(paidUsers) },
{ caption: comparisonCaption, delta: readDelta(source, "new_paid_users_delta_rate", "newPaidUsersDeltaRate"), label: "新增付费用户", value: formatNumber(newPaidUsers) }
],
trend: [
...metricTrend(dailySeries, "paid_users", "付费用户", "number"),
...metricTrend(dailySeries, "new_paid_users", "新增付费用户", "number")
],
trendFormat: "number"
},
{ caption: comparisonCaption, delta: readDelta(source, "arpu_delta_rate", "arpuDeltaRate"), deltaTone: deltaTone(source, "arpu_delta_rate", "arpuDeltaRate"), icon: TrendIcon, label: "ARPU", trend: metricTrend(dailySeries, "arpu_usd_minor", "ARPU", "money"), trendFormat: "money", value: formatMoney(arpu) },
{ caption: comparisonCaption, delta: readDelta(source, "arppu_delta_rate", "arppuDeltaRate"), icon: StarUserIcon, label: "ARPPU", trend: metricTrend(dailySeries, "arppu_usd_minor", "ARPPU", "money"), trendFormat: "money", value: formatMoney(arppu) }
],
luckyMetrics: [
{ caption: "近 7 日 +10.62%", label: "流水", value: formatWholeMoney(source.lucky_gift_turnover) },
{ caption: "近 7 日 +8.15%", label: "返奖", value: formatWholeMoney(source.lucky_gift_payout) },
{ caption: "近 7 日 +4.66%", label: "利润", value: formatWholeMoney(source.lucky_gift_profit) },
{ caption: "近 7 日 -2.01pp", label: "返奖率", value: formatPercent(source.lucky_gift_payout_rate) },
{ caption: "近 7 日 +0.76pp", label: "利润率", value: formatPercent(source.lucky_gift_profit_rate) }
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_turnover_delta_rate", "luckyGiftTurnoverDeltaRate")), label: "流水", value: formatWholeMoney(luckyGiftTurnover) },
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_payout_delta_rate", "luckyGiftPayoutDeltaRate")), label: "返奖", value: formatWholeMoney(luckyGiftPayout) },
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_profit_delta_rate", "luckyGiftProfitDeltaRate")), label: "利润", value: formatWholeMoney(luckyGiftProfit) },
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_payout_rate_delta_pp", "luckyGiftPayoutRateDeltaPp"), "pp"), label: "返奖率", value: formatPercent(source.lucky_gift_payout_rate) },
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "lucky_gift_profit_rate_delta_pp", "luckyGiftProfitRateDeltaPp"), "pp"), label: "利润率", value: formatPercent(source.lucky_gift_profit_rate) }
],
luckyGiftPools,
payoutDistribution,
revenueSeries,
roomMetrics: [
{ caption: "近 7 日 +16.80%", label: "礼物消费(充值)", value: formatWholeMoney(source.gift_coin_spent) },
{ caption: "近 7 日 +12.93%", label: "幸运礼物流水", value: formatWholeMoney(source.room_lucky_gift_turnover ?? source.lucky_gift_turnover) }
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "gift_coin_spent_delta_rate", "giftCoinSpentDeltaRate")), label: "礼物消费(充值)", value: formatWholeMoney(source.gift_coin_spent) },
{ caption: captionWithDelta(comparisonCaption, readDelta(source, "room_lucky_gift_turnover_delta_rate", "lucky_gift_turnover_delta_rate", "roomLuckyGiftTurnoverDeltaRate", "luckyGiftTurnoverDeltaRate")), label: "幸运礼物流水", value: formatWholeMoney(source.room_lucky_gift_turnover ?? source.lucky_gift_turnover) }
],
sideMetrics: [
{ caption: "近 7 日 -0.80%", deltaTone: "down", label: "ARPU", value: formatMoney(source.arpu_usd_minor) },
{ caption: "近 7 日 +0.18pp", label: "付费转化率", value: formatPercent(ratio(paidUsers, newUsers)) },
{ caption: "近 7 日 +3.06%", label: "ARPPU", value: formatMoney(source.arppu_usd_minor) }
{ 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, "arppu_delta_rate", "arppuDeltaRate")), label: "ARPPU", value: formatMoney(arppu) }
],
topCountries: countryBreakdown.slice(0, 5),
updatedAt: formatDateTime(source.updated_at_ms || source.updatedAtMs || Date.now())
topCountries: countryBreakdown.slice(0, 4),
updatedAt: formatDateTime(source.updated_at_ms || source.updatedAtMs || Date.now(), timeZone)
};
}
function normalizeRevenueSeries(source) {
if (Array.isArray(source.daily_series) && source.daily_series.length) {
return source.daily_series.map((item) => ({
coin_seller: numberValue(item.coin_seller ?? item.coinSeller),
google: numberValue(item.google),
label: localizeSeriesLabel(item.label || item.stat_day || item.day || "-"),
lineTotal: numberValue(item.line_total ?? item.lineTotal ?? item.trend_total ?? item.trendTotal ?? item.total ?? item.recharge_usd_minor),
mifapay: numberValue(item.mifapay ?? item.mifa_pay),
total: numberValue(item.total ?? item.recharge_usd_minor)
}));
function coinMetric(label, value, delta, caption, extra = {}) {
return { caption, delta, label, unit: "金币", unitIcon: CoinIcon, value: formatCoin(value), ...extra };
}
function captionWithDelta(label, delta) {
return `${label} ${delta}`;
}
function readNumber(source, ...keys) {
for (const key of keys) {
if (source?.[key] !== undefined && source[key] !== null) {
return numberValue(source[key]);
}
}
return 0;
}
function readDelta(source, ...keys) {
const unit = keys[keys.length - 1] === "pp" ? keys.pop() : "%";
for (const key of keys) {
if (source?.[key] !== undefined && source[key] !== null && source[key] !== "") {
return formatDelta(source[key], unit);
}
}
return "";
}
function deltaTone(source, ...keys) {
for (const key of keys) {
if (source?.[key] !== undefined && source[key] !== null && source[key] !== "") {
return numberValue(source[key]) < 0 ? "down" : "up";
}
}
return "up";
}
function formatDelta(value, unit) {
const numeric = numberValue(value);
const normalized = unit === "%" && Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
const prefix = normalized > 0 ? "+" : "";
return `${prefix}${normalized.toFixed(2)}${unit}`;
}
function isTodayRange(range, timeZone) {
return sameRange(range, todayRange(timeZone));
}
function normalizeRevenueSeries(source) {
if (!source || !Object.keys(source).length) {
return [];
}
if (Array.isArray(source.daily_series) && source.daily_series.length) {
return source.daily_series.map((item) => {
const coinSeller = numberValue(item.coin_seller ?? item.coin_seller_recharge_usd_minor ?? item.coinSellerRechargeUsdMinor);
const google = numberValue(item.google ?? item.google_recharge_usd_minor ?? item.googleRechargeUsdMinor);
const mifapay = numberValue(item.mifapay ?? item.mifa_pay ?? item.mifapay_recharge_usd_minor ?? item.mifaPayRechargeUsdMinor);
const channelTotal = google + mifapay + coinSeller;
const total = Math.max(numberValue(item.total ?? item.recharge_usd_minor), channelTotal);
return {
coin_seller: coinSeller,
google,
label: localizeSeriesLabel(item.label || item.stat_day || item.day || "-"),
lineTotal: Math.max(numberValue(item.line_total ?? item.lineTotal ?? item.trend_total ?? item.trendTotal ?? total), channelTotal),
mifapay,
total
};
});
}
const coinSeller = readNumber(source, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor");
const google = numberValue(source.google_recharge_usd_minor ?? source.googleRechargeUsdMinor);
const mifapay = numberValue(source.mifapay_recharge_usd_minor ?? source.mifaPayRechargeUsdMinor);
const total = effectiveRechargeUSDMinor(source);
return [
{
coin_seller: numberValue(source.coin_seller_recharge_usd_minor),
google: numberValue(source.google_recharge_usd_minor),
coin_seller: coinSeller,
google,
label: "当前",
lineTotal: numberValue(source.recharge_usd_minor),
mifapay: numberValue(source.mifapay_recharge_usd_minor),
total: numberValue(source.recharge_usd_minor)
lineTotal: total,
mifapay,
total
}
];
}
function normalizeCountries(source, countryId) {
const raw = Array.isArray(source.country_breakdown) && source.country_breakdown.length ? source.country_breakdown : null;
const rows =
raw ||
[
{
active_users: source.active_users,
arppu_usd_minor: source.arppu_usd_minor,
country: countryId ? `国家 ${countryId}` : "全部区域",
paid_users: source.paid_users,
recharge_usd_minor: source.recharge_usd_minor,
x: 56,
y: 52
}
];
const totalRecharge = rows.reduce((sum, item) => sum + numberValue(item.recharge_usd_minor), 0) || 1;
const rows = raw || (countryId ? [{
active_users: source.active_users,
arppu_usd_minor: source.arppu_usd_minor,
country: `国家 ${countryId}`,
paid_users: source.paid_users,
recharge_usd_minor: source.recharge_usd_minor,
x: 56,
y: 52
}] : []);
const totalRecharge = rows.reduce((sum, item) => sum + effectiveRechargeUSDMinor(item), 0) || 1;
return rows
.map((item, index) => ({
...normalizeCountryRow(item, index, totalRecharge)
@ -120,14 +219,16 @@ function normalizeCountries(source, countryId) {
function normalizeCountryRow(item, index, totalRecharge) {
const code = item.country_code || item.countryCode || item.iso_code || item.isoCode;
const country = item.country || item.country_name || code || `国家 ${index + 1}`;
const countryId = numberValue(item.country_id ?? item.countryId);
const country = stripCountryFlagPrefix(item.country || item.country_name || code || (countryId > 0 ? `国家 ${countryId || index + 1}` : "未知国家"));
const meta = resolveCountryMeta({ code, name: country });
const recharge = numberValue(item.recharge_usd_minor);
const recharge = effectiveRechargeUSDMinor(item);
return {
active_users: numberValue(item.active_users),
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),
coin_seller_transfer_coin: numberValue(item.coin_seller_transfer_coin ?? item.coinSellerTransferCoin),
country: meta?.label || country,
country_code: code || meta?.code || "",
flag: countryFlag(code || meta?.code),
@ -137,40 +238,160 @@ function normalizeCountryRow(item, index, totalRecharge) {
payer_rate: ratio(item.paid_users, item.active_users),
recharge_usd_minor: recharge,
recharge_conversion_rate: numberValue(item.recharge_conversion_rate ?? item.rechargeConversionRate),
recharge_users: numberValue(item.recharge_users ?? item.rechargeUsers),
recharge_users: numberValue(item.recharge_users ?? item.rechargeUsers ?? item.paid_users ?? item.paidUsers),
share: recharge / totalRecharge,
trend_rate: numberValue(item.trend_rate ?? item.trendRate),
visitors: numberValue(item.visitors ?? item.new_users ?? item.newUsers)
};
}
function effectiveRechargeUSDMinor(source) {
const direct = readNumber(source, "recharge_usd_minor", "rechargeUsdMinor", "total_recharge_usd_minor", "totalRechargeUsdMinor");
const channelTotal = readNumber(source, "mifapay_recharge_usd_minor", "mifapayRechargeUsdMinor", "mifa_pay_recharge_usd_minor", "mifaPayRechargeUsdMinor") +
readNumber(source, "google_recharge_usd_minor", "googleRechargeUsdMinor") +
readNumber(source, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor");
return Math.max(direct, channelTotal);
}
function effectiveUserRechargeUSDMinor(source) {
const google = readNumber(source, "google_recharge_usd_minor", "googleRechargeUsdMinor");
const mifapay = readNumber(source, "mifapay_recharge_usd_minor", "mifapayRechargeUsdMinor", "mifa_pay_recharge_usd_minor", "mifaPayRechargeUsdMinor");
const channelUserRecharge = google + mifapay;
const direct = readNumber(source, "user_recharge_usd_minor", "userRechargeUsdMinor", "recharge_usd_minor", "rechargeUsdMinor");
const coinSeller = readNumber(source, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor");
return Math.max(channelUserRecharge, Math.max(0, direct - coinSeller));
}
function normalizeDailySeries(source) {
if (!Array.isArray(source?.daily_series) || !source.daily_series.length) {
return [];
}
return source.daily_series.map((item) => {
const google = readNumber(item, "google_recharge_usd_minor", "googleRechargeUsdMinor", "google");
const mifapay = readNumber(item, "mifapay_recharge_usd_minor", "mifapayRechargeUsdMinor", "mifa_pay_recharge_usd_minor", "mifaPayRechargeUsdMinor", "mifapay");
const coinSeller = readNumber(item, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor", "coin_seller");
const channelTotal = google + mifapay + coinSeller;
const recharge = Math.max(effectiveRechargeUSDMinor(item), channelTotal);
const userRecharge = Math.max(google + mifapay, Math.max(0, readNumber(item, "user_recharge_usd_minor", "userRechargeUsdMinor", "recharge_usd_minor", "rechargeUsdMinor") - coinSeller));
const gameTurnover = readNumber(item, "game_turnover", "gameTurnover");
const gameProfit = readNumber(item, "game_profit", "gameProfit");
const luckyGiftTurnover = readNumber(item, "lucky_gift_turnover", "luckyGiftTurnover", "room_lucky_gift_turnover", "roomLuckyGiftTurnover");
const luckyGiftPayout = readNumber(item, "lucky_gift_payout", "luckyGiftPayout");
return {
active_users: readNumber(item, "active_users", "activeUsers"),
arppu_usd_minor: readNumber(item, "arppu_usd_minor", "arppuUsdMinor"),
arpu_usd_minor: readNumber(item, "arpu_usd_minor", "arpuUsdMinor"),
coin_seller: coinSeller,
coin_seller_recharge_usd_minor: coinSeller,
coin_seller_transfer_coin: readNumber(item, "coin_seller_transfer_coin", "coinSellerTransferCoin"),
game_profit: gameProfit,
game_turnover: gameTurnover,
gift_coin_spent: readNumber(item, "gift_coin_spent", "giftCoinSpent"),
google,
label: localizeSeriesLabel(item.label || item.stat_day || item.day || "-"),
lineTotal: Math.max(readNumber(item, "line_total", "lineTotal", "trend_total", "trendTotal", "total"), recharge, channelTotal),
lucky_gift_payout: luckyGiftPayout,
lucky_gift_profit: readNumber(item, "lucky_gift_profit", "luckyGiftProfit") || (luckyGiftTurnover - luckyGiftPayout),
lucky_gift_turnover: luckyGiftTurnover,
mifapay,
new_paid_users: readNumber(item, "new_paid_users", "newPaidUsers"),
new_user_recharge_usd_minor: readNumber(item, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor"),
new_users: readNumber(item, "new_users", "newUsers"),
paid_users: readNumber(item, "paid_users", "paidUsers"),
recharge_usd_minor: recharge,
recharge_users: readNumber(item, "recharge_users", "rechargeUsers", "paid_users", "paidUsers"),
stat_day: item.stat_day || item.statDay || item.day || item.label || "",
total: recharge,
user_recharge_usd_minor: userRecharge
};
});
}
function metricTrend(series, key, name, format = "number") {
const data = series.map((item) => ({
label: item.label || item.stat_day || "-",
value: format === "money" ? numberValue(item[key]) / 100 : numberValue(item[key])
}));
return data.length ? [{ data, name }] : [];
}
function normalizeGiftRanking(source) {
if (Array.isArray(source.gift_ranking) && source.gift_ranking.length) {
return source.gift_ranking.map((item) => ({ name: item.name || item.gift_name || item.gift_id || "-", value: numberValue(item.value ?? item.amount ?? item.coin) }));
return source.gift_ranking.map((item) => ({
name: item.name || item.gift_name || item.gift_id || "-",
trend_rate: numberValue(item.trend_rate ?? item.trendRate),
value: numberValue(item.value ?? item.amount ?? item.coin)
}));
}
return [{ name: "礼物消费", value: numberValue(source.gift_coin_spent) }];
return [];
}
function normalizeDistribution(source) {
if (Array.isArray(source.payout_distribution) && source.payout_distribution.length) {
return source.payout_distribution.map((item) => ({ name: item.name || "-", value: numberValue(item.value) }));
}
if (!source || !Object.keys(source).length) {
return [];
}
return [
{ name: "幸运礼物返奖", value: numberValue(source.lucky_gift_payout) },
{ name: "幸运礼物利润", value: Math.max(numberValue(source.lucky_gift_profit), 0) }
];
}
function normalizeLuckyGiftPools(source) {
const raw = firstArray(source?.lucky_gift_pools, source?.luckyGiftPools, source?.lucky_gift_pool_stats, source?.luckyGiftPoolStats, source?.pool_breakdown);
return raw
.map((item) => {
const turnover = readNumber(item, "turnover_coin", "turnoverCoin", "turnover", "total_spent_coins", "totalSpentCoins", "lucky_gift_turnover", "luckyGiftTurnover");
const payout = readNumber(item, "payout_coin", "payoutCoin", "payout", "total_reward_coins", "totalRewardCoins", "lucky_gift_payout", "luckyGiftPayout");
const rawProfit = item.profit_coin ?? item.profitCoin ?? item.profit ?? item.lucky_gift_profit ?? item.luckyGiftProfit;
const profit = rawProfit === undefined || rawProfit === null ? turnover - payout : numberValue(rawProfit);
return {
payout,
payout_rate: numberValue(item.payout_rate ?? item.payoutRate ?? ratio(payout, turnover)),
pool_id: String(item.pool_id ?? item.poolId ?? item.id ?? "-").trim() || "-",
profit,
profit_rate: numberValue(item.profit_rate ?? item.profitRate ?? ratio(profit, turnover)),
turnover
};
})
.sort((left, right) => right.turnover - left.turnover || left.pool_id.localeCompare(right.pool_id));
}
function summarizeLuckyGiftPools(items) {
return items.reduce((summary, item) => ({
payout: summary.payout + item.payout,
profit: summary.profit + item.profit,
turnover: summary.turnover + item.turnover
}), { payout: 0, profit: 0, turnover: 0 });
}
function firstArray(...values) {
return values.find((value) => Array.isArray(value)) || [];
}
function normalizeGameRanking(source) {
if (Array.isArray(source.game_ranking) && source.game_ranking.length) {
return source.game_ranking.map((item) => ({
game_id: item.game_id || item.gameId || "-",
game_label: gameDisplayLabel(item),
game_name: item.game_name || item.gameName || item.name || "",
platform_code: item.platform_code || item.platformCode || "",
profit_rate: numberValue(item.profit_rate ?? item.profitRate),
turnover_coin: numberValue(item.turnover_coin ?? item.turnoverCoin)
}));
}
return [{ game_id: "全部游戏", platform_code: "", profit_rate: numberValue(source.game_profit_rate), turnover_coin: numberValue(source.game_turnover) }];
return [];
}
function gameDisplayLabel(item) {
const id = String(item.game_id || item.gameId || "").trim();
const name = String(item.game_name || item.gameName || item.name || "").trim();
if (name && id && name !== id) {
return `${name}${id}`;
}
return name || id || "-";
}
function localizeSeriesLabel(label) {

View File

@ -0,0 +1,72 @@
import { expect, test } from "vitest";
import { createDashboardModel } from "./createDashboardModel.js";
test("uses lucky gift pool breakdown to aggregate business metrics", () => {
const model = createDashboardModel({
lucky_gift_pools: [
{ payout_coin: 300, pool_id: "lucky_100", turnover_coin: 1_000 },
{ payout_coin: 50, pool_id: "super_lucky", turnover_coin: 500 }
],
lucky_gift_payout: 1,
lucky_gift_profit: 1,
lucky_gift_turnover: 1
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } });
expect(model.luckyGiftPools).toEqual([
expect.objectContaining({ payout: 300, pool_id: "lucky_100", profit: 700, turnover: 1_000 }),
expect.objectContaining({ payout: 50, pool_id: "super_lucky", profit: 450, turnover: 500 })
]);
expect(metricValue(model, "幸运礼物流水")).toBe("1,500");
expect(metricValue(model, "幸运礼物返奖")).toBe("350");
expect(metricValue(model, "幸运礼物利润")).toBe("1,150");
expect(model.businessKpis.filter((item) => item.detailKey === "luckyGiftPools")).toHaveLength(3);
});
test("includes coin seller stock recharge in USD total and keeps seller transfer as coin metric", () => {
const model = createDashboardModel({
coin_seller_recharge_usd_minor: 200,
coin_seller_transfer_coin: 160_000,
country_breakdown: [
{
coin_seller_recharge_usd_minor: 200,
coin_seller_transfer_coin: 160_000,
country: "阿富汗",
google_recharge_usd_minor: 150,
recharge_usd_minor: 150
}
],
daily_series: [
{
coin_seller: 200,
google: 150,
label: "当前",
recharge_usd_minor: 150
}
],
google_recharge_usd_minor: 150,
recharge_usd_minor: 150
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } });
expect(metricValue(model, "总充值")).toBe("$4");
expect(metricValue(model, "币商转账金币")).toBe("160,000");
expect(model.revenueSeries[0]).toEqual(expect.objectContaining({ coin_seller: 200, google: 150, lineTotal: 350, total: 350 }));
expect(model.countryBreakdown[0]).toEqual(expect.objectContaining({ coin_seller_transfer_coin: 160_000, recharge_usd_minor: 350 }));
});
test("exposes new user KPI and removes visitor stage from funnel", () => {
const model = createDashboardModel({
active_users: 80,
new_users: 12,
new_users_delta_rate: 0.24,
paid_users: 8,
recharge_users: 7
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } });
expect(metricValue(model, "新增用户数")).toBe("12");
expect(model.kpis.map((item) => item.label)).toContain("新增用户数");
expect(model.funnel.map((item) => item.name)).toEqual(["活跃用户", "付费用户", "充值用户"]);
});
function metricValue(model, label) {
return [...model.kpis, ...model.businessKpis].find((item) => item.label === label)?.value;
}

View File

@ -1,67 +0,0 @@
export const sampleOverview = {
active_users: 221188,
arppu_usd_minor: 306,
arpu_usd_minor: 5,
coin_seller_recharge_usd_minor: 196046500,
country_breakdown: [
{ active_users: 81560, arpu_usd_minor: 7, arppu_usd_minor: 334, avg_recharge_usd_minor: 2520, country: "印度尼西亚", new_user_recharge_usd_minor: 142140000, paid_users: 7008, recharge_conversion_rate: 0.0183, recharge_users: 5687, recharge_usd_minor: 235490000, trend_rate: 0.2043, visitors: 310200 },
{ active_users: 58190, arpu_usd_minor: 7, arppu_usd_minor: 303, avg_recharge_usd_minor: 2373, country: "印度", new_user_recharge_usd_minor: 94280000, paid_users: 4850, recharge_conversion_rate: 0.0179, recharge_users: 3985, recharge_usd_minor: 158732000, trend_rate: 0.1795, visitors: 222150 },
{ active_users: 24350, arpu_usd_minor: 7, arppu_usd_minor: 318, avg_recharge_usd_minor: 2485, country: "美国", new_user_recharge_usd_minor: 38165000, paid_users: 1980, recharge_conversion_rate: 0.0214, recharge_users: 1620, recharge_usd_minor: 62876000, trend_rate: 0.1611, visitors: 112700 },
{ active_users: 19840, arpu_usd_minor: 5, arppu_usd_minor: 297, avg_recharge_usd_minor: 2310, country: "巴西", new_user_recharge_usd_minor: 25041000, paid_users: 1360, recharge_conversion_rate: 0.0188, recharge_users: 1120, recharge_usd_minor: 40368000, trend_rate: 0.1238, visitors: 89350 },
{ active_users: 16720, arpu_usd_minor: 5, arppu_usd_minor: 290, avg_recharge_usd_minor: 2026, country: "土耳其", new_user_recharge_usd_minor: 15362000, paid_users: 1150, recharge_conversion_rate: 0.0188, recharge_users: 945, recharge_usd_minor: 24456000, trend_rate: 0.1081, visitors: 74880 },
{ active_users: 11220, arpu_usd_minor: 5, arppu_usd_minor: 284, avg_recharge_usd_minor: 2083, country: "埃及", new_user_recharge_usd_minor: 8941000, paid_users: 780, recharge_conversion_rate: 0.0188, recharge_users: 610, recharge_usd_minor: 15894000, trend_rate: 0.0942, visitors: 48660 },
{ active_users: 9880, arpu_usd_minor: 4, arppu_usd_minor: 270, avg_recharge_usd_minor: 2011, country: "泰国", new_user_recharge_usd_minor: 7403000, paid_users: 650, recharge_conversion_rate: 0.0171, recharge_users: 520, recharge_usd_minor: 13287000, trend_rate: 0.0725, visitors: 45210 },
{ active_users: 8860, arpu_usd_minor: 4, arppu_usd_minor: 251, avg_recharge_usd_minor: 1973, country: "越南", new_user_recharge_usd_minor: 6224000, paid_users: 580, recharge_conversion_rate: 0.0171, recharge_users: 470, recharge_usd_minor: 11643000, trend_rate: 0.0618, visitors: 42120 }
],
daily_series: [
{ coin_seller: 38000000, google: 42000000, label: "5月25日", line_total: 470000000, mifapay: 36000000, total: 116000000 },
{ coin_seller: 44000000, google: 47000000, label: "5月26日", line_total: 510000000, mifapay: 39000000, total: 130000000 },
{ coin_seller: 48000000, google: 52000000, label: "5月27日", line_total: 640000000, mifapay: 61000000, total: 161000000 },
{ coin_seller: 39000000, google: 39000000, label: "5月28日", line_total: 455000000, mifapay: 34000000, total: 112000000 },
{ coin_seller: 42000000, google: 44000000, label: "5月29日", line_total: 515000000, mifapay: 41000000, total: 127000000 },
{ coin_seller: 52000000, google: 61000000, label: "5月30日", line_total: 635000000, mifapay: 43000000, total: 156000000 },
{ coin_seller: 56000000, google: 53000000, label: "5月31日", line_total: 710000000, mifapay: 65000000, total: 174000000 }
],
game_payout: 343560,
game_players: 12932,
game_profit: 436248,
game_profit_rate: 0.5405,
game_ranking: [
{ game_id: "龙腾财富", payout_coin: 64020, player_count: 2038, profit_coin: 146410, profit_rate: 0.6958, turnover_coin: 210430 },
{ game_id: "海洋宝藏", payout_coin: 53260, player_count: 1820, profit_coin: 113660, profit_rate: 0.6809, turnover_coin: 166920 },
{ game_id: "幸运转盘", payout_coin: 43980, player_count: 1460, profit_coin: 84690, profit_rate: 0.6582, turnover_coin: 128670 },
{ game_id: "黄金帝国", payout_coin: 33400, player_count: 1024, profit_coin: 54060, profit_rate: 0.6254, turnover_coin: 87460 },
{ game_id: "探险之旅", payout_coin: 21980, player_count: 760, profit_coin: 38340, profit_rate: 0.6356, turnover_coin: 60320 }
],
game_refund: 27200,
game_turnover: 807008,
gift_coin_spent: 4277860,
gift_ranking: [
{ name: "飞梦", value: 568200 },
{ name: "银河飞船", value: 412450 },
{ name: "爱心城堡", value: 329180 },
{ name: "玫瑰花园", value: 265770 },
{ name: "水晶之心", value: 198260 }
],
google_recharge_usd_minor: 223200000,
lucky_gift_payout: 2573750,
lucky_gift_payout_rate: 6.3815,
lucky_gift_payers: 12932,
lucky_gift_profit: -2171086,
lucky_gift_profit_rate: -5.3815,
lucky_gift_turnover: 402664,
mifapay_recharge_usd_minor: 318500000,
new_user_recharge_usd_minor: 481316000,
new_users: 1012000,
paid_users: 16170,
payout_distribution: [
{ name: "用户幸运礼物返奖", value: 1616840 },
{ name: "幸运盒子返奖", value: 518420 },
{ name: "活动返奖", value: 269040 },
{ name: "其他", value: 169450 }
],
recharge_usd_minor: 737746500,
retention: { day1_rate: 0.6136, day7_rate: 0.181, day30_rate: 0.1077 },
room_lucky_gift_turnover: 635150,
updated_at_ms: Date.now()
};

View File

@ -11,14 +11,24 @@
.metric-card {
grid-column: span 2;
display: grid;
grid-template-columns: 44px 1fr;
height: 128px;
align-items: center;
gap: 15px;
padding: 14px 18px;
grid-template-columns: minmax(0, 1fr);
height: 140px;
align-items: stretch;
padding: 10px 14px;
border-radius: 8px;
}
.metric-card--clickable {
cursor: pointer;
}
.metric-card--clickable:hover,
.metric-card--clickable:focus-visible {
border-color: rgba(39, 228, 245, 0.78);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 0 0 3px rgba(39, 228, 245, 0.08);
outline: none;
}
.metric-icon {
display: grid;
width: 40px;
@ -38,10 +48,12 @@
}
.metric-content {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto 22px;
min-width: 0;
}
.metric-content > span,
.metric-label-row,
.mini-stat > span,
.section-label,
.rank-title {
@ -49,14 +61,75 @@
font-size: 12px;
}
.metric-label-row {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
}
.metric-title-icon {
display: inline-grid;
flex: 0 0 auto;
width: 15px;
height: 15px;
place-items: center;
color: #27e4f5;
}
.metric-title-icon svg {
width: 15px;
height: 15px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
}
.metric-label-row > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-unit-icon {
display: inline-grid;
flex: 0 0 auto;
width: 14px;
height: 14px;
place-items: center;
color: #27e4f5;
}
.metric-unit-icon svg {
width: 13px;
height: 13px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
}
.metric-unit {
flex: 0 0 auto;
color: #7f9bb7;
font-size: 10px;
font-style: normal;
font-weight: 680;
}
.metric-content strong {
display: block;
margin-top: 6px;
align-self: center;
margin-top: 2px;
overflow: hidden;
color: #f6fbff;
font-size: clamp(24px, 1.5vw, 30px);
font-size: clamp(20px, 1.25vw, 25px);
font-weight: 780;
line-height: 1.1;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
@ -65,11 +138,18 @@
display: flex;
justify-content: space-between;
gap: 8px;
margin-top: 9px;
padding-top: 8px;
margin-top: 2px;
padding-top: 4px;
border-top: 1px solid rgba(120, 169, 205, 0.16);
color: #6f8aa4;
font-size: 12px;
font-size: 11px;
}
.metric-delta--compact {
margin-top: 2px;
padding-top: 0;
border-top: 0;
font-size: 10px;
}
.metric-delta b,
@ -78,10 +158,129 @@
font-weight: 760;
}
.negative {
color: #f5a623;
font-weight: 760;
}
.metric-delta--down b {
color: #f5a623;
}
.metric-delta .metric-delta-empty {
color: #7f9bb7;
}
.metric-sub-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
align-self: stretch;
margin-top: 4px;
}
.metric-sub-item {
display: grid;
min-width: 0;
align-content: center;
}
.metric-sub-item > span {
overflow: hidden;
color: #8aa6c2;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-sub-item strong {
align-self: auto;
margin-top: 2px;
font-size: clamp(14px, 0.86vw, 18px);
}
.metric-sparkline {
display: block;
width: 100%;
height: 18px;
margin-top: 3px;
overflow: visible;
}
.metric-sparkline--empty {
border-bottom: 2px solid rgba(39, 228, 245, 0.22);
}
.metric-sparkline-line {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2.2;
vector-effect: non-scaling-stroke;
}
.metric-sparkline-line--1 {
stroke: #27e4f5;
}
.metric-sparkline-line--2 {
stroke: #24d79f;
opacity: 0.82;
}
.skeleton-block {
display: block;
position: relative;
overflow: hidden;
border-radius: 4px;
background: rgba(95, 151, 193, 0.16);
}
.skeleton-block::after {
position: absolute;
inset: 0;
transform: translateX(-110%);
background: linear-gradient(90deg, transparent, rgba(121, 213, 238, 0.22), transparent);
animation: skeleton-shimmer 1.35s ease-in-out infinite;
content: "";
}
@keyframes skeleton-shimmer {
to {
transform: translateX(110%);
}
}
.metric-card.is-loading .metric-content {
display: grid;
gap: 10px;
}
.metric-icon--skeleton {
color: transparent;
}
.skeleton-icon {
width: 34px;
height: 34px;
border-radius: 50%;
}
.skeleton-label {
width: 92px;
height: 13px;
}
.skeleton-value {
width: min(190px, 78%);
height: 30px;
}
.skeleton-caption {
width: min(160px, 68%);
height: 14px;
}
.side-metrics {
display: grid;
gap: 8px;

View File

@ -24,6 +24,24 @@
min-height: 0;
}
.chart-skeleton {
display: grid;
align-content: start;
gap: 16px;
padding: 12px 20px 20px;
}
.skeleton-chart-legend {
width: 58%;
height: 14px;
}
.skeleton-chart-body {
width: 100%;
height: 196px;
border-radius: 8px;
}
.donut-chart {
height: 182px;
}

View File

@ -7,9 +7,9 @@
.databi-screen {
--analysis-row-height: 320px;
--business-metric-row-height: 88px;
--business-metric-row-height: 104px;
display: grid;
grid-template-rows: 72px 128px var(--business-metric-row-height) var(--analysis-row-height);
grid-template-rows: 72px 140px var(--business-metric-row-height) var(--analysis-row-height);
gap: 16px;
min-height: 0;
}
@ -84,63 +84,543 @@
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 10px;
gap: 8px;
}
.range-control,
.region-control {
display: inline-flex;
.filter-control {
position: relative;
flex: 0 0 auto;
}
.filter-trigger {
display: grid;
min-width: 138px;
height: 44px;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
border: 1px solid rgba(64, 148, 197, 0.42);
padding: 0 12px;
border: 1px solid rgba(64, 148, 197, 0.48);
border-radius: 8px;
background: rgba(7, 29, 48, 0.86);
background: rgba(7, 29, 48, 0.9);
color: #d8ebff;
cursor: pointer;
font: inherit;
text-align: left;
}
.range-control {
width: 378px;
padding: 0 16px;
.filter-control--timezone .filter-trigger {
width: 118px;
min-width: 118px;
}
.range-control input,
.region-control select {
.filter-control--app .filter-trigger {
width: 138px;
min-width: 138px;
}
.filter-control--country .filter-trigger {
width: 150px;
}
.filter-trigger:hover,
.filter-trigger[aria-expanded="true"] {
border-color: rgba(39, 228, 245, 0.78);
box-shadow: 0 0 0 3px rgba(39, 228, 245, 0.08);
}
.filter-trigger:disabled {
cursor: progress;
opacity: 0.68;
}
.filter-icon,
.filter-chevron {
color: #cbd9e9;
}
.filter-icon {
display: grid;
min-width: 20px;
place-items: center;
}
.control-glyph {
color: #27e4f5;
font-size: 10px;
font-weight: 820;
letter-spacing: 0;
}
.filter-copy {
display: grid;
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;
text-overflow: ellipsis;
white-space: nowrap;
}
.date-range-trigger {
display: grid;
width: 336px;
height: 44px;
grid-template-columns: 24px minmax(0, 1fr) 20px;
align-items: center;
gap: 10px;
padding: 0 12px;
border: 1px solid rgba(64, 148, 197, 0.48);
border-radius: 8px;
background: rgba(7, 29, 48, 0.9);
box-shadow: none;
color: #e6f3ff;
cursor: pointer;
font: inherit;
text-align: left;
}
.date-range-trigger:hover,
.date-range-trigger[aria-expanded="true"] {
border-color: rgba(39, 228, 245, 0.78);
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 {
overflow: hidden;
color: #7f9bb7;
font-size: 10px;
font-weight: 720;
line-height: 1.05;
text-overflow: ellipsis;
white-space: nowrap;
}
.date-range-values {
display: grid;
min-width: 0;
grid-template-columns: minmax(0, auto) 16px minmax(0, auto);
align-items: center;
gap: 8px;
}
.date-range-values b {
overflow: hidden;
color: #eaf6ff;
font-size: 14px;
font-weight: 760;
line-height: 1.15;
text-overflow: ellipsis;
white-space: nowrap;
}
.date-range-values i {
color: #a7bdd3;
font-size: 13px;
font-style: normal;
text-align: center;
}
.date-range-chevron {
color: #cbd9e9;
justify-self: end;
}
.filter-popover {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 20;
display: grid;
width: 100%;
min-width: 168px;
gap: 4px;
padding: 8px;
border: 1px solid rgba(64, 148, 197, 0.52);
border-radius: 8px;
background: rgba(4, 21, 36, 0.98);
box-shadow: 0 18px 36px rgba(0, 0, 0, 0.36), inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.filter-popover button {
display: flex;
min-height: 34px;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 10px;
border: 0;
border-radius: 6px;
background: transparent;
color: #cfe3f7;
cursor: pointer;
font: inherit;
font-size: 12px;
text-align: left;
}
.filter-popover button:hover,
.filter-popover button.is-active {
background: rgba(39, 228, 245, 0.12);
color: #f3fbff;
}
.filter-popover--searchable {
width: 260px;
}
.filter-search {
display: grid;
gap: 5px;
padding: 2px 2px 8px;
}
.filter-search span {
color: #7f9bb7;
font-size: 10px;
font-weight: 720;
}
.filter-search input {
width: 100%;
height: 34px;
padding: 0 10px;
border: 1px solid rgba(64, 148, 197, 0.36);
border-radius: 7px;
outline: 0;
background: rgba(3, 18, 31, 0.9);
color: #e6f3ff;
font: inherit;
font-size: 12px;
}
.filter-search input:focus {
border-color: rgba(39, 228, 245, 0.72);
box-shadow: 0 0 0 3px rgba(39, 228, 245, 0.08);
}
.filter-options-scroll {
display: grid;
max-height: 250px;
gap: 4px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
}
.filter-options-scroll button span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filter-options-scroll button small {
flex: 0 0 auto;
color: #7f9bb7;
font-size: 10px;
font-weight: 760;
}
.filter-empty {
display: block;
padding: 10px;
color: #8faac5;
font-size: 12px;
}
.date-time-popover {
position: absolute;
top: calc(100% + 10px);
right: 0;
z-index: 30;
display: grid;
width: 680px;
overflow: hidden;
border: 1px solid rgba(39, 228, 245, 0.36);
border-radius: 10px;
background: rgba(4, 18, 31, 0.98);
box-shadow: 0 20px 46px rgba(0, 0, 0, 0.44), inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.date-time-shortcuts,
.date-time-fields,
.date-time-footer {
display: flex;
align-items: center;
}
.date-time-shortcuts {
gap: 8px;
padding: 10px 12px 0;
}
.date-time-shortcuts button,
.date-time-fields button,
.calendar-toolbar button,
.calendar-day,
.time-column button,
.date-time-footer button {
border: 0;
background: transparent;
color: #d8ebff;
color: #cfe3f7;
cursor: pointer;
font: inherit;
outline: 0;
}
.range-control input {
width: 132px;
color-scheme: dark;
.date-time-shortcuts button {
height: 28px;
padding: 0 12px;
border-radius: 7px;
color: #8faac5;
font-size: 12px;
font-weight: 720;
}
.range-control input::-webkit-calendar-picker-indicator {
display: none;
.date-time-shortcuts button:hover,
.date-time-shortcuts button.is-active {
background: rgba(39, 228, 245, 0.13);
color: #f2fbff;
}
.range-control svg,
.region-control svg {
color: #ccd9e8;
.date-time-fields {
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid rgba(111, 148, 183, 0.18);
}
.region-control {
width: 280px;
padding: 0 16px;
}
.region-control select {
.date-time-fields button {
display: grid;
min-width: 0;
flex: 1;
gap: 3px;
padding: 8px 12px;
border: 1px solid rgba(64, 148, 197, 0.3);
border-radius: 8px;
text-align: left;
}
.range-control:focus-within,
.region-control:focus-within {
border-color: rgba(39, 228, 245, 0.86);
box-shadow: 0 0 0 3px rgba(39, 228, 245, 0.1);
.date-time-fields button.is-active {
border-color: rgba(39, 228, 245, 0.76);
background: rgba(39, 228, 245, 0.1);
}
.date-time-fields span {
color: #7f9bb7;
font-size: 11px;
}
.date-time-fields b {
overflow: hidden;
color: #eaf6ff;
font-size: 13px;
font-weight: 760;
text-overflow: ellipsis;
white-space: nowrap;
}
.date-time-picker {
display: grid;
grid-template-columns: 452px 1fr;
min-height: 348px;
}
.calendar-pane {
display: grid;
grid-template-rows: 42px 1fr;
border-right: 1px solid rgba(111, 148, 183, 0.2);
}
.calendar-toolbar {
display: grid;
grid-template-columns: 34px 34px 1fr 34px 34px;
align-items: center;
border-bottom: 1px solid rgba(111, 148, 183, 0.18);
}
.calendar-toolbar button {
height: 34px;
color: #9fb4c9;
font-size: 24px;
line-height: 1;
}
.calendar-toolbar button:hover {
color: #27e4f5;
}
.calendar-toolbar strong {
color: #eaf6ff;
font-size: 20px;
font-weight: 760;
text-align: center;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-auto-rows: 40px;
align-content: start;
gap: 4px;
padding: 10px 18px 14px;
}
.calendar-weekday {
display: grid;
place-items: center;
color: #8faac5;
font-size: 16px;
font-weight: 720;
}
.calendar-day {
display: grid;
width: 36px;
height: 36px;
place-self: center;
place-items: center;
border-radius: 8px;
color: #d4e6f4;
font-size: 18px;
}
.calendar-day:hover {
background: rgba(39, 228, 245, 0.1);
color: #f2fbff;
}
.calendar-day.is-muted {
color: rgba(178, 194, 207, 0.38);
}
.calendar-day.is-between {
background: rgba(39, 228, 245, 0.07);
}
.calendar-day.is-start,
.calendar-day.is-end {
background: rgba(39, 228, 245, 0.18);
color: #ffffff;
font-weight: 780;
}
.calendar-day.is-active {
border: 1px solid #2f7cff;
background: rgba(47, 124, 255, 0.15);
color: #ffffff;
}
.time-pane {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.time-column {
display: grid;
grid-template-rows: 34px 1fr;
min-height: 0;
border-right: 1px solid rgba(111, 148, 183, 0.18);
}
.time-column:last-child {
border-right: 0;
}
.time-column > span {
display: grid;
place-items: center;
border-bottom: 1px solid rgba(111, 148, 183, 0.18);
color: #8faac5;
font-size: 12px;
font-weight: 720;
}
.time-column > div {
display: grid;
max-height: 306px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
}
.time-column button {
min-height: 34px;
color: #d4e6f4;
font-size: 17px;
}
.time-column button:hover,
.time-column button.is-active {
background: rgba(39, 228, 245, 0.12);
color: #ffffff;
font-weight: 760;
}
.date-time-footer {
justify-content: space-between;
min-height: 54px;
padding: 10px 12px;
border-top: 1px solid rgba(111, 148, 183, 0.18);
}
.date-time-footer span {
min-width: 0;
overflow: hidden;
color: #8faac5;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.date-time-footer button {
flex: 0 0 auto;
height: 34px;
min-width: 66px;
padding: 0 18px;
border: 1px solid rgba(39, 228, 245, 0.52);
border-radius: 8px;
background: rgba(39, 228, 245, 0.12);
color: #f2fbff;
font-size: 13px;
font-weight: 760;
}
.date-time-footer button:hover {
border-color: #27e4f5;
background: rgba(39, 228, 245, 0.18);
}
.live-dot {
@ -170,38 +650,83 @@
box-shadow: 0 0 14px rgba(247, 163, 58, 0.72);
}
.metric-grid,
.metric-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 16px;
}
.metric-grid .metric-card {
grid-column: span 1;
}
.business-metric-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 16px;
}
.business-metric-grid .metric-card {
height: var(--business-metric-row-height);
grid-column: span 1;
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
padding: 10px 16px;
padding: 10px 14px;
}
.business-metric-grid .metric-content {
display: grid;
grid-template-columns: minmax(0, 1fr) max-content;
grid-template-rows: auto 1fr;
column-gap: 12px;
grid-template-columns: minmax(max-content, 1fr) max-content;
grid-template-rows: auto 1fr 18px;
column-gap: 8px;
align-items: center;
}
.business-metric-grid .metric-content > span {
.business-metric-grid .metric-card.is-loading .metric-content {
grid-template-columns: minmax(0, 1fr) 96px;
grid-template-rows: 16px 28px;
}
.business-metric-grid .metric-card.is-loading .skeleton-label {
grid-column: 1;
grid-row: 1;
width: 88px;
}
.business-metric-grid .metric-card.is-loading .skeleton-value {
grid-column: 1;
grid-row: 2;
width: min(160px, 76%);
height: 24px;
}
.business-metric-grid .metric-card.is-loading .skeleton-caption {
grid-column: 2;
grid-row: 1 / 3;
align-self: center;
justify-self: end;
width: 86px;
}
.business-metric-grid .metric-label-row {
grid-column: 1;
grid-row: 1;
gap: 4px;
font-size: 11px;
white-space: nowrap;
}
.business-metric-grid .metric-label-row > span {
flex: 0 0 auto;
overflow: visible;
text-overflow: clip;
}
.business-metric-grid .metric-content strong {
grid-column: 1;
grid-row: 2;
margin-top: 3px;
font-size: clamp(18px, 1vw, 20px);
font-size: clamp(17px, 0.92vw, 20px);
line-height: 1.25;
}
@ -224,6 +749,11 @@
color: #7f9bb7;
}
.business-metric-grid .metric-sparkline {
grid-column: 1 / 3;
grid-row: 3;
}
.databi-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
@ -255,18 +785,3 @@
display: flex;
flex-direction: column;
}
.databi-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 34px;
margin-top: 16px;
color: #7f91a8;
font-size: 12px;
}
.databi-footer strong {
color: #f7a33a;
font-weight: 760;
}

View File

@ -103,6 +103,16 @@
min-width: 0;
}
.panel-empty {
display: grid;
width: 100%;
height: 100%;
min-height: 120px;
place-items: center;
color: #7f9bb7;
font-size: 13px;
}
.globe-map {
position: relative;
z-index: 2;
@ -169,8 +179,8 @@
height: 58px;
border: 1px solid rgba(43, 229, 245, 0.36);
border-radius: 2px;
background: linear-gradient(180deg, #2ff4e9 0%, #1fadc3 52%, #16759a 100%);
box-shadow: 0 0 14px rgba(37, 228, 245, 0.24);
background: linear-gradient(180deg, #24d79f 0%, #27e4f5 48%, #4281ff 100%);
box-shadow: 0 0 16px rgba(39, 228, 245, 0.32);
}
.rank-list {
@ -180,6 +190,16 @@
min-width: 0;
}
.rank-empty {
display: grid;
min-height: 112px;
place-items: center;
border: 1px dashed rgba(108, 157, 196, 0.22);
border-radius: 8px;
color: #7f9bb7;
font-size: 13px;
}
.rank-row {
display: grid;
grid-template-columns: 20px 20px minmax(0, 1fr) 62px;
@ -231,9 +251,431 @@
border-radius: 8px;
background: rgba(7, 35, 58, 0.72);
color: #2fe8f3;
cursor: pointer;
font-size: 14px;
}
.all-countries-button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.skeleton-panel {
pointer-events: none;
}
.skeleton-map {
display: grid;
min-height: 170px;
place-items: center;
}
.skeleton-map-glow {
width: 74%;
height: 62%;
border-radius: 8px;
}
.skeleton-rank-title {
width: 120px;
height: 14px;
margin-bottom: 2px;
}
.skeleton-rank-row {
width: 100%;
height: 36px;
border-radius: 8px;
}
.skeleton-button {
width: 100%;
height: 34px;
border-radius: 8px;
}
.country-globe-modal {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 32px;
background: rgba(1, 9, 19, 0.72);
backdrop-filter: blur(8px);
animation: modal-overlay-in 180ms ease-out both;
}
.country-globe-dialog {
display: grid;
width: min(1040px, calc(100vw - 64px));
height: min(760px, calc(100vh - 64px));
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
border: 1px solid rgba(44, 191, 227, 0.58);
border-radius: 8px;
background:
linear-gradient(180deg, rgba(9, 43, 70, 0.96), rgba(4, 18, 31, 0.98)),
rgba(5, 24, 42, 0.98);
box-shadow: 0 28px 72px rgba(0, 0, 0, 0.56), inset 0 1px 0 rgba(255, 255, 255, 0.06);
animation: modal-dialog-in 200ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.country-globe-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 18px;
border-bottom: 1px solid rgba(111, 148, 183, 0.2);
}
.country-globe-head h2 {
margin: 0;
color: #f3f9ff;
font-size: 20px;
font-weight: 780;
}
.country-globe-head span {
display: block;
margin-top: 4px;
color: #8faac5;
font-size: 12px;
}
.country-globe-head button {
display: inline-flex;
width: 34px;
height: 34px;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid rgba(64, 148, 197, 0.48);
border-radius: 8px;
background: rgba(7, 29, 48, 0.9);
color: #d8ebff;
cursor: pointer;
}
.country-globe-head button:hover {
border-color: rgba(39, 228, 245, 0.78);
color: #ffffff;
}
.country-globe-body {
min-height: 0;
padding: 18px;
}
.pool-detail-modal {
position: fixed;
inset: 0;
z-index: 90;
display: grid;
place-items: center;
padding: 32px;
background: rgba(1, 9, 19, 0.72);
backdrop-filter: blur(8px);
animation: modal-overlay-in 180ms ease-out both;
}
.metric-trend-modal {
position: fixed;
inset: 0;
z-index: 88;
display: grid;
place-items: center;
padding: 32px;
background: rgba(1, 9, 19, 0.72);
backdrop-filter: blur(8px);
animation: modal-overlay-in 180ms ease-out both;
}
.pool-detail-dialog {
display: grid;
width: min(860px, calc(100vw - 64px));
max-height: min(680px, calc(100vh - 64px));
grid-template-rows: auto auto minmax(0, 1fr);
overflow: hidden;
border: 1px solid rgba(44, 191, 227, 0.58);
border-radius: 8px;
background:
linear-gradient(180deg, rgba(9, 43, 70, 0.96), rgba(4, 18, 31, 0.98)),
rgba(5, 24, 42, 0.98);
box-shadow: 0 28px 72px rgba(0, 0, 0, 0.56), inset 0 1px 0 rgba(255, 255, 255, 0.06);
animation: modal-dialog-in 200ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.metric-trend-dialog {
display: grid;
width: min(760px, calc(100vw - 64px));
height: min(480px, calc(100vh - 64px));
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
border: 1px solid rgba(44, 191, 227, 0.58);
border-radius: 8px;
background:
linear-gradient(180deg, rgba(9, 43, 70, 0.96), rgba(4, 18, 31, 0.98)),
rgba(5, 24, 42, 0.98);
box-shadow: 0 28px 72px rgba(0, 0, 0, 0.56), inset 0 1px 0 rgba(255, 255, 255, 0.06);
animation: modal-dialog-in 200ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.pool-detail-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 18px;
border-bottom: 1px solid rgba(111, 148, 183, 0.2);
}
.metric-trend-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 18px;
border-bottom: 1px solid rgba(111, 148, 183, 0.2);
}
.pool-detail-head h2 {
margin: 0;
color: #f3f9ff;
font-size: 20px;
font-weight: 780;
}
.metric-trend-head h2 {
margin: 0;
color: #f3f9ff;
font-size: 20px;
font-weight: 780;
}
.pool-detail-head span {
display: block;
margin-top: 4px;
color: #8faac5;
font-size: 12px;
}
.metric-trend-head span {
display: block;
margin-top: 4px;
color: #8faac5;
font-size: 12px;
}
.pool-detail-head button {
display: inline-flex;
width: 34px;
height: 34px;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid rgba(64, 148, 197, 0.48);
border-radius: 8px;
background: rgba(7, 29, 48, 0.9);
color: #d8ebff;
cursor: pointer;
}
.metric-trend-head button {
display: inline-flex;
width: 34px;
height: 34px;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid rgba(64, 148, 197, 0.48);
border-radius: 8px;
background: rgba(7, 29, 48, 0.9);
color: #d8ebff;
cursor: pointer;
}
.pool-detail-head button:hover {
border-color: rgba(39, 228, 245, 0.78);
color: #ffffff;
}
.metric-trend-head button:hover {
border-color: rgba(39, 228, 245, 0.78);
color: #ffffff;
}
.country-globe-modal.is-closing,
.pool-detail-modal.is-closing,
.metric-trend-modal.is-closing {
animation: modal-overlay-out 160ms ease-in both;
}
.country-globe-modal.is-closing .country-globe-dialog,
.pool-detail-modal.is-closing .pool-detail-dialog,
.metric-trend-modal.is-closing .metric-trend-dialog {
animation: modal-dialog-out 160ms ease-in both;
}
.modal-close-mark {
position: relative;
display: block;
width: 16px;
height: 16px;
}
.modal-close-mark::before,
.modal-close-mark::after {
position: absolute;
top: 50%;
left: 50%;
width: 18px;
height: 2px;
border-radius: 999px;
background: currentColor;
content: "";
transform-origin: center;
}
.modal-close-mark::before {
transform: translate(-50%, -50%) rotate(45deg);
}
.modal-close-mark::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
@keyframes modal-overlay-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modal-overlay-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes modal-dialog-in {
from {
opacity: 0;
transform: translateY(10px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes modal-dialog-out {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(8px) scale(0.99);
}
}
.metric-trend-body {
min-height: 0;
padding: 16px 18px 18px;
}
.metric-trend-chart {
width: 100%;
height: 100%;
}
.pool-detail-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
padding: 16px 18px 8px;
}
.pool-summary-card {
min-width: 0;
padding: 12px 14px;
border: 1px solid rgba(35, 128, 180, 0.46);
border-radius: 8px;
background: rgba(7, 29, 48, 0.76);
}
.pool-summary-card span,
.pool-summary-card small {
color: #8faac5;
font-size: 12px;
}
.pool-summary-card strong {
display: block;
margin-top: 6px;
overflow: hidden;
color: #f6fbff;
font-size: 22px;
font-weight: 780;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.pool-detail-table-wrap {
min-height: 0;
padding: 10px 18px 18px;
overflow: auto;
}
.pool-detail-table {
width: 100%;
min-width: 720px;
border-collapse: collapse;
}
.pool-detail-table th,
.pool-detail-table td {
height: 34px;
padding: 0 12px;
border-bottom: 1px solid rgba(108, 157, 196, 0.22);
border-right: 1px solid rgba(108, 157, 196, 0.15);
color: #cfe3f7;
font-weight: 620;
text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.pool-detail-table th {
background: rgba(128, 171, 211, 0.08);
color: #9eb7ce;
font-size: 12px;
font-weight: 720;
}
.pool-detail-table th:nth-child(2),
.pool-detail-table td:nth-child(2) {
text-align: left;
}
.pool-detail-table th:first-child,
.pool-detail-table td:first-child {
width: 42px;
text-align: center;
}
.rank-row b,
.bar-row b,
.game-row b,
@ -345,3 +787,25 @@
margin-inline: 54px;
background: linear-gradient(180deg, rgba(32, 203, 161, 0.94), rgba(18, 168, 137, 0.92));
}
.skeleton-funnel-stage {
height: 37px;
clip-path: polygon(4% 0, 96% 0, 88% 100%, 12% 100%);
}
.skeleton-funnel-stage--2 {
margin-inline: 18px;
}
.skeleton-funnel-stage--3 {
margin-inline: 36px;
}
.skeleton-funnel-stage--4 {
margin-inline: 54px;
}
.skeleton-mini-stat {
height: 72px;
border-radius: 8px;
}

View File

@ -1,7 +1,5 @@
@media (max-width: 1440px) {
.metric-card {
grid-template-columns: 34px 1fr;
gap: 10px;
padding: 12px;
}
@ -135,7 +133,6 @@
}
.metric-card {
grid-template-columns: 36px 1fr;
padding-inline: 12px;
}

View File

@ -29,6 +29,10 @@
font-weight: 720;
}
.country-table th.is-sortable {
padding: 0;
}
.country-table th:nth-child(2),
.country-table td:nth-child(2) {
text-align: left;
@ -48,6 +52,44 @@
text-align: center;
}
.sort-header-button {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
width: 100%;
height: 25px;
padding: 0 12px;
border: 0;
background: transparent;
color: inherit;
font: inherit;
line-height: 1;
text-align: right;
cursor: pointer;
}
.sort-header-button--left {
justify-content: flex-start;
text-align: left;
}
.sort-header-button:hover,
.sort-header-button.is-active {
color: #e9f6ff;
}
.sort-indicator {
width: 10px;
color: #6f8ea8;
font-size: 10px;
text-align: center;
}
.sort-header-button.is-active .sort-indicator {
color: #25d8f5;
}
.table-tabs {
display: inline-flex;
gap: 3px;
@ -73,3 +115,21 @@
color: #e9f6ff;
box-shadow: inset 0 0 0 1px rgba(37, 216, 245, 0.38);
}
.table-skeleton-row td,
.table-empty-row td {
border-right: 0;
text-align: center;
}
.skeleton-table-line {
width: 100%;
height: 15px;
border-radius: 3px;
}
.table-empty-row td {
height: 76px;
color: #7f9bb7;
font-size: 13px;
}

View File

@ -1,8 +1,54 @@
export function lastDaysRange(days) {
const end = new Date();
const start = new Date(end);
start.setDate(end.getDate() - days + 1);
return { end: dateInputValue(end), start: dateInputValue(start) };
export const DEFAULT_TIME_ZONE = "Asia/Shanghai";
export const TIME_ZONE_STORAGE_KEY = "hyapp-admin.display-time-zone";
export const TIME_ZONE_OPTIONS = [
{ label: "北京时间", value: "Asia/Shanghai" },
{ label: "UTC", value: "UTC" }
];
const dateFormatterCache = new Map();
const dateTimeFormatterCache = new Map();
export function todayRange(timeZone = DEFAULT_TIME_ZONE) {
const today = dateInputValueInTimeZone(new Date(), timeZone);
return { end: today, endTime: "23:59:59", start: today, startTime: "00:00:00" };
}
export function lastDaysRange(days, timeZone = DEFAULT_TIME_ZONE) {
const end = dateInputValueInTimeZone(new Date(), timeZone);
const start = shiftDateValue(end, 1 - Number(days || 1));
return { end, endTime: "23:59:59", start, startTime: "00:00:00" };
}
export function thisMonthRange(timeZone = DEFAULT_TIME_ZONE) {
const end = dateInputValueInTimeZone(new Date(), timeZone);
const [year, month] = end.split("-");
return { end, endTime: "23:59:59", start: `${year}-${month}-01`, startTime: "00:00:00" };
}
export function normalizeTimeZone(value) {
return TIME_ZONE_OPTIONS.some((option) => option.value === value) ? value : DEFAULT_TIME_ZONE;
}
export function getTimeZoneLabel(value) {
return TIME_ZONE_OPTIONS.find((option) => option.value === value)?.label || "北京时间";
}
export function readStoredTimeZone(storage = globalThis.localStorage) {
try {
return normalizeTimeZone(storage?.getItem(TIME_ZONE_STORAGE_KEY));
} catch {
return DEFAULT_TIME_ZONE;
}
}
export function writeStoredTimeZone(value, storage = globalThis.localStorage) {
const normalized = normalizeTimeZone(value);
try {
storage?.setItem(TIME_ZONE_STORAGE_KEY, normalized);
} catch {
// 偏好写入失败不能影响本次大屏筛选。
}
return normalized;
}
export function startOfDay(value) {
@ -13,13 +59,89 @@ export function endOfDay(value) {
return value ? new Date(`${value}T23:59:59`).getTime() : "";
}
export function formatDateTime(value) {
const date = new Date(Number(value) || Date.now());
return `${dateInputValue(date)} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
export function rangeEndMs(range, timeZone = DEFAULT_TIME_ZONE) {
return dateTimeMs(range?.end, range?.endTime || "23:59:59", timeZone);
}
function dateInputValue(date) {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
export function rangeStartMs(range, timeZone = DEFAULT_TIME_ZONE) {
return dateTimeMs(range?.start, range?.startTime || "00:00:00", timeZone);
}
export function formatDateTime(value, timeZone = DEFAULT_TIME_ZONE) {
const millis = Number(value) || Date.now();
const parts = Object.fromEntries(
getDateTimeFormatter(normalizeTimeZone(timeZone))
.formatToParts(millis)
.filter((part) => part.type !== "literal")
.map((part) => [part.type, part.value])
);
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`;
}
export function sameRange(left, right) {
return left?.start === right?.start && left?.end === right?.end && normalizeTime(left?.startTime || "00:00:00") === normalizeTime(right?.startTime || "00:00:00") && normalizeTime(left?.endTime || "23:59:59") === normalizeTime(right?.endTime || "23:59:59");
}
function dateTimeMs(dateValue, timeValue, timeZone) {
if (!dateValue) {
return "";
}
const suffix = normalizeTimeZone(timeZone) === "UTC" ? "Z" : "+08:00";
return Date.parse(`${dateValue}T${normalizeTime(timeValue)}${suffix}`);
}
function normalizeTime(value) {
const parts = String(value || "00:00:00").split(":");
return `${pad2(parts[0] || 0)}:${pad2(parts[1] || 0)}:${pad2(parts[2] || 0)}`;
}
function dateInputValueInTimeZone(date, timeZone) {
const parts = Object.fromEntries(
getDateFormatter(normalizeTimeZone(timeZone))
.formatToParts(date)
.filter((part) => part.type !== "literal")
.map((part) => [part.type, part.value])
);
return `${parts.year}-${parts.month}-${parts.day}`;
}
function shiftDateValue(value, days) {
const [year, month, day] = String(value).split("-").map(Number);
const date = new Date(Date.UTC(year || 1970, (month || 1) - 1, (day || 1) + days));
return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}`;
}
function getDateFormatter(timeZone) {
if (!dateFormatterCache.has(timeZone)) {
dateFormatterCache.set(
timeZone,
new Intl.DateTimeFormat("en-CA", {
day: "2-digit",
month: "2-digit",
timeZone,
year: "numeric"
})
);
}
return dateFormatterCache.get(timeZone);
}
function getDateTimeFormatter(timeZone) {
if (!dateTimeFormatterCache.has(timeZone)) {
dateTimeFormatterCache.set(
timeZone,
new Intl.DateTimeFormat("en-CA", {
day: "2-digit",
hour: "2-digit",
hourCycle: "h23",
minute: "2-digit",
month: "2-digit",
timeZone,
year: "numeric"
})
);
}
return dateTimeFormatterCache.get(timeZone);
}
function pad2(value) {

View File

@ -0,0 +1,16 @@
import { expect, test } from "vitest";
import { rangeEndMs, rangeStartMs } from "./time.js";
test("converts the selected date range with UTC boundaries", () => {
const range = { end: "2026-06-06", endTime: "23:59:59", start: "2026-06-06", startTime: "00:00:00" };
expect(rangeStartMs(range, "UTC")).toBe(Date.UTC(2026, 5, 6, 0, 0, 0));
expect(rangeEndMs(range, "UTC")).toBe(Date.UTC(2026, 5, 6, 23, 59, 59));
});
test("converts the selected date range with Beijing time boundaries", () => {
const range = { end: "2026-06-06", endTime: "23:59:59", start: "2026-06-06", startTime: "00:00:00" };
expect(rangeStartMs(range, "Asia/Shanghai")).toBe(Date.UTC(2026, 5, 5, 16, 0, 0));
expect(rangeEndMs(range, "Asia/Shanghai")).toBe(Date.UTC(2026, 5, 6, 15, 59, 59));
});

View File

@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { getMenus } from "@/features/menus/api";
@ -11,6 +11,7 @@ import {
mapBackendMenus,
mergeNavigationItems
} from "@/app/navigation/menu.js";
import { recordSecondLevelMenuVisit } from "@/app/navigation/menuUsage.js";
import { Header } from "./Header.jsx";
import { Sidebar } from "./Sidebar.jsx";
@ -18,7 +19,7 @@ export function AdminLayout() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const { can } = useAuth();
const { can, user } = useAuth();
const { refresh } = useRefreshSignal();
const { data: backendMenus } = useAdminQuery(() => getMenus(), {
errorMessage: "加载菜单失败",
@ -35,6 +36,10 @@ export function AdminLayout() {
}, [backendMenus, can]);
const activeNav = useMemo(() => findNavItemByPath(location.pathname, menus), [location.pathname, menus]);
useEffect(() => {
recordSecondLevelMenuVisit({ menus, pathname: location.pathname, user });
}, [location.pathname, menus, user]);
return (
<div className={`app-shell ${sidebarCollapsed ? "app-shell--sidebar-collapsed" : ""}`}>
<Header
@ -50,7 +55,7 @@ export function AdminLayout() {
<main className="workspace">
<div className="page-transition" key={location.pathname}>
<Outlet />
<Outlet context={{ menus }} />
</div>
</main>
</div>

View File

@ -20,12 +20,14 @@ import MapOutlined from "@mui/icons-material/MapOutlined";
import MenuOpenOutlined from "@mui/icons-material/MenuOpenOutlined";
import PublicOutlined from "@mui/icons-material/PublicOutlined";
import PushPinOutlined from "@mui/icons-material/PushPinOutlined";
import PaidOutlined from "@mui/icons-material/PaidOutlined";
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
import RedeemOutlined from "@mui/icons-material/RedeemOutlined";
import SendOutlined from "@mui/icons-material/SendOutlined";
import ShieldOutlined from "@mui/icons-material/ShieldOutlined";
import SettingsApplicationsOutlined from "@mui/icons-material/SettingsApplicationsOutlined";
import SportsEsportsOutlined from "@mui/icons-material/SportsEsportsOutlined";
import StarBorderOutlined from "@mui/icons-material/StarBorderOutlined";
import StorefrontOutlined from "@mui/icons-material/StorefrontOutlined";
import TaskAltOutlined from "@mui/icons-material/TaskAltOutlined";
import WalletOutlined from "@mui/icons-material/WalletOutlined";
@ -46,6 +48,7 @@ const iconMap = {
dashboard: DashboardOutlined,
event_available: EventAvailableOutlined,
explore: MapOutlined,
favorite: FavoriteBorderOutlined,
flag: FlagOutlined,
gift: CardGiftcardOutlined,
inventory: Inventory2Outlined,
@ -58,6 +61,7 @@ const iconMap = {
map: MapOutlined,
menu: MenuOpenOutlined,
operations: ReceiptLongOutlined,
paid: PaidOutlined,
public: PublicOutlined,
push_pin: PushPinOutlined,
receipt: ReceiptLongOutlined,
@ -67,6 +71,7 @@ const iconMap = {
settings: SettingsApplicationsOutlined,
shield: ShieldOutlined,
sports_esports: SportsEsportsOutlined,
star: StarBorderOutlined,
storefront: StorefrontOutlined,
task: TaskAltOutlined,
users: ManageAccountsOutlined,
@ -74,21 +79,25 @@ const iconMap = {
workspace_premium: WorkspacePremiumOutlined,
};
const topLevelMenuOrder = new Map(
[
"overview",
"app-users",
"host-org",
"rooms",
"app-config",
"resources",
"operations",
"payment",
"activities",
"games",
"geo",
"system",
].map((code, index) => [code, index]),
);
export const fallbackNavigation = [
routeNavItem("overview", { icon: DashboardOutlined }),
{
code: "system",
icon: SettingsApplicationsOutlined,
id: "system",
label: "后台设置",
children: [
routeNavItem("system-users", { icon: ManageAccountsOutlined }),
routeNavItem("system-roles", { icon: ShieldOutlined }),
routeNavItem("system-menus", { icon: MenuOpenOutlined }),
routeNavItem("logs-login", { icon: LoginOutlined }),
routeNavItem("logs-operations", { icon: ReceiptLongOutlined }),
],
},
{
code: "app-users",
icon: ManageAccountsOutlined,
@ -101,6 +110,22 @@ export const fallbackNavigation = [
routeNavItem("app-user-region-blocks", { icon: PublicOutlined }),
],
},
{
code: "host-org",
icon: HubOutlined,
id: "host-org",
label: "团队管理",
children: [
routeNavItem("host-org-managers", { icon: ManageAccountsOutlined }),
routeNavItem("host-org-agencies", { icon: ApartmentOutlined }),
routeNavItem("host-org-bd-leaders", { icon: ShieldOutlined }),
routeNavItem("host-org-bds", { icon: GroupsOutlined }),
routeNavItem("host-org-hosts", { icon: ManageAccountsOutlined }),
routeNavItem("host-agency-policy", { icon: WalletOutlined }),
routeNavItem("host-salary-settlement", { icon: ReceiptLongOutlined }),
routeNavItem("host-org-coin-sellers", { icon: WalletOutlined }),
],
},
{
code: "rooms",
icon: BedroomParentOutlined,
@ -146,12 +171,20 @@ export const fallbackNavigation = [
label: "运营管理",
children: [
routeNavItem("operation-coin-ledger", { icon: ReceiptLongOutlined }),
routeNavItem("operation-coin-seller-ledger", { icon: ReceiptLongOutlined }),
routeNavItem("operation-coin-adjustment", { icon: WalletOutlined }),
routeNavItem("lucky-gift", { icon: RedeemOutlined }),
routeNavItem("operation-reports", { icon: FlagOutlined }),
routeNavItem("operation-gift-diamond", { icon: DiamondOutlined }),
routeNavItem("lucky-gift", { icon: RedeemOutlined }),
],
},
{
code: "payment",
icon: WalletOutlined,
id: "payment",
label: "支付管理",
children: [routeNavItem("payment-bill-list", { icon: ReceiptLongOutlined })],
},
{
code: "activities",
icon: CampaignOutlined,
@ -161,20 +194,16 @@ export const fallbackNavigation = [
routeNavItem("daily-task-list", { icon: TaskAltOutlined }),
routeNavItem("registration-reward", { icon: CardGiftcardOutlined }),
routeNavItem("first-recharge-reward", { icon: CardGiftcardOutlined }),
routeNavItem("cumulative-recharge-reward", { icon: WorkspacePremiumOutlined }),
routeNavItem("seven-day-checkin", { icon: EventAvailableOutlined }),
routeNavItem("room-treasure", { icon: RedeemOutlined }),
routeNavItem("room-rocket", { icon: RedeemOutlined }),
routeNavItem("room-turnover-reward", { icon: PaidOutlined }),
routeNavItem("weekly-star", { icon: StarBorderOutlined }),
routeNavItem("user-leaderboard", { icon: LeaderboardOutlined }),
routeNavItem("red-packet", { icon: RedeemOutlined }),
routeNavItem("vip-config", { icon: WorkspacePremiumOutlined }),
],
},
{
code: "payment",
icon: WalletOutlined,
id: "payment",
label: "支付管理",
children: [routeNavItem("payment-bill-list", { icon: ReceiptLongOutlined })],
},
{
code: "games",
icon: SportsEsportsOutlined,
@ -193,25 +222,22 @@ export const fallbackNavigation = [
],
},
{
code: "host-org",
icon: HubOutlined,
id: "host-org",
label: "团队管理",
code: "system",
icon: SettingsApplicationsOutlined,
id: "system",
label: "后台设置",
children: [
routeNavItem("host-org-managers", { icon: ManageAccountsOutlined }),
routeNavItem("host-org-agencies", { icon: ApartmentOutlined }),
routeNavItem("host-org-bd-leaders", { icon: ShieldOutlined }),
routeNavItem("host-org-bds", { icon: GroupsOutlined }),
routeNavItem("host-org-hosts", { icon: ManageAccountsOutlined }),
routeNavItem("host-agency-policy", { icon: WalletOutlined }),
routeNavItem("host-salary-settlement", { icon: ReceiptLongOutlined }),
routeNavItem("host-org-coin-sellers", { icon: WalletOutlined }),
routeNavItem("system-users", { icon: ManageAccountsOutlined }),
routeNavItem("system-roles", { icon: ShieldOutlined }),
routeNavItem("system-menus", { icon: MenuOpenOutlined }),
routeNavItem("logs-login", { icon: LoginOutlined }),
routeNavItem("logs-operations", { icon: ReceiptLongOutlined }),
],
},
];
export function mapBackendMenus(menus = []) {
return relocateBackendMenus(menus)
return orderTopLevelMenus(relocateBackendMenus(menus))
.map((item) => {
if (deprecatedMenuCodes.has(item.code)) {
return null;
@ -237,6 +263,17 @@ function relocateBackendMenus(menus = []) {
return relocateRechargeProductsToAppConfig(relocateLogMenusToSystem(menus));
}
function orderTopLevelMenus(menus = []) {
return menus
.map((item, index) => ({ index, item }))
.sort((left, right) => {
const leftOrder = topLevelMenuOrder.get(left.item.code) ?? Number.MAX_SAFE_INTEGER;
const rightOrder = topLevelMenuOrder.get(right.item.code) ?? Number.MAX_SAFE_INTEGER;
return leftOrder === rightOrder ? left.index - right.index : leftOrder - rightOrder;
})
.map(({ item }) => item);
}
function relocateRechargeProductsToAppConfig(menus = []) {
const appConfigMenu = menus.find((item) => item.code === "app-config");
const paymentMenu = menus.find((item) => item.code === "payment");

View File

@ -2,6 +2,35 @@ import { describe, expect, test } from "vitest";
import { fallbackNavigation, filterNavigationByPermission, mapBackendMenus, mergeNavigationItems } from "./menu.js";
describe("navigation menu helpers", () => {
test("keeps requested parent menu order in fallback navigation", () => {
expect(fallbackNavigation.map((item) => item.code)).toEqual([
"overview",
"app-users",
"host-org",
"rooms",
"app-config",
"resources",
"operations",
"payment",
"activities",
"games",
"geo",
"system",
]);
});
test("normalizes requested parent menu order from backend menus", () => {
const mapped = mapBackendMenus([
{ children: [], code: "system", icon: "settings", id: "system", label: "后台设置" },
{ children: [], code: "geo", icon: "map", id: "geo", label: "地区管理" },
{ children: [], code: "host-org", icon: "network", id: "host-org", label: "团队管理" },
{ children: [], code: "app-users", icon: "users", id: "app-users", label: "用户管理" },
{ children: [], code: "overview", icon: "dashboard", id: "overview", label: "总览" },
]);
expect(mapped.map((item) => item.code)).toEqual(["overview", "app-users", "host-org", "geo", "system"]);
});
test("keeps only fallback menu items allowed by permissions", () => {
const items = filterNavigationByPermission(
fallbackNavigation,

View File

@ -0,0 +1,124 @@
import { getSelectedAppCode } from "@/shared/api/request";
const storagePrefix = "hyapp-admin.menu-usage.v1";
const maxStoredItems = 80;
const dedupeWindowMs = 1000;
export function recordSecondLevelMenuVisit({ appCode, menus = [], now = Date.now(), pathname, storage, user }) {
const safeStorage = storage || localStorageOrNull();
if (!safeStorage || !user || !pathname) {
return [];
}
const matchedItem = findSecondLevelNavItemByPath(pathname, menus);
if (!matchedItem?.path) {
return getFrequentSecondLevelMenus({ appCode, menus, storage: safeStorage, user });
}
const storageKey = menuUsageStorageKey(user, appCode);
const currentItems = readUsageItems(storageKey, safeStorage);
const previous = currentItems.find((item) => item.code === matchedItem.code);
const previousVisitAt = Number(previous?.lastVisitedAtMs || 0);
const shouldCount = previous?.path !== matchedItem.path || now - previousVisitAt > dedupeWindowMs;
const nextItem = {
code: matchedItem.code,
count: (Number(previous?.count || 0) || 0) + (shouldCount ? 1 : 0),
label: matchedItem.label,
lastVisitedAtMs: now,
parentCode: matchedItem.parentCode,
parentLabel: matchedItem.parentLabel,
path: matchedItem.path,
};
const nextItems = [nextItem, ...currentItems.filter((item) => item.code !== matchedItem.code)]
.sort(compareUsageItems)
.slice(0, maxStoredItems);
writeUsageItems(storageKey, nextItems, safeStorage);
return getFrequentSecondLevelMenus({ appCode, menus, storage: safeStorage, user });
}
export function getFrequentSecondLevelMenus({ appCode, limit = 8, menus = [], storage, user }) {
const safeStorage = storage || localStorageOrNull();
if (!safeStorage || !user) {
return [];
}
const itemByCode = new Map(flattenSecondLevelNavItems(menus).map((item) => [item.code, item]));
return readUsageItems(menuUsageStorageKey(user, appCode), safeStorage)
.sort(compareUsageItems)
.map((item) => {
const currentItem = itemByCode.get(item.code);
if (!currentItem?.path) {
return null;
}
return {
...item,
icon: currentItem.icon,
label: currentItem.label,
parentLabel: currentItem.parentLabel,
path: currentItem.path,
};
})
.filter(Boolean)
.slice(0, limit);
}
export function findSecondLevelNavItemByPath(pathname, menus = []) {
return flattenSecondLevelNavItems(menus)
.filter((item) => isActiveRoute(pathname, item.path))
.sort((left, right) => right.path.length - left.path.length)[0];
}
export function flattenSecondLevelNavItems(menus = []) {
return menus.flatMap((parent) =>
(parent.children || [])
.filter((child) => child.path)
.map((child) => ({
...child,
parentCode: parent.code,
parentLabel: parent.label,
})),
);
}
function menuUsageStorageKey(user, appCode) {
const scope = user?.id || user?.account || "anonymous";
const app = appCode || getSelectedAppCode() || "default";
return `${storagePrefix}.${app}.${scope}`;
}
function readUsageItems(storageKey, storage) {
try {
const payload = JSON.parse(storage.getItem(storageKey) || "{}");
return Array.isArray(payload.items) ? payload.items.filter((item) => item?.code && item?.path) : [];
} catch {
return [];
}
}
function writeUsageItems(storageKey, items, storage) {
storage.setItem(storageKey, JSON.stringify({ items, updatedAtMs: Date.now(), version: 1 }));
}
function compareUsageItems(left, right) {
const countDiff = Number(right.count || 0) - Number(left.count || 0);
if (countDiff !== 0) {
return countDiff;
}
return Number(right.lastVisitedAtMs || 0) - Number(left.lastVisitedAtMs || 0);
}
function isActiveRoute(activePath, itemPath) {
if (!itemPath) {
return false;
}
if (activePath === itemPath) {
return true;
}
return activePath.startsWith(`${itemPath}/`);
}
function localStorageOrNull() {
try {
return globalThis.localStorage || null;
} catch {
return null;
}
}

View File

@ -0,0 +1,69 @@
import { describe, expect, test } from "vitest";
import {
findSecondLevelNavItemByPath,
getFrequentSecondLevelMenus,
recordSecondLevelMenuVisit,
} from "@/app/navigation/menuUsage.js";
const user = { account: "admin", id: 1 };
const appCode = "lalu";
const menus = [
{ code: "overview", id: "overview", label: "系统概览", path: "/overview" },
{
code: "app-users",
id: "app-users",
label: "用户管理",
children: [
{ code: "app-user-list", id: "app-user-list", label: "用户列表", path: "/app/users" },
{ code: "app-user-login-logs", id: "app-user-login-logs", label: "登录日志", path: "/app/users/login-logs" },
],
},
{
code: "resources",
id: "resources",
label: "资源管理",
children: [{ code: "gift-list", id: "gift-list", label: "礼物列表", path: "/resources/gifts" }],
},
];
describe("menu usage cache", () => {
test("matches the best second-level menu for nested paths", () => {
expect(findSecondLevelNavItemByPath("/app/users/login-logs/123", menus)).toMatchObject({
code: "app-user-login-logs",
parentLabel: "用户管理",
});
expect(findSecondLevelNavItemByPath("/overview", menus)).toBeUndefined();
});
test("records second-level visits and returns frequent cards", () => {
const storage = memoryStorage();
recordSecondLevelMenuVisit({ appCode, menus, now: 1000, pathname: "/app/users", storage, user });
recordSecondLevelMenuVisit({ appCode, menus, now: 2500, pathname: "/resources/gifts", storage, user });
recordSecondLevelMenuVisit({ appCode, menus, now: 4000, pathname: "/app/users", storage, user });
recordSecondLevelMenuVisit({ appCode, menus, now: 4100, pathname: "/app/users", storage, user });
recordSecondLevelMenuVisit({ appCode, menus, now: 6000, pathname: "/overview", storage, user });
const items = getFrequentSecondLevelMenus({ appCode, menus, storage, user });
expect(items.map((item) => [item.code, item.count])).toEqual([
["app-user-list", 2],
["gift-list", 1],
]);
});
test("drops cached menus that are no longer visible", () => {
const storage = memoryStorage();
recordSecondLevelMenuVisit({ appCode, menus, now: 1000, pathname: "/resources/gifts", storage, user });
const visibleMenus = menus.filter((item) => item.code !== "resources");
expect(getFrequentSecondLevelMenus({ appCode, menus: visibleMenus, storage, user })).toEqual([]);
});
});
function memoryStorage() {
const values = new Map();
return {
getItem: (key) => values.get(key) || null,
removeItem: (key) => values.delete(key),
setItem: (key, value) => values.set(key, String(value)),
};
}

View File

@ -29,6 +29,7 @@ export const PERMISSIONS = {
agencyView: "agency:view",
agencyCreate: "agency:create",
agencyStatus: "agency:status",
agencyDelete: "agency:delete",
bdView: "bd:view",
bdCreate: "bd:create",
bdUpdate: "bd:update",
@ -36,7 +37,9 @@ export const PERMISSIONS = {
coinSellerCreate: "coin-seller:create",
coinSellerUpdate: "coin-seller:update",
coinSellerStockCredit: "coin-seller:stock-credit",
coinSellerExchangeRate: "coin-seller:exchange-rate",
coinLedgerView: "coin-ledger:view",
coinSellerLedgerView: "coin-seller-ledger:view",
coinAdjustmentView: "coin-adjustment:view",
coinAdjustmentCreate: "coin-adjustment:create",
reportView: "report:view",
@ -121,18 +124,27 @@ export const PERMISSIONS = {
registrationRewardUpdate: "registration-reward:update",
firstRechargeRewardView: "first-recharge-reward:view",
firstRechargeRewardUpdate: "first-recharge-reward:update",
cumulativeRechargeRewardView: "cumulative-recharge-reward:view",
cumulativeRechargeRewardUpdate: "cumulative-recharge-reward:update",
sevenDayCheckInView: "seven-day-checkin:view",
sevenDayCheckInUpdate: "seven-day-checkin:update",
luckyGiftView: "lucky-gift:view",
luckyGiftUpdate: "lucky-gift:update",
roomTreasureView: "room-treasure:view",
roomTreasureUpdate: "room-treasure:update",
roomRocketView: "room-rocket:view",
roomRocketUpdate: "room-rocket:update",
roomTurnoverRewardView: "room-turnover-reward:view",
roomTurnoverRewardUpdate: "room-turnover-reward:update",
roomTurnoverRewardRetry: "room-turnover-reward:retry",
userLeaderboardView: "user-leaderboard:view",
redPacketView: "red-packet:view",
redPacketUpdate: "red-packet:update",
vipConfigView: "vip-config:view",
vipConfigUpdate: "vip-config:update",
vipConfigGrant: "vip-config:grant",
weeklyStarView: "weekly-star:view",
weeklyStarCreate: "weekly-star:create",
weeklyStarUpdate: "weekly-star:update",
weeklyStarSettle: "weekly-star:settle",
uploadCreate: "upload:create",
} as const;
@ -173,6 +185,7 @@ export const MENU_CODES = {
emojiPackList: "emoji-pack-list",
operations: "operations",
operationCoinLedger: "operation-coin-ledger",
operationCoinSellerLedger: "operation-coin-seller-ledger",
operationCoinAdjustment: "operation-coin-adjustment",
operationReports: "operation-reports",
operationGiftDiamond: "operation-gift-diamond",
@ -181,12 +194,15 @@ export const MENU_CODES = {
dailyTaskList: "daily-task-list",
registrationReward: "registration-reward",
firstRechargeReward: "first-recharge-reward",
cumulativeRechargeReward: "cumulative-recharge-reward",
achievementConfig: "achievement-config",
sevenDayCheckIn: "seven-day-checkin",
roomTreasure: "room-treasure",
roomRocket: "room-rocket",
roomTurnoverReward: "room-turnover-reward",
userLeaderboard: "user-leaderboard",
redPacket: "red-packet",
vipConfig: "vip-config",
weeklyStar: "weekly-star",
geo: "geo",
hostOrg: "host-org",
hostOrgCountries: "host-org-countries",

View File

@ -2,6 +2,7 @@ import { authRoutes } from "@/features/auth/routes.js";
import { achievementRoutes } from "@/features/achievements/routes.js";
import { appConfigRoutes } from "@/features/app-config/routes.js";
import { appUserRoutes } from "@/features/app-users/routes.js";
import { cumulativeRechargeRewardRoutes } from "@/features/cumulative-recharge-reward/routes.js";
import { dashboardRoutes } from "@/features/dashboard/routes.js";
import { dailyTaskRoutes } from "@/features/daily-tasks/routes.js";
import { firstRechargeRewardRoutes } from "@/features/first-recharge-reward/routes.js";
@ -20,11 +21,13 @@ import { regionBlockRoutes } from "@/features/region-blocks/routes.js";
import { resourceRoutes } from "@/features/resources/routes.js";
import { rolesRoutes } from "@/features/roles/routes.js";
import { roomRoutes } from "@/features/rooms/routes.js";
import { roomTreasureRoutes } from "@/features/room-treasure/routes.js";
import { roomRocketRoutes } from "@/features/room-rocket/routes.js";
import { roomTurnoverRewardRoutes } from "@/features/room-turnover-reward/routes.js";
import { sevenDayCheckInRoutes } from "@/features/seven-day-checkin/routes.js";
import { userLeaderboardRoutes } from "@/features/user-leaderboard/routes.js";
import { usersRoutes } from "@/features/users/routes.js";
import { vipConfigRoutes } from "@/features/vip-config/routes.js";
import { weeklyStarRoutes } from "@/features/weekly-star/routes.js";
import type { MenuCode } from "@/app/permissions";
import type { AdminRoute, PublicRoute } from "./types";
@ -41,8 +44,11 @@ export const adminRoutes: AdminRoute[] = [
...achievementRoutes,
...registrationRewardRoutes,
...firstRechargeRewardRoutes,
...cumulativeRechargeRewardRoutes,
...sevenDayCheckInRoutes,
...roomTreasureRoutes,
...roomRocketRoutes,
...roomTurnoverRewardRoutes,
...weeklyStarRoutes,
...userLeaderboardRoutes,
...redPacketRoutes,
...vipConfigRoutes,

View File

@ -0,0 +1,35 @@
import { afterEach, expect, test, vi } from "vitest";
import { setAccessToken } from "@/shared/api/request";
import { listAppUsers } from "./api";
afterEach(() => {
setAccessToken("");
vi.unstubAllGlobals();
});
test("listAppUsers sends country region and time filters", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 50, total: 0 } })),
),
);
const result = await listAppUsers({
country: "PH",
end_ms: 2000,
page: 1,
page_size: 50,
region_id: 3,
start_ms: 1000,
});
const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(result.items).toEqual([]);
expect(String(url)).toContain("/api/v1/app/users?");
expect(String(url)).toContain("country=PH");
expect(String(url)).toContain("region_id=3");
expect(String(url)).toContain("start_ms=1000");
expect(String(url)).toContain("end_ms=2000");
expect(init?.method).toBe("GET");
});

View File

@ -35,10 +35,81 @@
align-items: center;
justify-content: space-between;
gap: var(--space-4);
flex: 0 0 auto;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border);
}
.toolbarLeft {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
.selectedMeta {
color: var(--text-secondary);
font-size: var(--admin-font-size);
font-weight: 650;
}
.timeFilter {
flex: 0 1 280px;
min-width: 220px;
}
.selectColumn {
display: flex;
align-items: center;
justify-content: center;
overflow: visible;
padding-right: var(--space-2) !important;
padding-left: var(--space-2) !important;
}
.selectColumn :global(.admin-cell__head-content) {
justify-content: center;
}
.sortHeader {
display: inline-flex;
max-width: 100%;
min-width: 0;
align-items: center;
gap: var(--space-1);
padding: 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
font-weight: inherit;
}
.sortHeader:hover,
.sortHeaderActive {
color: var(--text-primary);
}
.sortHeader span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sortIcon {
flex: 0 0 auto;
color: var(--text-tertiary);
font-size: 18px;
}
.sortHeaderActive .sortIcon {
color: var(--primary);
}
.filters {
display: flex;
min-width: 0;
@ -187,13 +258,15 @@
@media (max-width: 760px) {
.toolbar,
.toolbarLeft,
.filters {
align-items: stretch;
flex-direction: column;
}
.search,
.statusSelect {
.statusSelect,
.timeFilter {
flex: 1 1 auto;
width: 100%;
}

View File

@ -1,6 +1,7 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { banAppUser, listAppUsers, setAppUserPassword, unbanAppUser, updateAppUser } from "@/features/app-users/api";
@ -19,8 +20,14 @@ export function useAppUsersPage() {
const confirm = useConfirm();
const { showToast } = useToast();
const { countryOptions, loadingCountries } = useCountryOptions();
const { loadingRegions, regionOptions } = useRegionOptions();
const [country, setCountry] = useState("");
const [query, setQuery] = useState("");
const [regionId, setRegionId] = useState("");
const [status, setStatus] = useState("");
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
const [sortBy, setSortBy] = useState("created_at");
const [sortDirection, setSortDirection] = useState("desc");
const [page, setPage] = useState(1);
const [activeAction, setActiveAction] = useState("");
const [activeUser, setActiveUser] = useState(null);
@ -28,15 +35,23 @@ export function useAppUsersPage() {
const [countryForm, setCountryForm] = useState(emptyCountryForm);
const [passwordForm, setPasswordForm] = useState(emptyPasswordForm);
const [loadingAction, setLoadingAction] = useState("");
const [selectedUserIds, setSelectedUserIds] = useState([]);
const [patchedUsers, setPatchedUsers] = useState({});
const filters = useMemo(
() => ({
country,
keyword: query,
region_id: regionId,
sort_by: sortBy,
sort_direction: sortDirection,
end_ms: timeRange.endMs,
status,
start_ms: timeRange.startMs,
}),
[query, status],
[country, query, regionId, sortBy, sortDirection, status, timeRange.endMs, timeRange.startMs],
);
const {
data = emptyData,
data: queryData = emptyData,
error,
loading,
reload,
@ -48,6 +63,35 @@ export function useAppUsersPage() {
pageSize,
queryKey: ["app-users", filters, page],
});
const data = useMemo(() => {
const items = queryData.items || [];
return {
...queryData,
items: items.map((user) => (patchedUsers[user.userId] ? { ...user, ...patchedUsers[user.userId] } : user)),
};
}, [patchedUsers, queryData]);
useEffect(() => {
setSelectedUserIds([]);
setPatchedUsers({});
}, [country, page, query, regionId, sortBy, sortDirection, status, timeRange.endMs, timeRange.startMs]);
const locationFilterOptions = useMemo(() => {
const countryItems = countryOptions.map((option) => ({
label: `国家 · ${option.label}`,
value: `country:${option.value}`,
}));
const regionItems = [
{ label: "区域 · GLOBAL · 0", value: "region:0" },
...regionOptions.map((option) => ({
label: `区域 · ${option.label}`,
value: `region:${option.value}`,
})),
];
return [...countryItems, ...regionItems];
}, [countryOptions, regionOptions]);
const locationFilterValue = country ? `country:${country}` : regionId !== "" ? `region:${regionId}` : "";
const changeQuery = (value) => {
setQuery(value);
@ -59,9 +103,38 @@ export function useAppUsersPage() {
setPage(1);
};
const changeLocationFilter = (value) => {
const [kind, rawValue = ""] = String(value || "").split(":");
if (kind === "country" && rawValue) {
setCountry(rawValue);
setRegionId("");
} else if (kind === "region") {
setCountry("");
setRegionId(rawValue);
} else {
setCountry("");
setRegionId("");
}
setPage(1);
};
const changeTimeRange = (value) => {
setTimeRange(value);
setPage(1);
};
const changeSort = (field) => {
setSortDirection((current) => (sortBy === field ? (current === "asc" ? "desc" : "asc") : "desc"));
setSortBy(field);
setPage(1);
};
const resetFilters = () => {
setCountry("");
setQuery("");
setRegionId("");
setStatus("");
setTimeRange({ endMs: "", startMs: "" });
setPage(1);
};
@ -132,23 +205,58 @@ export function useAppUsersPage() {
const toggleBan = async (user) => {
const banned = isBanned(user);
await runAction(`status-${user.userId}`, banned ? "用户已解封" : "用户已封禁", async () => {
const result = banned ? await unbanAppUser(user.userId) : await banAppUser(user.userId);
patchUsers([mergeUserStatus(user, result, banned ? "active" : "banned")]);
setSelectedUserIds((current) => current.filter((userId) => userId !== user.userId));
});
};
const batchBan = async () => {
const users = (data.items || []).filter((user) => selectedUserIds.includes(user.userId) && !isBanned(user));
if (!users.length) {
showToast("请先选择未封禁用户", "warning");
return;
}
const ok = await confirm({
confirmText: banned ? "解封" : "封禁",
message: `${user.username || user.displayUserId || user.userId} 的状态会立即变更。`,
title: banned ? "解封用户" : "封禁用户",
tone: banned ? "primary" : "danger",
confirmText: "封禁",
message: `将封禁 ${users.length} 个用户`,
title: "批量封禁用户",
tone: "danger",
});
if (!ok) {
return;
}
await runAction(`status-${user.userId}`, banned ? "用户已解封" : "用户已封禁", async () => {
if (banned) {
await unbanAppUser(user.userId);
} else {
await banAppUser(user.userId);
setLoadingAction("batch-ban");
try {
const results = await Promise.allSettled(users.map((user) => banAppUser(user.userId)));
const updatedUsers = results
.map((result, index) =>
result.status === "fulfilled" ? mergeUserStatus(users[index], result.value, "banned") : null,
)
.filter(Boolean);
const failedCount = results.length - updatedUsers.length;
if (updatedUsers.length) {
patchUsers(updatedUsers);
setSelectedUserIds((current) =>
current.filter((userId) => !updatedUsers.some((updatedUser) => updatedUser.userId === userId)),
);
}
await reload();
});
if (failedCount > 0) {
showToast(
updatedUsers.length ? `已封禁 ${updatedUsers.length} 个,${failedCount} 个失败` : "批量封禁失败",
updatedUsers.length ? "warning" : "error",
);
return;
}
showToast(`已封禁 ${updatedUsers.length} 个用户`, "success");
} finally {
setLoadingAction("");
}
};
const runAction = async (action, successMessage, submitter) => {
@ -163,13 +271,28 @@ export function useAppUsersPage() {
}
};
const patchUsers = (users) => {
setPatchedUsers((current) => {
const next = { ...current };
users.forEach((user) => {
if (!user?.userId) {
return;
}
next[user.userId] = { ...(next[user.userId] || {}), ...user };
});
return next;
});
};
return {
abilities,
activeAction,
activeUser,
batchBan,
changeQuery,
changeStatus,
closeAction,
changeLocationFilter,
countryForm,
countryOptions,
data,
@ -178,19 +301,31 @@ export function useAppUsersPage() {
loading,
loadingAction,
loadingCountries,
loadingRegions,
locationFilterOptions,
locationFilterValue,
openCountry,
openEdit,
openPassword,
page,
passwordForm,
query,
regionId,
regionOptions,
reload,
resetFilters,
selectedUserIds,
setCountryForm,
setEditForm,
setPage,
setPasswordForm,
setSelectedUserIds,
sortBy,
sortDirection,
status,
timeRange,
changeSort,
changeTimeRange,
submitCountry,
submitEdit,
submitPassword,
@ -201,3 +336,11 @@ export function useAppUsersPage() {
function isBanned(user) {
return user.status === "banned" || user.status === "disabled";
}
function mergeUserStatus(currentUser, result, fallbackStatus) {
return {
...currentUser,
...(result || {}),
status: result?.status || fallbackStatus,
};
}

View File

@ -1,17 +1,23 @@
import BlockOutlined from "@mui/icons-material/BlockOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
import KeyboardArrowUpOutlined from "@mui/icons-material/KeyboardArrowUpOutlined";
import LockOpenOutlined from "@mui/icons-material/LockOpenOutlined";
import PasswordOutlined from "@mui/icons-material/PasswordOutlined";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import UnfoldMoreOutlined from "@mui/icons-material/UnfoldMoreOutlined";
import Autocomplete from "@mui/material/Autocomplete";
import Checkbox from "@mui/material/Checkbox";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
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 { 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";
@ -25,31 +31,59 @@ const columns = [
width: "minmax(240px, 1.5fr)",
render: (user) => <UserIdentity user={user} />,
},
{ key: "coin", label: "金币", width: "minmax(90px, 0.6fr)", render: (user) => formatNumber(user.coin) },
{
key: "status",
label: "状态",
width: "minmax(92px, 0.6fr)",
render: (user) => <UserStatus status={user.status} />,
},
{
key: "time",
label: "创建 / 活跃",
width: "minmax(180px, 1fr)",
render: (user) => (
<div className={styles.stack}>
<span>{formatMillis(user.createdAtMs)}</span>
<span className={styles.meta}>{formatMillis(user.lastActiveAtMs)}</span>
</div>
),
},
];
export function AppUserListPage() {
const page = useAppUsersPage();
const items = page.data.items || [];
const total = page.data.total || 0;
const selectableItems = items.filter((user) => !isBannedUser(user));
const allChecked =
selectableItems.length > 0 && selectableItems.every((user) => page.selectedUserIds.includes(user.userId));
const selectedOnPageCount = selectableItems.filter((user) => page.selectedUserIds.includes(user.userId)).length;
const someChecked = selectedOnPageCount > 0 && !allChecked;
const toggleAll = (checked) => {
if (!checked) {
page.setSelectedUserIds((current) =>
current.filter((userId) => !selectableItems.some((user) => user.userId === userId)),
);
return;
}
page.setSelectedUserIds((current) => Array.from(new Set([...current, ...selectableItems.map((user) => user.userId)])));
};
const tableColumns = [
{
key: "select",
className: styles.selectColumn,
header: (
<Checkbox
checked={allChecked}
disabled={!page.abilities.canStatus || selectableItems.length === 0}
indeterminate={someChecked}
size="small"
onChange={(event) => toggleAll(event.target.checked)}
/>
),
resizable: false,
width: "64px",
render: (user) => {
const banned = isBannedUser(user);
return (
<Checkbox
checked={page.selectedUserIds.includes(user.userId)}
disabled={!page.abilities.canStatus || banned || page.loadingAction === "batch-ban"}
size="small"
onChange={(event) => {
page.setSelectedUserIds((current) =>
event.target.checked
? Array.from(new Set([...current, user.userId]))
: current.filter((userId) => userId !== user.userId),
);
}}
/>
);
},
},
{
...columns[0],
filter: createTextColumnFilter({
@ -62,21 +96,63 @@ export function AppUserListPage() {
key: "location",
label: "国家 / 区域",
width: "minmax(170px, 1fr)",
filter: createOptionsColumnFilter({
emptyLabel: "全部国家 / 区域",
loading: page.loadingCountries || page.loadingRegions,
options: page.locationFilterOptions,
placeholder: "搜索国家或区域",
value: page.locationFilterValue,
onChange: page.changeLocationFilter,
}),
render: (user) => <UserLocation page={page} user={user} />,
},
...columns.slice(1).map((column) =>
column.key === "status"
? {
...column,
filter: createOptionsColumnFilter({
options: appUserStatusFilters,
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
}
: column,
),
{
key: "coin",
header: (
<SortHeader
field="coin"
label="金币"
sortBy={page.sortBy}
sortDirection={page.sortDirection}
onClick={page.changeSort}
/>
),
label: "金币",
width: "minmax(90px, 0.6fr)",
render: (user) => formatNumber(user.coin),
},
{
key: "status",
label: "状态",
width: "minmax(92px, 0.6fr)",
filter: createOptionsColumnFilter({
options: appUserStatusFilters,
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
render: (user) => <UserStatus status={user.status} />,
},
{
key: "time",
header: (
<SortHeader
field="created_at"
label="创建 / 活跃"
sortBy={page.sortBy}
sortDirection={page.sortDirection}
onClick={page.changeSort}
/>
),
label: "创建 / 活跃",
width: "minmax(180px, 1fr)",
render: (user) => (
<div className={styles.stack}>
<span>{formatMillis(user.createdAtMs)}</span>
<span className={styles.meta}>{formatMillis(user.lastActiveAtMs)}</span>
</div>
),
},
{
key: "actions",
label: "操作",
@ -90,6 +166,29 @@ export function AppUserListPage() {
<div className={styles.contentPanel}>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<div className={styles.listBlock}>
<div className={styles.toolbar}>
<div className={styles.toolbarLeft}>
{page.abilities.canStatus ? (
<span className={styles.selectedMeta}>已选 {page.selectedUserIds.length} </span>
) : null}
<TimeRangeFilter
className={styles.timeFilter}
label="创建时间"
value={page.timeRange}
onChange={page.changeTimeRange}
/>
</div>
{page.abilities.canStatus ? (
<Button
disabled={!page.selectedUserIds.length || page.loadingAction === "batch-ban"}
startIcon={<BlockOutlined fontSize="small" />}
variant="danger"
onClick={page.batchBan}
>
批量封禁
</Button>
) : null}
</div>
<DataTable
columns={tableColumns}
items={items}
@ -276,6 +375,27 @@ function UserStatus({ status }) {
);
}
function SortHeader({ field, label, onClick, sortBy, sortDirection }) {
const active = sortBy === field;
const nextDirection = active && sortDirection === "desc" ? "asc" : "desc";
const Icon = !active
? UnfoldMoreOutlined
: sortDirection === "asc"
? KeyboardArrowUpOutlined
: KeyboardArrowDownOutlined;
return (
<button
aria-label={`${label}${nextDirection === "asc" ? "正序" : "倒序"}排序`}
className={[styles.sortHeader, active ? styles.sortHeaderActive : ""].filter(Boolean).join(" ")}
type="button"
onClick={() => onClick(field)}
>
<span>{label}</span>
<Icon className={styles.sortIcon} fontSize="inherit" />
</button>
);
}
function ActionModal({ children, disabled, loading, onClose, onSubmit, open, sectionTitle = "基本信息", title }) {
return (
<AdminFormDialog
@ -310,3 +430,7 @@ function formatNumber(value) {
function formatCountry(user) {
return user.countryDisplayName || user.countryName || user.country || "-";
}
function isBannedUser(user) {
return user.status === "banned" || user.status === "disabled";
}

View File

@ -0,0 +1,176 @@
import { apiRequest } from "@/shared/api/request";
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import type { ApiPage, PageQuery } from "@/shared/api/types";
export interface CumulativeRechargeRewardTierDto {
tierId: number;
tierCode: string;
tierName: string;
thresholdUsdMinor: number;
resourceGroupId: number;
status: string;
sortOrder: number;
createdAtMs?: number;
updatedAtMs?: number;
}
export interface CumulativeRechargeRewardConfigDto {
appCode?: string;
enabled: boolean;
tiers: CumulativeRechargeRewardTierDto[];
updatedByAdminId?: number;
createdAtMs?: number;
updatedAtMs?: number;
}
export interface CumulativeRechargeRewardConfigPayload {
enabled: boolean;
tiers: Array<{
tier_id?: number;
tier_code: string;
tier_name: string;
threshold_usd_minor: number;
resource_group_id: number;
status: string;
sort_order: number;
}>;
}
export interface CumulativeRechargeRewardGrantUserDto {
userId?: number;
displayUserId?: string;
username?: string;
avatar?: string;
}
export interface CumulativeRechargeRewardGrantDto {
grantId: string;
appCode?: string;
cycleKey?: string;
eventId?: string;
transactionId?: string;
commandId?: string;
userId: number;
user?: CumulativeRechargeRewardGrantUserDto;
tierId?: number;
tierCode?: string;
resourceGroupId?: number;
thresholdUsdMinor?: number;
reachedUsdMinor?: number;
qualifyingUsdMinor?: number;
rechargeCoinAmount?: number;
rechargeType?: string;
status?: string;
walletCommandId?: string;
walletGrantId?: string;
failureReason?: string;
grantedAtMs?: number;
createdAtMs?: number;
updatedAtMs?: number;
}
type RawConfig = CumulativeRechargeRewardConfigDto & Record<string, unknown>;
type RawTier = CumulativeRechargeRewardTierDto & Record<string, unknown>;
type RawGrant = CumulativeRechargeRewardGrantDto & Record<string, unknown>;
export function getCumulativeRechargeRewardConfig(): Promise<CumulativeRechargeRewardConfigDto> {
const endpoint = API_ENDPOINTS.getCumulativeRechargeRewardConfig;
return apiRequest<RawConfig>(apiEndpointPath(API_OPERATIONS.getCumulativeRechargeRewardConfig), {
method: endpoint.method,
}).then(normalizeConfig);
}
export function updateCumulativeRechargeRewardConfig(
payload: CumulativeRechargeRewardConfigPayload,
): Promise<CumulativeRechargeRewardConfigDto> {
const endpoint = API_ENDPOINTS.updateCumulativeRechargeRewardConfig;
return apiRequest<RawConfig, CumulativeRechargeRewardConfigPayload>(
apiEndpointPath(API_OPERATIONS.updateCumulativeRechargeRewardConfig),
{
body: payload,
method: endpoint.method,
},
).then(normalizeConfig);
}
export function listCumulativeRechargeRewardGrants(
query: PageQuery = {},
): Promise<ApiPage<CumulativeRechargeRewardGrantDto>> {
const endpoint = API_ENDPOINTS.listCumulativeRechargeRewardGrants;
return apiRequest<ApiPage<RawGrant>>(apiEndpointPath(API_OPERATIONS.listCumulativeRechargeRewardGrants), {
method: endpoint.method,
query,
}).then((page) => ({
...page,
items: (page.items || []).map(normalizeGrant),
}));
}
function normalizeConfig(item: RawConfig): CumulativeRechargeRewardConfigDto {
const rawTiers = Array.isArray(item.tiers) ? (item.tiers as RawTier[]) : [];
return {
appCode: stringValue(item.appCode ?? item.app_code),
enabled: Boolean(item.enabled),
tiers: rawTiers.map(normalizeTier),
updatedByAdminId: numberValue(item.updatedByAdminId ?? item.updated_by_admin_id),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
};
}
function normalizeTier(item: RawTier): CumulativeRechargeRewardTierDto {
return {
tierId: numberValue(item.tierId ?? item.tier_id),
tierCode: stringValue(item.tierCode ?? item.tier_code),
tierName: stringValue(item.tierName ?? item.tier_name),
thresholdUsdMinor: numberValue(item.thresholdUsdMinor ?? item.threshold_usd_minor),
resourceGroupId: numberValue(item.resourceGroupId ?? item.resource_group_id),
status: stringValue(item.status) || "active",
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
};
}
function normalizeGrant(item: RawGrant): CumulativeRechargeRewardGrantDto {
const rawUser = (item.user || {}) as Record<string, unknown>;
return {
grantId: stringValue(item.grantId ?? item.grant_id),
appCode: stringValue(item.appCode ?? item.app_code),
cycleKey: stringValue(item.cycleKey ?? item.cycle_key),
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),
user: {
userId: numberValue(rawUser.userId ?? rawUser.user_id),
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
username: stringValue(rawUser.username),
avatar: stringValue(rawUser.avatar),
},
tierId: numberValue(item.tierId ?? item.tier_id),
tierCode: stringValue(item.tierCode ?? item.tier_code),
resourceGroupId: numberValue(item.resourceGroupId ?? item.resource_group_id),
thresholdUsdMinor: numberValue(item.thresholdUsdMinor ?? item.threshold_usd_minor),
reachedUsdMinor: numberValue(item.reachedUsdMinor ?? item.reached_usd_minor),
qualifyingUsdMinor: numberValue(item.qualifyingUsdMinor ?? item.qualifying_usd_minor),
rechargeCoinAmount: numberValue(item.rechargeCoinAmount ?? item.recharge_coin_amount),
rechargeType: stringValue(item.rechargeType ?? item.recharge_type),
status: stringValue(item.status),
walletCommandId: stringValue(item.walletCommandId ?? item.wallet_command_id),
walletGrantId: stringValue(item.walletGrantId ?? item.wallet_grant_id),
failureReason: stringValue(item.failureReason ?? item.failure_reason),
grantedAtMs: numberValue(item.grantedAtMs ?? item.granted_at_ms),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
};
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
}
function numberValue(value: unknown) {
const number = Number(value || 0);
return Number.isFinite(number) ? number : 0;
}

View File

@ -0,0 +1,162 @@
import AddOutlined from "@mui/icons-material/AddOutlined";
import SaveOutlined from "@mui/icons-material/SaveOutlined";
import Drawer from "@mui/material/Drawer";
import TextField from "@mui/material/TextField";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { RewardTierEditorTable } from "@/shared/ui/RewardTierEditorTable.jsx";
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
import styles from "@/features/cumulative-recharge-reward/cumulative-recharge-reward.module.css";
export function CumulativeRechargeRewardConfigDrawer({
abilities,
configLoading,
configSaving,
form,
onClose,
onSubmit,
open,
resourceGroups,
setForm,
}) {
const disabled = !abilities.canUpdate || configLoading || configSaving;
const addTier = () => {
setForm((current) => ({
...current,
tiers: [
...(current.tiers || []),
{
tierId: 0,
tierCode: `cumulative_recharge_${Date.now()}_${(current.tiers || []).length + 1}`,
tierName: "",
thresholdUsd: "",
resourceGroupId: "",
status: "active",
sortOrder: String((current.tiers || []).length),
},
],
}));
};
const updateTier = (index, patch) => {
setForm((current) => ({
...current,
tiers: (current.tiers || []).map((tier, tierIndex) => (tierIndex === index ? { ...tier, ...patch } : tier)),
}));
};
const removeTier = (index) => {
setForm((current) => ({
...current,
tiers: (current.tiers || []).filter((_, tierIndex) => tierIndex !== index),
}));
};
return (
<Drawer anchor="right" open={open} onClose={configSaving ? undefined : onClose}>
<form className="form-drawer form-drawer--activity-config" onSubmit={onSubmit}>
<h2>累充奖励配置</h2>
<div className="form-drawer__content">
<section className="form-drawer__section">
<div className="form-drawer__section-title">发放状态</div>
<AdminSwitch
checked={form.enabled}
checkedLabel="开启"
disabled={disabled}
label="累充奖励状态"
uncheckedLabel="关闭"
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
/>
</section>
<section className="form-drawer__section">
<div className={styles.drawerSectionTitle}>
<span>奖励档位</span>
<Button
disabled={disabled}
startIcon={<AddOutlined fontSize="small" />}
type="button"
onClick={addTier}
>
添加档位
</Button>
</div>
<RewardTierEditorTable
columns={[
{
key: "tier",
label: "档位",
render: (_tier, index) => (
<span className="reward-tier-editor-table__index">档位 {index + 1}</span>
),
},
{
key: "thresholdUsd",
label: "累充 USD",
render: (tier, index) => (
<TextField
disabled={disabled}
inputProps={{ "aria-label": "累充 USD", min: 0.01, step: 0.01 }}
placeholder="100.00"
type="number"
value={tier.thresholdUsd}
onChange={(event) => updateTier(index, { thresholdUsd: event.target.value })}
/>
),
},
{
key: "resourceGroupId",
label: "奖励资源组",
render: (tier, index) => (
<ResourceGroupSelectField
disabled={disabled}
drawerTitle="选择累充奖励资源组"
groups={resourceGroups}
label="奖励资源组"
value={tier.resourceGroupId}
onChange={(value) => updateTier(index, { resourceGroupId: value })}
/>
),
},
{
key: "status",
label: "状态",
render: (tier, index) => (
<AdminSwitch
checked={tier.status !== "inactive"}
checkedLabel="启用"
disabled={disabled}
label={`档位 ${index + 1} 状态`}
uncheckedLabel="停用"
onChange={(event) =>
updateTier(index, { status: event.target.checked ? "active" : "inactive" })
}
/>
),
},
]}
disabled={disabled}
emptyText="暂无档位"
gridTemplateColumns="72px minmax(140px, 0.85fr) minmax(260px, 1.5fr) 120px 52px"
rows={form.tiers || []}
onRemoveTier={removeTier}
/>
</section>
</div>
<div className="form-drawer__actions">
<Button disabled={configSaving} type="button" onClick={onClose}>
取消
</Button>
<Button
disabled={disabled}
startIcon={<SaveOutlined fontSize="small" />}
type="submit"
variant="primary"
>
保存
</Button>
</div>
</form>
</Drawer>
);
}

View File

@ -0,0 +1,97 @@
import EditOutlined from "@mui/icons-material/EditOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import { Button } from "@/shared/ui/Button.jsx";
import styles from "@/features/cumulative-recharge-reward/cumulative-recharge-reward.module.css";
export function CumulativeRechargeRewardConfigSummary({
canUpdate,
config,
configLoading,
resourceGroups,
onEdit,
onRefresh,
}) {
const tiers = config?.tiers || [];
const activeTiers = tiers.filter((tier) => tier.status === "active");
return (
<div className={styles.summaryPanel}>
<div className={styles.summaryItems}>
<SummaryItem label="状态">
<span
className={[
styles.statusBadge,
config?.enabled ? styles.statusActive : styles.statusInactive,
].join(" ")}
>
{config?.enabled ? "启用" : "停用"}
</span>
</SummaryItem>
<SummaryItem label="档位">
{activeTiers.length}/{tiers.length}
</SummaryItem>
<SummaryItem label="金额">{thresholdSummary(activeTiers)}</SummaryItem>
<SummaryItem label="资源组">{resourceGroupSummary(tiers, resourceGroups)}</SummaryItem>
</div>
<div className={styles.summaryActions}>
<Button disabled={configLoading} startIcon={<RefreshOutlined fontSize="small" />} onClick={onRefresh}>
刷新
</Button>
{canUpdate ? (
<Button
disabled={configLoading}
startIcon={<EditOutlined fontSize="small" />}
variant="primary"
onClick={onEdit}
>
编辑配置
</Button>
) : null}
</div>
</div>
);
}
function SummaryItem({ children, label }) {
return (
<div className={styles.summaryItem}>
<span className={styles.summaryLabel}>{label}</span>
<span className={styles.summaryValue}>{children}</span>
</div>
);
}
function thresholdSummary(tiers) {
if (!tiers.length) {
return "-";
}
const thresholds = tiers.map((tier) => tier.thresholdUsdMinor || 0).filter((value) => value > 0);
if (!thresholds.length) {
return "-";
}
const min = Math.min(...thresholds);
const max = Math.max(...thresholds);
return min === max ? formatUSD(min) : `${formatUSD(min)} - ${formatUSD(max)}`;
}
function resourceGroupSummary(tiers, resourceGroups) {
if (!tiers.length) {
return "-";
}
const names = tiers.slice(0, 2).map((tier) =>
groupName(
resourceGroups.find((group) => group.groupId === tier.resourceGroupId),
tier.resourceGroupId,
),
);
const suffix = tiers.length > names.length ? ` +${tiers.length - names.length}` : "";
return `${names.join("、")}${suffix}`;
}
function groupName(group, fallbackId) {
return group?.name || group?.groupCode || `资源组 #${fallbackId}`;
}
function formatUSD(value) {
return `$${(Number(value || 0) / 100).toFixed(2)}`;
}

View File

@ -0,0 +1,159 @@
.summaryPanel {
display: flex;
flex: 0 0 auto;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: var(--space-5);
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--border);
}
.summaryActions,
.summaryItems {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-3);
}
.summaryItems {
flex: 1;
gap: var(--space-7);
}
.summaryItem {
display: inline-flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
}
.summaryLabel {
flex: 0 0 auto;
color: var(--text-tertiary);
font-size: var(--admin-font-size);
font-weight: 650;
}
.summaryValue {
display: inline-flex;
min-width: 0;
align-items: center;
overflow: hidden;
color: var(--text-primary);
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.statusBadge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 52px;
height: 26px;
padding: 0 var(--space-2);
border-radius: 999px;
font-size: 12px;
font-weight: 750;
}
.statusActive {
background: var(--success-surface);
color: var(--success);
}
.statusInactive {
background: var(--fill-secondary);
color: var(--text-tertiary);
}
.identity {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-3);
}
.stack {
display: grid;
min-width: 0;
gap: 2px;
}
.name {
overflow: hidden;
color: var(--text-primary);
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
overflow: hidden;
color: var(--text-tertiary);
font-size: var(--admin-font-size);
text-overflow: ellipsis;
white-space: nowrap;
}
.drawerSectionTitle {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
color: var(--text-secondary);
font-weight: 700;
}
.tierEditorList {
display: grid;
gap: var(--space-3);
}
.tierEditor {
display: grid;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-2);
background: var(--fill-secondary);
}
.tierEditorHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
color: var(--text-primary);
font-weight: 700;
}
.emptyState {
display: flex;
min-height: 92px;
align-items: center;
justify-content: center;
border: 1px dashed var(--border-strong);
border-radius: var(--radius-2);
color: var(--text-tertiary);
}
@media (max-width: 900px) {
.summaryPanel {
align-items: flex-start;
flex-direction: column;
}
.summaryItems {
width: 100%;
align-items: flex-start;
flex-direction: column;
gap: var(--space-3);
}
.summaryActions {
justify-content: flex-start;
}
}

View File

@ -0,0 +1,190 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { listResourceGroups } from "@/features/resources/api";
import {
getCumulativeRechargeRewardConfig,
listCumulativeRechargeRewardGrants,
updateCumulativeRechargeRewardConfig,
} from "@/features/cumulative-recharge-reward/api";
import { useCumulativeRechargeRewardAbilities } from "@/features/cumulative-recharge-reward/permissions.js";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
const pageSize = 50;
const emptyGrants = { items: [], page: 1, pageSize, total: 0 };
const emptyForm = {
enabled: false,
tiers: [],
};
export function useCumulativeRechargeRewardPage() {
const abilities = useCumulativeRechargeRewardAbilities();
const { showToast } = useToast();
const [config, setConfig] = useState(null);
const [configDrawerOpen, setConfigDrawerOpen] = useState(false);
const [form, setForm] = useState(emptyForm);
const [configLoading, setConfigLoading] = useState(false);
const [configSaving, setConfigSaving] = useState(false);
const [resourceGroups, setResourceGroups] = useState([]);
const [query, setQuery] = useState("");
const [cycleKey, setCycleKey] = useState("");
const [status, setStatus] = useState("");
const [page, setPage] = useState(1);
const filters = useMemo(() => ({ keyword: query, cycle_key: cycleKey, status }), [cycleKey, query, status]);
const {
data: grants = emptyGrants,
error: grantsError,
loading: grantsLoading,
reload: reloadGrants,
} = usePaginatedQuery({
errorMessage: "加载累充奖励记录失败",
fetcher: listCumulativeRechargeRewardGrants,
filters,
page,
pageSize,
queryKey: ["cumulative-recharge-reward-grants", filters, page],
});
const reloadConfig = useCallback(async () => {
setConfigLoading(true);
try {
const [config, groups] = await Promise.all([
getCumulativeRechargeRewardConfig(),
listResourceGroups({ page: 1, page_size: 100, status: "active" }),
]);
setConfig(config);
setForm(formFromConfig(config));
setResourceGroups(groups.items || []);
} catch (err) {
showToast(err.message || "加载累充奖励配置失败", "error");
} finally {
setConfigLoading(false);
}
}, [showToast]);
useEffect(() => {
void reloadConfig();
}, [reloadConfig]);
const openConfigDrawer = useCallback(() => {
setForm(config ? formFromConfig(config) : emptyForm);
setConfigDrawerOpen(true);
}, [config]);
const closeConfigDrawer = useCallback(() => {
setConfigDrawerOpen(false);
setForm(config ? formFromConfig(config) : emptyForm);
}, [config]);
const changeQuery = (value) => {
setQuery(value);
setPage(1);
};
const changeCycleKey = (value) => {
setCycleKey(value);
setPage(1);
};
const changeStatus = (value) => {
setStatus(value);
setPage(1);
};
const submitConfig = async (event) => {
event.preventDefault();
if (!abilities.canUpdate) {
return;
}
setConfigSaving(true);
try {
const saved = await updateCumulativeRechargeRewardConfig(payloadFromForm(form));
setConfig(saved);
setForm(formFromConfig(saved));
setConfigDrawerOpen(false);
showToast("累充奖励配置已保存", "success");
await reloadGrants();
} catch (err) {
showToast(err.message || "保存累充奖励配置失败", "error");
} finally {
setConfigSaving(false);
}
};
return {
abilities,
changeCycleKey,
changeQuery,
changeStatus,
closeConfigDrawer,
config,
configDrawerOpen,
configLoading,
configSaving,
cycleKey,
form,
grants,
grantsError,
grantsLoading,
openConfigDrawer,
page,
query,
reloadConfig,
reloadGrants,
resourceGroups,
setForm,
setPage,
status,
submitConfig,
};
}
function formFromConfig(config) {
return {
enabled: Boolean(config.enabled),
tiers: (config.tiers || []).map((tier) => ({
tierId: tier.tierId || 0,
tierCode: tier.tierCode || "",
tierName: tier.tierName || "",
thresholdUsd: tier.thresholdUsdMinor ? String((tier.thresholdUsdMinor / 100).toFixed(2)) : "",
resourceGroupId: tier.resourceGroupId ? String(tier.resourceGroupId) : "",
status: tier.status || "active",
sortOrder: String(tier.sortOrder || 0),
})),
};
}
function payloadFromForm(form) {
const tiers = (form.tiers || []).map((tier, index) => {
const thresholdUsdMinor = Math.round(Number(tier.thresholdUsd || 0) * 100);
const resourceGroupId = Number(tier.resourceGroupId || 0);
if (thresholdUsdMinor <= 0) {
throw new Error("充值 USD 金额必须大于 0");
}
if (resourceGroupId <= 0) {
throw new Error("请选择资源组");
}
const existingTier = Number(tier.tierId || 0) > 0;
const tierCode = String(tier.tierCode || "").trim() || `cumulative_recharge_${Date.now()}_${index + 1}`;
const tierName =
existingTier && String(tier.tierName || "").trim()
? String(tier.tierName).trim()
: `累充 ${(thresholdUsdMinor / 100).toFixed(2)} USD`;
return {
tier_id: Number(tier.tierId || 0),
tier_code: tierCode,
tier_name: tierName,
threshold_usd_minor: thresholdUsdMinor,
resource_group_id: resourceGroupId,
status: tier.status === "inactive" ? "inactive" : "active",
sort_order: index,
};
});
if (form.enabled && tiers.filter((tier) => tier.status === "active").length === 0) {
throw new Error("开启后至少需要一个启用档位");
}
return {
enabled: Boolean(form.enabled),
tiers,
};
}

View File

@ -0,0 +1,209 @@
import Avatar from "@mui/material/Avatar";
import { CumulativeRechargeRewardConfigDrawer } from "@/features/cumulative-recharge-reward/components/CumulativeRechargeRewardConfigDrawer.jsx";
import { CumulativeRechargeRewardConfigSummary } from "@/features/cumulative-recharge-reward/components/CumulativeRechargeRewardConfigSummary.jsx";
import { useCumulativeRechargeRewardPage } from "@/features/cumulative-recharge-reward/hooks/useCumulativeRechargeRewardPage.js";
import styles from "@/features/cumulative-recharge-reward/cumulative-recharge-reward.module.css";
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
const grantStatusOptions = [
["pending", "发放中"],
["granted", "已发放"],
["failed", "发放失败"],
];
const columnsBase = [
{
key: "user",
label: "用户信息",
width: "minmax(260px, 1.1fr)",
render: (grant) => <GrantUser grant={grant} />,
},
{
key: "cycle",
label: "周期",
width: "minmax(150px, 0.7fr)",
render: (grant) => grant.cycleKey || "-",
},
{
key: "tier",
label: "命中档位",
width: "minmax(190px, 0.85fr)",
render: (grant) => (
<div className={styles.stack}>
<span>{grant.tierCode || "-"}</span>
<span className={styles.meta}>{formatUSD(grant.thresholdUsdMinor)}</span>
</div>
),
},
{
key: "amount",
label: "累计 / 本次",
width: "minmax(210px, 0.9fr)",
render: (grant) => (
<div className={styles.stack}>
<span>{formatUSD(grant.reachedUsdMinor)}</span>
<span className={styles.meta}>本次 {formatUSD(grant.qualifyingUsdMinor)}</span>
</div>
),
},
{
key: "source",
label: "来源",
width: "minmax(190px, 0.85fr)",
render: (grant) => (
<div className={styles.stack}>
<span>{grant.rechargeType || "-"}</span>
<span className={styles.meta}>
{grant.rechargeCoinAmount ? `${formatNumber(grant.rechargeCoinAmount)} 金币` : "-"}
</span>
</div>
),
},
{
key: "status",
label: "状态",
width: "minmax(120px, 0.55fr)",
render: (grant) => grantStatusLabel(grant.status),
},
{
key: "createdAt",
label: "创建时间",
width: "minmax(180px, 0.85fr)",
render: (grant) => formatMillis(grant.createdAtMs),
},
{
key: "grantId",
label: "记录",
width: "minmax(280px, 1fr)",
render: (grant) => (
<div className={styles.stack}>
<span>{grant.grantId}</span>
<span className={styles.meta}>{grant.transactionId || grant.eventId || "-"}</span>
</div>
),
},
{
key: "failure",
label: "失败原因",
width: "minmax(180px, 0.85fr)",
render: (grant) => grant.failureReason || "-",
},
];
export function CumulativeRechargeRewardPage() {
const page = useCumulativeRechargeRewardPage();
const total = page.grants.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-W23",
value: page.cycleKey,
onChange: page.changeCycleKey,
}),
};
}
if (column.key === "status") {
return {
...column,
filter: createOptionsColumnFilter({
options: [["", "全部状态"], ...grantStatusOptions],
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
};
}
return column;
});
return (
<AdminListPage>
<CumulativeRechargeRewardConfigSummary
canUpdate={page.abilities.canUpdate}
config={page.config}
configLoading={page.configLoading}
resourceGroups={page.resourceGroups}
onEdit={page.openConfigDrawer}
onRefresh={page.reloadConfig}
/>
<DataState error={page.grantsError} loading={page.grantsLoading} onRetry={page.reloadGrants}>
<AdminListBody>
<DataTable
columns={columns}
items={page.grants.items || []}
minWidth="1500px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.grants.pageSize || 50,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(grant) => grant.grantId}
/>
</AdminListBody>
</DataState>
<CumulativeRechargeRewardConfigDrawer
abilities={page.abilities}
configLoading={page.configLoading}
configSaving={page.configSaving}
form={page.form}
open={page.configDrawerOpen}
resourceGroups={page.resourceGroups}
setForm={page.setForm}
onClose={page.closeConfigDrawer}
onSubmit={page.submitConfig}
/>
</AdminListPage>
);
}
function GrantUser({ grant }) {
const user = grant.user || {};
return (
<div className={styles.identity}>
<Avatar
alt={user.username || String(grant.userId)}
src={user.avatar || ""}
sx={{ width: 36, height: 36 }}
/>
<div className={styles.stack}>
<span className={styles.name}>{user.username || `用户 ${grant.userId}`}</span>
<span className={styles.meta}>
{user.displayUserId ? `短号 ${user.displayUserId}` : `ID ${grant.userId}`}
</span>
</div>
</div>
);
}
function grantStatusLabel(status) {
return grantStatusOptions.find(([value]) => value === status)?.[1] || status || "-";
}
function formatUSD(value) {
return `$${(Number(value || 0) / 100).toFixed(2)}`;
}
function formatNumber(value) {
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
}

View File

@ -0,0 +1,11 @@
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { PERMISSIONS } from "@/app/permissions";
export function useCumulativeRechargeRewardAbilities() {
const { can } = useAuth();
return {
canUpdate: can(PERMISSIONS.cumulativeRechargeRewardUpdate),
canView: can(PERMISSIONS.cumulativeRechargeRewardView),
};
}

View File

@ -0,0 +1,13 @@
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const cumulativeRechargeRewardRoutes = [
{
label: "累充奖励配置",
loader: () =>
import("./pages/CumulativeRechargeRewardPage.jsx").then((module) => module.CumulativeRechargeRewardPage),
menuCode: MENU_CODES.cumulativeRechargeReward,
pageKey: "cumulative-recharge-reward",
path: "/activities/cumulative-recharge-reward",
permission: PERMISSIONS.cumulativeRechargeRewardView,
},
];

View File

@ -0,0 +1,46 @@
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { getFrequentSecondLevelMenus } from "@/app/navigation/menuUsage.js";
import { TimeText } from "@/shared/ui/TimeText.jsx";
export function DashboardFrequentMenus({ menus = [] }) {
const { user } = useAuth();
const navigate = useNavigate();
const frequentMenus = useMemo(() => getFrequentSecondLevelMenus({ menus, user }), [menus, user]);
return (
<section className="frequent-menu-section" aria-label="常用二级菜单">
<div className="frequent-menu-head">
<div>
<h2>常用页面</h2>
<p>本地缓存的二级菜单访问次数</p>
</div>
</div>
{frequentMenus.length ? (
<div className="frequent-menu-grid">
{frequentMenus.map((item) => {
const Icon = item.icon;
return (
<button
className="frequent-menu-card"
key={item.code}
type="button"
onClick={() => navigate(item.path)}
>
<span className="frequent-menu-card__icon">{Icon ? <Icon fontSize="small" /> : null}</span>
<span className="frequent-menu-card__group">{item.parentLabel}</span>
<strong>{item.label}</strong>
<span className="frequent-menu-card__meta">
进入 {item.count || 0} · <TimeText value={item.lastVisitedAtMs} />
</span>
</button>
);
})}
</div>
) : (
<div className="frequent-menu-empty">暂无本地访问记录</div>
)}
</section>
);
}

View File

@ -5,6 +5,105 @@
margin-top: var(--space-4);
}
.frequent-menu-section {
margin-top: var(--space-4);
}
.frequent-menu-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-3);
}
.frequent-menu-head h2 {
margin: 0;
color: var(--text-primary);
font-size: var(--admin-font-size-lg);
font-weight: 750;
}
.frequent-menu-head p {
margin: var(--space-1) 0 0;
color: var(--text-tertiary);
font-size: var(--admin-font-size);
}
.frequent-menu-grid {
display: grid;
grid-template-columns: repeat(4, minmax(180px, 1fr));
gap: var(--space-3);
}
.frequent-menu-card {
display: grid;
min-height: 112px;
align-content: start;
gap: var(--space-1);
padding: var(--space-4);
border: 1px solid var(--border);
border-radius: var(--radius-card);
background: var(--bg-card);
color: var(--text-secondary);
cursor: pointer;
font: inherit;
text-align: left;
transition:
border-color var(--motion-base) var(--ease-standard),
box-shadow var(--motion-base) var(--ease-standard),
transform var(--motion-base) var(--ease-emphasized);
}
.frequent-menu-card:hover {
border-color: var(--primary-border-strong);
box-shadow: var(--shadow-panel);
transform: translateY(-2px);
}
.frequent-menu-card__icon {
display: inline-flex;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
border-radius: var(--radius-control);
background: var(--primary-surface);
color: var(--primary);
}
.frequent-menu-card__group {
margin-top: var(--space-1);
color: var(--text-tertiary);
font-size: var(--admin-font-size-sm);
}
.frequent-menu-card strong {
min-width: 0;
overflow: hidden;
color: var(--text-primary);
font-size: var(--admin-font-size);
font-weight: 750;
text-overflow: ellipsis;
white-space: nowrap;
}
.frequent-menu-card__meta {
color: var(--text-tertiary);
font-size: var(--admin-font-size-sm);
}
.frequent-menu-empty {
display: flex;
min-height: 88px;
align-items: center;
padding: var(--space-4);
border: 1px dashed var(--border);
border-radius: var(--radius-card);
background: var(--bg-card);
color: var(--text-tertiary);
}
.chart-card {
min-height: 304px;
padding: var(--space-5);

View File

@ -1,25 +1,8 @@
import { DataState } from "@/shared/ui/DataState.jsx";
import { DashboardCharts } from "@/features/dashboard/components/DashboardCharts.jsx";
import { DashboardStats } from "@/features/dashboard/components/DashboardStats.jsx";
import { DashboardToolbar } from "@/features/dashboard/components/DashboardToolbar.jsx";
import { useDashboardPage } from "@/features/dashboard/hooks/useDashboardPage.js";
import { useOutletContext } from "react-router-dom";
import { DashboardFrequentMenus } from "@/features/dashboard/components/DashboardFrequentMenus.jsx";
export function OverviewPage() {
const page = useDashboardPage();
const { menus = [] } = useOutletContext() || {};
return (
<>
<DashboardToolbar
onRefresh={page.reload}
onRangeChange={page.setRange}
overview={page.overview}
range={page.range}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<DashboardStats overview={page.overview} stats={page.stats} />
<DashboardCharts overview={page.overview} />
</DataState>
</>
);
return <DashboardFrequentMenus menus={menus} />;
}

View File

@ -8,7 +8,7 @@ export interface GamePlatformDto {
platformName: string;
status: string;
apiBaseUrl: string;
// 服务端按 adapterType 选择厂商协议:demo/yomi_v4/leadercc_v1/zeeone_v1/baishun_v1。
// 服务端按 adapterType 选择真实厂商协议yomi_v4/leadercc_v1/zeeone_v1/baishun_v1/vivagames_v1。
adapterType: string;
// callbackSecret 由后台列表返回,用于配置页直接核对和轮换厂商 key。
callbackSecret?: string;
@ -173,7 +173,7 @@ function normalizePlatform(platform: Partial<GamePlatformDto>): GamePlatformDto
platformName: stringValue(platform.platformName),
status: stringValue(platform.status || "active"),
apiBaseUrl: stringValue(platform.apiBaseUrl),
adapterType: stringValue(platform.adapterType || "demo"),
adapterType: stringValue(platform.adapterType || "yomi_v4"),
callbackSecret: stringValue(platform.callbackSecret),
callbackSecretSet: Boolean(platform.callbackSecretSet),
callbackIpWhitelist: Array.isArray(platform.callbackIpWhitelist)

View File

@ -37,8 +37,8 @@ const defaultPlatformForm = {
platformName: "",
status: "active",
apiBaseUrl: "",
// 默认 demo 兼容本地调试;真实厂商在后台切到 yomi_v4/leadercc_v1/zeeone_v1/baishun_v1
adapterType: "demo",
// 新建平台必须选择真实厂商协议,避免把线上配置误落成 demo
adapterType: "yomi_v4",
// 后台现在会回显厂商 key编辑弹窗直接显示当前值提交空值仍代表“不覆盖旧值”。
callbackSecret: "",
callbackSecretSet: false,
@ -542,7 +542,7 @@ function platformToForm(platform) {
platformName: platform.platformName || "",
status: platform.status || "active",
apiBaseUrl: platform.apiBaseUrl || "",
adapterType: platform.adapterType || "demo",
adapterType: platform.adapterType || "yomi_v4",
// 使用后端返回的当前密钥,方便运营直接核对;用户清空提交时后端会保留旧值。
callbackSecret: platform.callbackSecret || "",
callbackSecretSet: Boolean(platform.callbackSecretSet),
@ -589,7 +589,7 @@ function platformPayload(form) {
platformName: form.platformName.trim(),
status: form.status.trim() || "active",
apiBaseUrl: isLeaderCC ? "" : form.apiBaseUrl.trim(),
adapterType: form.adapterType.trim() || "demo",
adapterType: form.adapterType.trim() || "yomi_v4",
// 空密钥提交给后端后会保留旧值;非空才覆盖,用于安全轮换 key/AppSecret。
callbackSecret: form.callbackSecret.trim(),
// 支持换行或逗号分隔,方便直接粘贴厂商给的多 IP 白名单。

View File

@ -44,11 +44,11 @@ const launchModeOptions = [
];
// game-service adapter_type
const adapterTypeOptions = [
["demo", "Demo"],
["yomi_v4", "小游 Yomi V4"],
["leadercc_v1", "灵仙 LeaderCC V1"],
["zeeone_v1", "ZeeOne V1"],
["baishun_v1", "百顺 BAISHUN V1"],
["vivagames_v1", "VIVAGAMES V1"],
];
const baseColumns = [
@ -542,7 +542,7 @@ function PlatformManageDialog({ open, page, onClose }) {
<div className={styles.stack}>
<span className={styles.name}>{platform.platformName || platform.platformCode}</span>
<span className={styles.meta}>
{platform.platformCode} / {platform.adapterType || "demo"}
{platform.platformCode} / {platform.adapterType || "未配置"}
</span>
{platform.apiBaseUrl ? <span className={styles.meta}>{platform.apiBaseUrl}</span> : null}
<span className={`${styles.meta} ${styles.secretMeta}`}>

View File

@ -1,5 +1,6 @@
import Add from "@mui/icons-material/Add";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import InputAdornment from "@mui/material/InputAdornment";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
@ -119,19 +120,18 @@ function PolicyBaseFields({ disabled, page }) {
required
className={styles.fieldCompact}
disabled={disabled}
label="金币转钻石比例"
helperText="0 表示不折算"
label="剩余钻石兑美元"
size="small"
value={form.giftCoinToDiamondRatio}
onChange={(event) => setForm({ giftCoinToDiamondRatio: event.target.value })}
/>
<TextField
required
className={styles.fieldCompact}
disabled={disabled}
label="剩余钻石转美元比例"
size="small"
value={form.residualDiamondToUsdRate}
onChange={(event) => setForm({ residualDiamondToUsdRate: event.target.value })}
slotProps={{
htmlInput: { min: 0, step: 1 },
input: {
endAdornment: <InputAdornment position="end">钻石=1美元</InputAdornment>,
},
}}
type="number"
value={form.residualDiamondsPerUsd}
onChange={(event) => setForm({ residualDiamondsPerUsd: event.target.value })}
/>
<TimeRangeFilter
className={styles.timeRangeField}

View File

@ -0,0 +1,54 @@
import { render, screen } from "@testing-library/react";
import { vi, test, expect } from "vitest";
import { HostAgencyPolicyDrawer } from "@/features/host-agency-policy/components/HostAgencyPolicyDrawer.jsx";
test("host agency policy drawer shows residual diamonds per USD and hides gift ratio input", () => {
render(<HostAgencyPolicyDrawer page={drawerPage()} />);
expect(screen.queryByLabelText("金币转钻石比例")).not.toBeInTheDocument();
expect(screen.getByText("剩余钻石兑美元")).toBeInTheDocument();
expect(screen.getByDisplayValue("100")).toBeInTheDocument();
expect(screen.getByText("钻石=1美元")).toBeInTheDocument();
expect(screen.getByText("0 表示不折算")).toBeInTheDocument();
});
function drawerPage() {
return {
abilities: {
canCreate: true,
canUpdate: true,
},
addLevel: vi.fn(),
closeDrawer: vi.fn(),
drawerOpen: true,
editingPolicy: null,
form: {
effectiveFrom: "",
effectiveTo: "",
enabled: false,
levels: [
{
agencySalaryUsd: "0.5",
hostCoinReward: "90000",
hostSalaryUsd: "1.5",
level: "1",
requiredDiamonds: "350000",
sortOrder: "1",
status: "active",
},
],
name: "Lalu Host & Agency Salary Policy",
regionId: "1001",
residualDiamondsPerUsd: "100",
settlementMode: "daily",
settlementTriggerMode: "automatic",
},
loadingAction: "",
loadingRegions: false,
regionOptions: [{ label: "默认区域", value: "1001" }],
removeLevel: vi.fn(),
setForm: vi.fn(),
submitPolicy: vi.fn((event) => event.preventDefault()),
updateLevel: vi.fn(),
};
}

View File

@ -12,6 +12,10 @@ import {
updateHostAgencyPolicy,
} from "@/features/host-agency-policy/api";
import { useHostAgencyPolicyAbilities } from "@/features/host-agency-policy/permissions.js";
import {
residualDiamondsPerUsdToRate,
residualRateToDiamondsPerUsd,
} from "@/features/host-agency-policy/residualRate.js";
import { hostAgencyPolicyFormSchema } from "@/features/host-agency-policy/schema";
const pageSize = 50;
@ -51,12 +55,11 @@ const emptyForm = (policy = null) => ({
effectiveTo: normalizeTimeMs(policy?.effectiveToMs),
// 新建默认停用,运营保存草稿后再手动启用,避免半配置状态影响结算匹配。
enabled: policy ? policy.status === "active" : false,
giftCoinToDiamondRatio: policy?.giftCoinToDiamondRatio || "1",
// 编辑时保留后端等级;新建时一次铺满 24 个默认等级,减少运营逐级录入成本。
levels: policy?.levels?.length ? policy.levels.map(levelToForm) : defaultLevels.map(defaultLevelToForm),
name: policy?.name || "Lalu Host & Agency Salary Policy",
regionId: policy?.regionId ? String(policy.regionId) : "",
residualDiamondToUsdRate: policy?.residualDiamondToUsdRate || "0",
residualDiamondsPerUsd: residualRateToDiamondsPerUsd(policy?.residualDiamondToUsdRate || "0"),
settlementMode: policy?.settlementMode || "daily",
settlementTriggerMode: policy?.settlementTriggerMode || "automatic",
});
@ -321,7 +324,8 @@ function buildPayload(form) {
return {
effective_from_ms: timeValueToMs(form.effectiveFrom),
effective_to_ms: timeValueToMs(form.effectiveTo),
gift_coin_to_diamond_ratio: String(form.giftCoinToDiamondRatio || "1").trim(),
// 主播周期钻石入账比例由“礼物钻石”配置页控制;工资政策不再提供第二套可编辑比例。
gift_coin_to_diamond_ratio: "1",
levels: form.levels
.map((level) => ({
// 保存等级时统一转 number避免字符串数字进入后端后造成弱类型歧义。
@ -337,7 +341,7 @@ function buildPayload(form) {
.sort((left, right) => left.level - right.level),
name: String(form.name || "").trim(),
region_id: Number(form.regionId || 0),
residual_diamond_to_usd_rate: String(form.residualDiamondToUsdRate || "0").trim(),
residual_diamond_to_usd_rate: residualDiamondsPerUsdToRate(form.residualDiamondsPerUsd),
settlement_mode: form.settlementMode || "daily",
// 触发方式会被发布到 wallet 运行表manual 表示后续只能从工资结算页人工触发。
settlement_trigger_mode: form.settlementTriggerMode || "automatic",

View File

@ -21,6 +21,7 @@ import { formatMillis } from "@/shared/utils/time.js";
import { HostAgencyPolicyDrawer } from "@/features/host-agency-policy/components/HostAgencyPolicyDrawer.jsx";
import { useHostAgencyPolicyPage } from "@/features/host-agency-policy/hooks/useHostAgencyPolicyPage.js";
import { TeamSalaryPolicyPanel } from "@/features/host-agency-policy/pages/TeamSalaryPolicyPanel.jsx";
import { residualRateLabel } from "@/features/host-agency-policy/residualRate.js";
import styles from "@/features/host-agency-policy/host-agency-policy.module.css";
const statusOptions = [
@ -238,8 +239,8 @@ function SettlementSummary({ item }) {
<div className={styles.stack}>
<span>{settlementLabel(item.settlementMode)}</span>
<span className={styles.meta}>{settlementTriggerLabel(item.settlementTriggerMode)}</span>
<span className={styles.meta}>金币转钻石 {item.giftCoinToDiamondRatio || "1"}</span>
<span className={styles.meta}>剩余钻石转美元 {item.residualDiamondToUsdRate || "0"}</span>
<span className={styles.meta}>入账钻石按礼物钻石配置</span>
<span className={styles.meta}>{residualRateLabel(item.residualDiamondToUsdRate)}</span>
</div>
);
}

View File

@ -0,0 +1,37 @@
const maxRateScale = 12;
export function residualDiamondsPerUsdToRate(value) {
const diamonds = Number(String(value ?? "").trim());
if (!Number.isFinite(diamonds) || diamonds <= 0) {
return "0";
}
return trimDecimal((1 / diamonds).toFixed(maxRateScale));
}
export function residualRateToDiamondsPerUsd(rate) {
const value = Number(String(rate ?? "").trim());
if (!Number.isFinite(value) || value <= 0) {
return "0";
}
const diamonds = 1 / value;
const rounded = Math.round(diamonds);
// 后端 rate 只保留 12 位小数1/3 这类配置回显时会有极小误差;贴近整数时按整数展示。
if (Math.abs(diamonds - rounded) < 0.000001) {
return String(rounded);
}
return trimDecimal(diamonds.toFixed(6));
}
export function residualRateLabel(rate) {
const diamonds = residualRateToDiamondsPerUsd(rate);
return diamonds === "0" ? "剩余钻石不折美元" : `剩余钻石 ${diamonds}钻石=1美元`;
}
function trimDecimal(value) {
const text = String(value || "0");
if (!text.includes(".")) {
return text;
}
const trimmed = text.replace(/0+$/, "").replace(/\.$/, "");
return trimmed || "0";
}

View File

@ -0,0 +1,20 @@
import { describe, expect, test } from "vitest";
import {
residualDiamondsPerUsdToRate,
residualRateLabel,
residualRateToDiamondsPerUsd,
} from "@/features/host-agency-policy/residualRate.js";
describe("host agency residual diamond rate", () => {
test("submits diamonds per USD as backend USD-per-diamond rate", () => {
expect(residualDiamondsPerUsdToRate("100")).toBe("0.01");
expect(residualDiamondsPerUsdToRate("1000000")).toBe("0.000001");
expect(residualDiamondsPerUsdToRate("0")).toBe("0");
});
test("displays backend rate as diamonds per USD", () => {
expect(residualRateToDiamondsPerUsd("0.01")).toBe("100");
expect(residualRateLabel("0.01")).toBe("剩余钻石 100钻石=1美元");
expect(residualRateLabel("0")).toBe("剩余钻石不折美元");
});
});

View File

@ -21,11 +21,10 @@ export const hostAgencyPolicyFormSchema = z
effectiveFrom: z.union([z.string(), z.number()]).optional(),
effectiveTo: z.union([z.string(), z.number()]).optional(),
enabled: z.boolean(),
giftCoinToDiamondRatio: z.union([z.string(), z.number()]),
levels: z.array(levelSchema).min(1, "请至少配置一个等级"),
name: z.string().trim().min(1, "请输入政策名称").max(120, "政策名称不能超过 120 个字符"),
regionId: z.union([z.string(), z.number()]),
residualDiamondToUsdRate: z.union([z.string(), z.number()]),
residualDiamondsPerUsd: z.union([z.string(), z.number()]),
settlementMode: z.enum(settlementModes),
settlementTriggerMode: z.enum(settlementTriggerModes),
})
@ -35,14 +34,11 @@ export const hostAgencyPolicyFormSchema = z
if (!Number.isInteger(regionId) || regionId <= 0) {
context.addIssue({ code: "custom", message: "请选择适用区域", path: ["regionId"] });
}
if (!isFixedDecimal(value.giftCoinToDiamondRatio, 6, false)) {
context.addIssue({ code: "custom", message: "金币转钻石比例不正确", path: ["giftCoinToDiamondRatio"] });
}
if (!isFixedDecimal(value.residualDiamondToUsdRate, 12, true)) {
if (!isNonNegativeInteger(value.residualDiamondsPerUsd)) {
context.addIssue({
code: "custom",
message: "剩余钻石转美元比例不正确",
path: ["residualDiamondToUsdRate"],
message: "剩余钻石兑美元配置不正确",
path: ["residualDiamondsPerUsd"],
});
}
const effectiveFromMs = timeValueToMs(value.effectiveFrom);
@ -190,6 +186,11 @@ function isFixedDecimal(value: unknown, scale: number, allowZero: boolean) {
return decimalToScaled(value, scale, allowZero) >= 0;
}
function isNonNegativeInteger(value: unknown) {
const raw = String(value ?? "").trim();
return /^\d+$/.test(raw);
}
function decimalToScaled(value: unknown, scale: number, allowZero = true) {
// 小数校验转换成整数刻度比较,避免 JS 浮点数直接参与金额大小判断。
const raw = String(value ?? "").trim();

View File

@ -1,10 +1,12 @@
import { afterEach, expect, test, vi } from "vitest";
import { setAccessToken } from "@/shared/api/request";
import {
createBDLeader,
createCountry,
createCoinSeller,
createRegion,
creditCoinSellerStock,
deleteAgency,
disableRegion,
listAgencies,
listBDs,
@ -14,6 +16,7 @@ import {
listRegions,
replaceRegionCountries,
setCoinSellerStatus,
updateBDLeaderPositionAlias,
updateCountry,
} from "./api";
@ -32,8 +35,24 @@ 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 listHosts({ agency_id: 41, page: 1, page_size: 10 });
await listCoinSellers({ page: 1, page_size: 10, region_id: 7, status: "active" });
await listHosts({ agency_id: 41, page: 1, page_size: 10, sort_by: "diamond", sort_direction: "desc" });
await listCoinSellers({
page: 1,
page_size: 10,
region_id: 7,
sort_by: "merchant_balance",
sort_direction: "desc",
status: "active",
});
await createBDLeader({
commandId: "bd-leader-test",
positionAlias: "Senior Admin",
targetUserId: 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, {
@ -42,14 +61,18 @@ test("host org list APIs use generated admin paths and filters", async () => {
rechargeAmount: "100.000000",
type: "usdt_purchase",
});
await deleteAgency(7001, { commandId: "agency-delete-test", reason: "cleanup" });
const [bdUrl, bdInit] = vi.mocked(fetch).mock.calls[0];
const [agencyUrl] = vi.mocked(fetch).mock.calls[1];
const [hostUrl] = vi.mocked(fetch).mock.calls[2];
const [coinSellerUrl, coinSellerInit] = vi.mocked(fetch).mock.calls[3];
const [coinSellerCreateUrl, coinSellerCreateInit] = vi.mocked(fetch).mock.calls[4];
const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[5];
const [coinSellerStockUrl, coinSellerStockInit] = vi.mocked(fetch).mock.calls[6];
const [bdLeaderCreateUrl, bdLeaderCreateInit] = vi.mocked(fetch).mock.calls[4];
const [bdLeaderAliasUrl, bdLeaderAliasInit] = vi.mocked(fetch).mock.calls[5];
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];
expect(String(bdUrl)).toContain("/api/v1/admin/bds?");
expect(String(bdUrl)).toContain("parent_leader_user_id=21");
@ -60,16 +83,30 @@ test("host org list APIs use generated admin paths and filters", async () => {
expect(String(agencyUrl)).toContain("parent_bd_user_id=31");
expect(String(hostUrl)).toContain("/api/v1/admin/hosts?");
expect(String(hostUrl)).toContain("agency_id=41");
expect(String(hostUrl)).toContain("sort_by=diamond");
expect(String(hostUrl)).toContain("sort_direction=desc");
expect(String(coinSellerUrl)).toContain("/api/v1/admin/coin-sellers?");
expect(String(coinSellerUrl)).toContain("region_id=7");
expect(String(coinSellerUrl)).toContain("sort_by=merchant_balance");
expect(String(coinSellerUrl)).toContain("sort_direction=desc");
expect(String(coinSellerUrl)).toContain("status=active");
expect(coinSellerInit?.method).toBe("GET");
expect(String(bdLeaderCreateUrl)).toContain("/api/v1/admin/bd-leaders");
expect(bdLeaderCreateInit?.method).toBe("POST");
expect(JSON.parse(String(bdLeaderCreateInit?.body))).toMatchObject({ positionAlias: "Senior Admin" });
expect(JSON.parse(String(bdLeaderCreateInit?.body))).not.toHaveProperty("regionId");
expect(String(bdLeaderAliasUrl)).toContain("/api/v1/admin/bd-leaders/1002/position-alias");
expect(bdLeaderAliasInit?.method).toBe("PATCH");
expect(JSON.parse(String(bdLeaderAliasInit?.body))).toMatchObject({ positionAlias: "Regional Admin" });
expect(String(coinSellerCreateUrl)).toContain("/api/v1/admin/coin-sellers");
expect(coinSellerCreateInit?.method).toBe("POST");
expect(String(coinSellerStatusUrl)).toContain("/api/v1/admin/coin-sellers/1001/status");
expect(coinSellerStatusInit?.method).toBe("PATCH");
expect(String(coinSellerStockUrl)).toContain("/api/v1/admin/coin-sellers/1001/stock-credits");
expect(coinSellerStockInit?.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" });
});
test("country and region APIs use generated admin paths", async () => {

View File

@ -6,9 +6,12 @@ import type {
AgencyJoinEnabledPayload,
ApiList,
ApiPage,
BDLeaderPositionAliasPayload,
BDProfileDto,
BDStatusPayload,
CoinSellerDto,
CoinSellerSalaryRatesDto,
CoinSellerSalaryRatesPayload,
CoinSellerStockCreditDto,
CoinSellerStockCreditPayload,
CoinSellerStatusPayload,
@ -198,6 +201,19 @@ export function deleteBDLeader(userId: EntityId): Promise<BDProfileDto> {
});
}
export function updateBDLeaderPositionAlias(
userId: EntityId,
payload: BDLeaderPositionAliasPayload,
): Promise<BDProfileDto> {
return apiRequest<BDProfileDto, BDLeaderPositionAliasPayload>(
`/v1/admin/bd-leaders/${encodeURIComponent(String(userId))}/position-alias`,
{
body: payload,
method: "PATCH",
},
);
}
export function setBDLeaderStatus(userId: EntityId, payload: BDStatusPayload): Promise<BDProfileDto> {
const endpoint = API_ENDPOINTS.setBDLeaderStatus;
return apiRequest<BDProfileDto, BDStatusPayload>(
@ -250,6 +266,30 @@ export function creditCoinSellerStock(
);
}
export function getCoinSellerSalaryRates(regionId: EntityId): Promise<CoinSellerSalaryRatesDto> {
const endpoint = API_ENDPOINTS.getCoinSellerSalaryRates;
return apiRequest<CoinSellerSalaryRatesDto>(
apiEndpointPath(API_OPERATIONS.getCoinSellerSalaryRates, { region_id: regionId }),
{
method: endpoint.method,
},
);
}
export function replaceCoinSellerSalaryRates(
regionId: EntityId,
payload: CoinSellerSalaryRatesPayload,
): Promise<CoinSellerSalaryRatesDto> {
const endpoint = API_ENDPOINTS.replaceCoinSellerSalaryRates;
return apiRequest<CoinSellerSalaryRatesDto, CoinSellerSalaryRatesPayload>(
apiEndpointPath(API_OPERATIONS.replaceCoinSellerSalaryRates, { region_id: regionId }),
{
body: payload,
method: endpoint.method,
},
);
}
export function createAgency(payload: CreateAgencyPayload): Promise<CreateAgencyResultDto> {
const endpoint = API_ENDPOINTS.createAgency;
return apiRequest<CreateAgencyResultDto, CreateAgencyPayload>(apiEndpointPath(API_OPERATIONS.createAgency), {
@ -269,6 +309,17 @@ export function closeAgency(agencyId: EntityId, payload: HostCommandPayload): Pr
);
}
export function deleteAgency(agencyId: EntityId, payload: HostCommandPayload): Promise<AgencyDto> {
const endpoint = API_ENDPOINTS.deleteAgency;
return apiRequest<AgencyDto, HostCommandPayload>(
apiEndpointPath(API_OPERATIONS.deleteAgency, { agency_id: agencyId }),
{
body: payload,
method: endpoint.method,
},
);
}
export function setAgencyJoinEnabled(agencyId: EntityId, payload: AgencyJoinEnabledPayload): Promise<AgencyDto> {
const endpoint = API_ENDPOINTS.setAgencyJoinEnabled;
return apiRequest<AgencyDto, AgencyJoinEnabledPayload>(

View File

@ -1,32 +1,51 @@
import Tooltip from "@mui/material/Tooltip";
import styles from "@/features/host-org/host-org.module.css";
import { Button } from "@/shared/ui/Button.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx";
export function HostOrgToolbar({ actions = [] }) {
if (!actions.length) {
export function HostOrgToolbar({ actions = [], leadingActions = [] }) {
if (!actions.length && !leadingActions.length) {
return null;
}
return (
<div className={styles.toolbar}>
<div className={styles.toolbarActions}>
{actions.map((action) => (
<Tooltip arrow key={action.label} title={action.label}>
<span>
<IconButton
className={styles.toolbarAction}
disabled={action.disabled}
label={action.label}
tone={action.tone}
onClick={action.onClick}
sx={action.variant === "primary" ? primaryActionSx : undefined}
>
{action.icon}
</IconButton>
</span>
</Tooltip>
))}
</div>
{leadingActions.length ? (
<div className={styles.toolbarLeft}>
{leadingActions.map((action) => (
<Button
className={styles.toolbarTextAction}
disabled={action.disabled}
key={action.label}
startIcon={action.icon}
variant={action.variant}
onClick={action.onClick}
>
{action.label}
</Button>
))}
</div>
) : null}
{actions.length ? (
<div className={styles.toolbarActions}>
{actions.map((action) => (
<Tooltip arrow key={action.label} title={action.label}>
<span>
<IconButton
className={styles.toolbarAction}
disabled={action.disabled}
label={action.label}
tone={action.tone}
onClick={action.onClick}
sx={action.variant === "primary" ? primaryActionSx : undefined}
>
{action.icon}
</IconButton>
</span>
</Tooltip>
))}
</div>
) : null}
</div>
);
}

View File

@ -0,0 +1,23 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
test("renders leading config action separately from right icon actions", async () => {
const user = userEvent.setup();
const openRates = vi.fn();
const openCreate = vi.fn();
render(
<HostOrgToolbar
actions={[{ icon: <span />, label: "添加币商", onClick: openCreate, variant: "primary" }]}
leadingActions={[{ icon: <span />, label: "工资兑换比例", onClick: openRates }]}
/>,
);
await user.click(screen.getByRole("button", { name: "工资兑换比例" }));
await user.click(screen.getByRole("button", { name: "添加币商" }));
expect(openRates).toHaveBeenCalledTimes(1);
expect(openCreate).toHaveBeenCalledTimes(1);
});

View File

@ -1,33 +1,27 @@
import { useMemo, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { closeAgency, createAgency, listAgencies, setAgencyJoinEnabled } from "@/features/host-org/api";
import { closeAgency, createAgency, deleteAgency, listAgencies, setAgencyJoinEnabled } from "@/features/host-org/api";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { useAgencyAbilities } from "@/features/host-org/permissions.js";
import { agencyCloseSchema, agencyJoinEnabledSchema, createAgencySchema } from "@/features/host-org/schema";
import { createAgencySchema } from "@/features/host-org/schema";
const emptyAgencyForm = () => ({
commandId: makeCommandId("agency"),
joinEnabled: true,
maxHosts: 0,
name: "",
ownerUserId: "",
parentBdUserId: "",
reason: "",
});
const emptyCloseForm = () => ({ agencyId: "", commandId: makeCommandId("agency-close"), reason: "" });
const emptyJoinEnabledForm = () => ({
agencyId: "",
commandId: makeCommandId("agency-join"),
joinEnabled: true,
reason: "",
});
const pageSize = 50;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
export function useHostAgenciesPage() {
const abilities = useAgencyAbilities();
const confirm = useConfirm();
const { showToast } = useToast();
const { loadingRegions, regionOptions } = useRegionOptions();
const [query, setQuery] = useState("");
@ -36,8 +30,8 @@ export function useHostAgenciesPage() {
const [parentBdUserId, setParentBdUserId] = useState("");
const [page, setPage] = useState(1);
const [agencyForm, setAgencyForm] = useState(emptyAgencyForm);
const [closeForm, setCloseForm] = useState(emptyCloseForm);
const [joinEnabledForm, setJoinEnabledForm] = useState(emptyJoinEnabledForm);
const [rowPatches, setRowPatches] = useState({});
const [removedAgencyIds, setRemovedAgencyIds] = useState(() => new Set());
const [loadingAction, setLoadingAction] = useState("");
const [activeAction, setActiveAction] = useState("");
const filters = useMemo(
@ -62,6 +56,26 @@ export function useHostAgenciesPage() {
pageSize,
queryKey: ["host-org", "agencies", filters, page],
});
const viewData = useMemo(() => {
const rawItems = Array.isArray(data.items) ? data.items : [];
const items = rawItems
.filter((item) => !removedAgencyIds.has(String(item.agencyId)))
.map((item) => ({ ...item, ...(rowPatches[String(item.agencyId)] || {}) }));
const visibleRemovedCount = rawItems.length - items.length;
return {
...data,
items,
total: Math.max(0, Number(data.total || 0) - visibleRemovedCount),
};
}, [data, removedAgencyIds, rowPatches]);
const patchAgencyRow = (agencyId, patch) => {
setRowPatches((current) => {
const key = String(agencyId);
return { ...current, [key]: { ...(current[key] || {}), ...patch } };
});
};
const changeQuery = (value) => {
setQuery(value);
@ -103,41 +117,59 @@ export function useHostAgenciesPage() {
});
};
const submitClose = async (event) => {
event.preventDefault();
await runAction("agency-close", "Agency 已关闭", async () => {
const payload = parseForm(agencyCloseSchema, closeForm);
const { agencyId, ...body } = payload;
const data = await closeAgency(agencyId, body);
setCloseForm(emptyCloseForm());
setActiveAction("");
await reload();
return data;
});
};
const closeAgencyFromList = async (agency) => {
if (!abilities.canStatus || !agency?.agencyId || agency.status !== "active") {
return;
}
await runAction(`agency-status-${agency.agencyId}`, "Agency 已关闭", async () => {
await closeAgency(agency.agencyId, {
const data = await closeAgency(agency.agencyId, {
commandId: makeCommandId("agency-close"),
reason: "close agency from list switch",
});
await reload();
patchAgencyRow(agency.agencyId, data || { status: "closed", joinEnabled: false });
return data;
});
};
const submitJoinEnabled = async (event) => {
event.preventDefault();
await runAction("agency-join", "入会开关已更新", async () => {
const payload = parseForm(agencyJoinEnabledSchema, joinEnabledForm);
const { agencyId, ...body } = payload;
const data = await setAgencyJoinEnabled(agencyId, body);
setJoinEnabledForm(emptyJoinEnabledForm());
setActiveAction("");
await reload();
const toggleAgencyJoinEnabledFromList = async (agency, joinEnabled) => {
if (!abilities.canStatus || !agency?.agencyId || agency.status !== "active") {
return;
}
await runAction(`agency-join-${agency.agencyId}`, "入会开关已更新", async () => {
const data = await setAgencyJoinEnabled(agency.agencyId, {
commandId: makeCommandId("agency-join"),
joinEnabled,
reason: "set agency join enabled from list switch",
});
patchAgencyRow(agency.agencyId, data || { joinEnabled });
return data;
});
};
const removeAgencyFromList = async (agency) => {
if (!abilities.canDelete || !agency?.agencyId) {
return;
}
const ok = await confirm({
confirmText: "删除",
message: `确认删除 Agency ${agency.name || agency.agencyId}?只删除 Agency 身份,不删除主播;下属主播会解除关联。`,
title: "删除 Agency",
tone: "danger",
});
if (!ok) {
return;
}
await runAction(`agency-delete-${agency.agencyId}`, "Agency 已删除", async () => {
const data = await deleteAgency(agency.agencyId, {
commandId: makeCommandId("agency-delete"),
reason: "delete agency from list",
});
setRemovedAgencyIds((current) => {
const next = new Set(current);
next.add(String(agency.agencyId));
return next;
});
return data;
});
};
@ -163,10 +195,8 @@ export function useHostAgenciesPage() {
changeRegionId,
changeStatus,
closeAgencyFromList,
closeForm,
data,
data: viewData,
error,
joinEnabledForm,
loading,
loadingAction,
loadingRegions,
@ -179,16 +209,12 @@ export function useHostAgenciesPage() {
resetFilters,
closeAction: () => setActiveAction(""),
openAgencyForm: () => setActiveAction("agency"),
openCloseForm: () => setActiveAction("agency-close"),
openJoinEnabledForm: () => setActiveAction("agency-join"),
removeAgencyFromList,
setAgencyForm,
setCloseForm,
setJoinEnabledForm,
setPage,
status,
submitAgency,
submitClose,
submitJoinEnabled,
toggleAgencyJoinEnabledFromList,
};
}

View File

@ -11,13 +11,19 @@ import {
listBDs,
setBDLeaderStatus,
setBDStatus,
updateBDLeaderPositionAlias,
} from "@/features/host-org/api";
import { listAppUsers } from "@/features/app-users/api";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { useBDAbilities } from "@/features/host-org/permissions.js";
import { createBDSchema, createBDLeaderSchema } from "@/features/host-org/schema";
const emptyBDLeaderForm = () => ({ commandId: makeCommandId("bd-leader"), reason: "", regionId: "", targetUserId: "" });
const emptyBDLeaderForm = () => ({
commandId: makeCommandId("bd-leader"),
positionAlias: "",
reason: "",
targetUserId: "",
});
const emptyBDForm = () => ({ commandId: makeCommandId("bd"), parentLeaderUserId: "", reason: "", targetUserId: "" });
const pageSize = 50;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
@ -42,6 +48,7 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
const [selectedLeader, setSelectedLeader] = useState(null);
const [loadingAction, setLoadingAction] = useState("");
const [activeAction, setActiveAction] = useState("");
const [profileOverrides, setProfileOverrides] = useState({});
const filters = useMemo(
() => ({
keyword: query,
@ -65,6 +72,16 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
pageSize,
queryKey: ["host-org", "bds", profileType, filters, page],
});
const displayData = useMemo(
() => ({
...data,
items: (data.items || []).map((item) => ({
...item,
...(profileOverrides[String(item.userId)] || {}),
})),
}),
[data, profileOverrides],
);
const loadLeaderBDs = useCallback(async (leader) => {
if (!leader?.userId) {
@ -216,6 +233,44 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
});
};
const saveBDLeaderPositionAlias = useCallback(
async (leader, rawAlias) => {
const userId = leader?.userId;
const positionAlias = String(rawAlias ?? "").trim();
const currentAlias = String(leader?.positionAlias ?? "").trim();
if (!userId || positionAlias === currentAlias) {
return leader;
}
if (Array.from(positionAlias).length > 64) {
const err = new Error("职位别名不能超过 64 个字符");
showToast(err.message, "error");
throw err;
}
try {
const profile = await updateBDLeaderPositionAlias(userId, {
commandId: makeCommandId("bd-leader-position-alias"),
positionAlias,
reason: "",
});
setProfileOverrides((current) => ({
...current,
[String(userId)]: {
...(current[String(userId)] || {}),
...(profile || {}),
positionAlias: profile?.positionAlias ?? positionAlias,
},
}));
showToast("职位别名已保存", "success");
return profile;
} catch (err) {
showToast(err.message || "保存职位别名失败", "error");
throw err;
}
},
[showToast],
);
const removeBDLeader = async (leader) => {
const ok = await confirm({
confirmText: "删除",
@ -278,7 +333,7 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
changeQuery,
changeRegionId,
changeStatus,
data,
data: displayData,
error,
loading,
loadingAction,
@ -295,6 +350,7 @@ export function useHostBdsPage({ profileType = "bd" } = {}) {
reload,
removeBDLeader,
resetFilters,
saveBDLeaderPositionAlias,
closeAction,
loadLeaderBDs: () => loadLeaderBDs(selectedLeader),
openAddLeaderBD,

View File

@ -3,7 +3,14 @@ import { parseForm } from "@/shared/forms/validation";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { createCoinSeller, creditCoinSellerStock, listCoinSellers, setCoinSellerStatus } from "@/features/host-org/api";
import {
createCoinSeller,
creditCoinSellerStock,
getCoinSellerSalaryRates,
listCoinSellers,
replaceCoinSellerSalaryRates,
setCoinSellerStatus,
} from "@/features/host-org/api";
import { useCoinSellerAbilities } from "@/features/host-org/permissions.js";
import {
coinSellerStatusSchema,
@ -28,6 +35,13 @@ const emptyStockForm = () => ({
rechargeAmount: "",
type: "usdt_purchase",
});
const emptyRateTier = (index = 0) => ({
coinPerUsd: "",
enabled: true,
maxUsd: "",
minUsd: "",
sortOrder: (index + 1) * 10,
});
export function useHostCoinSellersPage() {
const abilities = useCoinSellerAbilities();
@ -36,23 +50,32 @@ export function useHostCoinSellersPage() {
const [query, setQuery] = useState("");
const [status, setStatus] = useState("");
const [regionId, setRegionId] = useState("");
const [sortBy, setSortBy] = useState("");
const [sortDirection, setSortDirection] = useState("");
const [page, setPage] = useState(1);
const [activeAction, setActiveAction] = useState("");
const [createForm, setCreateForm] = useState(emptyCreateForm);
const [editForm, setEditForm] = useState(emptyEditForm);
const [stockForm, setStockForm] = useState(emptyStockForm);
const [rateRegionId, setRateRegionId] = useState("");
const [rateTiers, setRateTiers] = useState([]);
const [loadingRates, setLoadingRates] = useState(false);
const [selectedSeller, setSelectedSeller] = useState(null);
const [loadingAction, setLoadingAction] = useState("");
const [contactSavingIds, setContactSavingIds] = useState({});
const [sellerRowPatches, setSellerRowPatches] = useState({});
const filters = useMemo(
() => ({
keyword: query,
region_id: regionId,
sort_by: sortBy,
sort_direction: sortDirection,
status,
}),
[query, regionId, status],
[query, regionId, sortBy, sortDirection, status],
);
const {
data = emptyData,
data: queryData = emptyData,
error,
loading,
reload,
@ -64,6 +87,16 @@ export function useHostCoinSellersPage() {
pageSize,
queryKey: ["host-org", "coin-sellers", filters, page],
});
const data = useMemo(() => {
const sourceItems = Array.isArray(queryData.items) ? queryData.items : [];
return {
...queryData,
items: sourceItems.map((seller) => ({
...seller,
...(sellerRowPatches[seller.userId] || {}),
})),
};
}, [queryData, sellerRowPatches]);
const changeQuery = (value) => {
setQuery(value);
@ -80,10 +113,18 @@ export function useHostCoinSellersPage() {
setPage(1);
};
const changeSort = (field) => {
setSortDirection((current) => (sortBy === field ? (current === "asc" ? "desc" : "asc") : "desc"));
setSortBy(field);
setPage(1);
};
const resetFilters = () => {
setQuery("");
setStatus("");
setRegionId("");
setSortBy("");
setSortDirection("");
setPage(1);
};
@ -117,11 +158,77 @@ export function useHostCoinSellersPage() {
setActiveAction("stock");
};
const openSellerLedger = (seller) => {
if (!seller?.userId || !abilities.canLedger) {
return;
}
setSelectedSeller(seller);
setActiveAction("ledger");
};
const openRateSettings = async () => {
if (!abilities.canExchangeRate) {
return;
}
const nextRegionId = rateRegionId || regionId || regionOptions[0]?.value || "";
setRateRegionId(nextRegionId);
setActiveAction("rates");
if (nextRegionId) {
await loadRateTiers(nextRegionId);
} else {
setRateTiers([]);
}
};
const closeAction = () => {
setActiveAction("");
setSelectedSeller(null);
};
const changeRateRegionId = async (value) => {
setRateRegionId(value);
if (value) {
await loadRateTiers(value);
} else {
setRateTiers([]);
}
};
const loadRateTiers = async (targetRegionId = rateRegionId) => {
if (!targetRegionId) {
return;
}
setLoadingRates(true);
try {
const data = await getCoinSellerSalaryRates(targetRegionId);
const next = (data?.tiers || []).map((tier, index) => ({
coinPerUsd: String(tier.coinPerUsd || ""),
enabled: tier.status ? tier.status === "active" : tier.enabled !== false,
maxUsd: tier.maxUsd || centsToUSD(tier.maxUsdMinor),
minUsd: tier.minUsd || centsToUSD(tier.minUsdMinor),
sortOrder: tier.sortOrder || (index + 1) * 10,
}));
setRateTiers(next.length ? next : [emptyRateTier(0)]);
} catch (err) {
setRateTiers([emptyRateTier(0)]);
showToast(err.message || "加载兑换比例失败", "error");
} finally {
setLoadingRates(false);
}
};
const addRateTier = () => {
setRateTiers((items) => [...items, emptyRateTier(items.length)]);
};
const removeRateTier = (index) => {
setRateTiers((items) => items.filter((_, itemIndex) => itemIndex !== index));
};
const updateRateTier = (index, patch) => {
setRateTiers((items) => items.map((item, itemIndex) => (itemIndex === index ? { ...item, ...patch } : item)));
};
const submitCreateSeller = async (event) => {
event.preventDefault();
await runAction("coin-seller-create", "币商已添加", async () => {
@ -149,6 +256,44 @@ export function useHostCoinSellersPage() {
);
};
const saveSellerContact = async (seller, rawContact) => {
if (!abilities.canUpdate || !seller?.userId) {
return false;
}
const contact = String(rawContact ?? "").trim();
if (contact === String(seller.contact || "").trim()) {
return true;
}
const sellerID = seller.userId;
setContactSavingIds((current) => ({ ...current, [sellerID]: true }));
try {
await setCoinSellerStatus(sellerID, {
commandId: makeCommandId("coin-seller-contact"),
contact,
reason: "update coin seller contact",
status: seller.status || "active",
});
setSellerRowPatches((current) => ({
...current,
[sellerID]: {
...(current[sellerID] || {}),
contact,
},
}));
showToast("联系方式已保存", "success");
return true;
} catch (err) {
showToast(err.message || "保存联系方式失败", "error");
return false;
} finally {
setContactSavingIds((current) => {
const next = { ...current };
delete next[sellerID];
return next;
});
}
};
const submitStockCredit = async (event) => {
event.preventDefault();
if (!selectedSeller?.userId) {
@ -163,6 +308,29 @@ export function useHostCoinSellersPage() {
});
};
const submitRateSettings = async (event) => {
event.preventDefault();
if (!rateRegionId) {
showToast("请选择区域", "error");
return;
}
await runAction("coin-seller-rates", "兑换比例已保存", async () => {
const payload = {
tiers: rateTiers
.filter((tier) => tier.minUsd || tier.maxUsd || tier.coinPerUsd)
.map((tier, index) => ({
coinPerUsd: Number(tier.coinPerUsd),
enabled: tier.enabled !== false,
maxUsd: String(tier.maxUsd || "").trim(),
minUsd: String(tier.minUsd || "").trim(),
sortOrder: Number(tier.sortOrder || (index + 1) * 10),
})),
};
await replaceCoinSellerSalaryRates(rateRegionId, payload);
await loadRateTiers(rateRegionId);
});
};
const toggleSeller = async (seller, nextEnabled = seller.status !== "active") => {
if (!abilities.canUpdate || !seller?.userId) {
return;
@ -198,24 +366,35 @@ export function useHostCoinSellersPage() {
activeAction,
changeQuery,
changeRegionId,
changeSort,
changeStatus,
closeAction,
contactSavingIds,
createForm,
data,
editForm,
error,
loading,
loadingAction,
loadingRates,
loadingRegions,
openRateSettings,
openCreateSeller,
openEditSeller,
openSellerLedger,
openStockCredit,
page,
query,
regionId,
regionOptions,
rateRegionId,
rateTiers,
reload,
addRateTier,
changeRateRegionId,
removeRateTier,
resetFilters,
saveSellerContact,
selectedSeller,
setCreateForm,
setEditForm,
@ -223,13 +402,25 @@ export function useHostCoinSellersPage() {
setStockForm,
status,
stockForm,
sortBy,
sortDirection,
submitRateSettings,
submitCreateSeller,
submitEditSeller,
submitStockCredit,
toggleSeller,
updateRateTier,
};
}
function makeCommandId(prefix) {
return `${prefix}-${Date.now()}`;
}
function centsToUSD(value) {
const amount = Number(value || 0);
if (!Number.isFinite(amount) || amount <= 0) {
return "";
}
return `${Math.trunc(amount / 100)}.${String(Math.abs(amount % 100)).padStart(2, "0")}`;
}

View File

@ -14,15 +14,18 @@ export function useHostHostsPage() {
const [status, setStatus] = useState("");
const [regionId, setRegionId] = useState("");
const [agencyId, setAgencyId] = useState("");
const [diamondSortDirection, setDiamondSortDirection] = useState("");
const [page, setPage] = useState(1);
const filters = useMemo(
() => ({
agency_id: agencyId,
keyword: query,
region_id: regionId,
sort_by: diamondSortDirection ? "diamond" : "",
sort_direction: diamondSortDirection,
status,
}),
[agencyId, query, regionId, status],
[agencyId, diamondSortDirection, query, regionId, status],
);
const {
data = emptyData,
@ -58,11 +61,17 @@ export function useHostHostsPage() {
setPage(1);
};
const toggleDiamondSort = () => {
setDiamondSortDirection((current) => (current === "desc" ? "asc" : "desc"));
setPage(1);
};
const resetFilters = () => {
setQuery("");
setStatus("");
setRegionId("");
setAgencyId("");
setDiamondSortDirection("");
setPage(1);
};
@ -74,6 +83,7 @@ export function useHostHostsPage() {
changeRegionId,
changeStatus,
data,
diamondSortDirection,
error,
loading,
loadingRegions,
@ -85,5 +95,6 @@ export function useHostHostsPage() {
resetFilters,
setPage,
status,
toggleDiamondSort,
};
}

View File

@ -66,6 +66,10 @@
margin-left: auto;
}
.toolbarTextAction {
flex: 0 0 auto;
}
.filters {
display: flex;
min-width: 0;
@ -100,6 +104,52 @@
gap: var(--space-2);
}
.rateDrawerForm {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.ledgerDrawer.ledgerDrawer {
width: min(960px, calc(100vw - 32px));
}
.ledgerDrawerBody.ledgerDrawerBody {
display: flex;
overflow: hidden;
}
.rateTierHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
font-weight: 760;
color: var(--text-primary);
}
.rateTierList {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.rateTierRow {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)) auto auto;
gap: var(--space-3);
align-items: center;
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-card);
}
.rateTierSwitch {
display: flex;
justify-content: center;
}
.flagCell {
font-size: var(--admin-font-size);
}
@ -121,6 +171,46 @@
font-weight: 650;
}
.inlineAliasText {
color: var(--text-secondary);
font-weight: 650;
}
.inlineAliasButton {
display: inline-flex;
max-width: 100%;
min-height: 28px;
align-items: center;
padding: 0;
border: 0;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font: inherit;
font-weight: 650;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.inlineAliasButton:hover {
color: var(--primary);
}
.inlineAliasInput {
width: min(180px, 100%);
min-height: 32px;
padding: 0 var(--space-2);
border: 1px solid var(--primary);
border-radius: var(--radius-control);
outline: 0;
background: var(--bg-card);
color: var(--text-primary);
font: inherit;
font-weight: 650;
}
.sellerIdentity {
display: inline-flex;
min-width: 0;

View File

@ -1,6 +1,5 @@
import Add from "@mui/icons-material/Add";
import BlockOutlined from "@mui/icons-material/BlockOutlined";
import ToggleOnOutlined from "@mui/icons-material/ToggleOnOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField";
import { formatMillis } from "@/shared/utils/time.js";
@ -11,6 +10,7 @@ import { HostOrgEntity, HostOrgPerson, hostOrgRegionName } from "@/features/host
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";
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import styles from "@/features/host-org/host-org.module.css";
@ -48,40 +48,29 @@ const agencyColumns = [
{
key: "joinEnabled",
label: "入会",
render: (item) => (
<span className={item.joinEnabled ? styles.positive : styles.negative}>
{item.joinEnabled ? "允许" : "禁止"}
</span>
),
width: "minmax(80px, 0.6fr)",
render: (item, _index, context) => <AgencyJoinSwitch item={item} page={context?.page} />,
width: "minmax(90px, 0.65fr)",
},
{ key: "maxHosts", label: "最大主播数", width: "minmax(100px, 0.7fr)" },
{
key: "updatedAtMs",
label: "更新时间",
render: (item) => formatMillis(item.updatedAtMs),
width: "minmax(170px, 1fr)",
},
{
key: "actions",
label: "操作",
render: (item, _index, context) => <AgencyActions item={item} page={context?.page} />,
width: "minmax(80px, 0.45fr)",
},
];
export function HostAgenciesPage() {
const page = useHostAgenciesPage();
const createDisabled = !page.abilities.canCreate;
const statusDisabled = !page.abilities.canStatus;
const items = page.data.items || [];
const total = page.data.total || 0;
const toolbarActions = [
page.abilities.canStatus
? { icon: <ToggleOnOutlined fontSize="small" />, label: "入会开关", onClick: page.openJoinEnabledForm }
: null,
page.abilities.canStatus
? {
icon: <BlockOutlined fontSize="small" />,
label: "关闭 Agency",
onClick: page.openCloseForm,
tone: "danger",
}
: null,
page.abilities.canCreate
? { icon: <Add fontSize="small" />, label: "创建 Agency", onClick: page.openAgencyForm, variant: "primary" }
: null,
@ -134,7 +123,7 @@ export function HostAgenciesPage() {
columns={columns}
context={{ page, regionOptions: page.regionOptions }}
items={items}
minWidth="1250px"
minWidth="1180px"
pagination={
total > 0
? {
@ -178,18 +167,10 @@ export function HostAgenciesPage() {
<TextField
disabled={createDisabled}
label="上级 BD 短 ID"
required
type="number"
value={page.agencyForm.parentBdUserId}
onChange={(event) => page.setAgencyForm({ ...page.agencyForm, parentBdUserId: event.target.value })}
/>
<TextField
disabled={createDisabled}
label="最大主播数"
type="number"
value={page.agencyForm.maxHosts}
onChange={(event) => page.setAgencyForm({ ...page.agencyForm, maxHosts: event.target.value })}
/>
<AdminSwitch
checked={page.agencyForm.joinEnabled}
checkedLabel="允许"
@ -209,77 +190,6 @@ export function HostAgenciesPage() {
onChange={(event) => page.setAgencyForm({ ...page.agencyForm, reason: event.target.value })}
/>
</HostOrgActionModal>
<HostOrgActionModal
disabled={statusDisabled}
loading={page.loadingAction === "agency-join"}
open={page.activeAction === "agency-join"}
sectionTitle="入会设置"
onClose={page.closeAction}
onSubmit={page.submitJoinEnabled}
title="入会开关"
>
<TextField
disabled={statusDisabled}
label="Agency ID"
required
type="number"
value={page.joinEnabledForm.agencyId}
onChange={(event) =>
page.setJoinEnabledForm({ ...page.joinEnabledForm, agencyId: event.target.value })
}
/>
<AdminSwitch
checked={page.joinEnabledForm.joinEnabled}
checkedLabel="允许"
disabled={statusDisabled}
label="入会状态"
uncheckedLabel="禁止"
onChange={(event) =>
page.setJoinEnabledForm({ ...page.joinEnabledForm, joinEnabled: event.target.checked })
}
/>
<TextField
disabled={statusDisabled}
label="原因"
multiline
minRows={2}
value={page.joinEnabledForm.reason}
onChange={(event) =>
page.setJoinEnabledForm({ ...page.joinEnabledForm, reason: event.target.value })
}
/>
</HostOrgActionModal>
<HostOrgActionModal
disabled={statusDisabled}
loading={page.loadingAction === "agency-close"}
open={page.activeAction === "agency-close"}
sectionTitle="关闭信息"
onClose={page.closeAction}
onSubmit={page.submitClose}
submitLabel="确认关闭"
submitVariant="danger"
title="关闭 Agency"
>
<TextField
disabled={statusDisabled}
label="Agency ID"
required
type="number"
value={page.closeForm.agencyId}
onChange={(event) => page.setCloseForm({ ...page.closeForm, agencyId: event.target.value })}
/>
<TextField
disabled={statusDisabled}
label="原因"
multiline
minRows={2}
required
value={page.closeForm.reason}
onChange={(event) => page.setCloseForm({ ...page.closeForm, reason: event.target.value })}
/>
</HostOrgActionModal>
</section>
);
}
@ -321,3 +231,41 @@ function AgencyStatusSwitch({ item, page }) {
/>
);
}
function AgencyJoinSwitch({ item, page }) {
const active = item.status === "active";
return (
<AdminSwitch
checked={Boolean(item.joinEnabled)}
disabled={!active || !page?.abilities.canStatus || page.loadingAction === `agency-join-${item.agencyId}`}
inputProps={{ "aria-label": item.joinEnabled ? "禁止入会" : "允许入会" }}
onChange={(event) => page.toggleAgencyJoinEnabledFromList(item, event.target.checked)}
/>
);
}
function AgencyActions({ item, page }) {
return (
<AdminRowActions>
{page?.abilities.canDelete ? (
<AdminActionIconButton
disabled={page.loadingAction === `agency-delete-${item.agencyId}`}
label="删除 Agency"
sx={dangerActionSx}
onClick={() => page.removeAgencyFromList(item)}
>
<DeleteOutlineOutlined fontSize="small" />
</AdminActionIconButton>
) : null}
</AdminRowActions>
);
}
const dangerActionSx = {
borderColor: "var(--danger-border)",
color: "var(--danger)",
"&:hover": {
borderColor: "var(--danger-border-strong)",
backgroundColor: "var(--danger-surface)",
},
};

View File

@ -1,6 +1,7 @@
import Add from "@mui/icons-material/Add";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import PersonAddAlt1Outlined from "@mui/icons-material/PersonAddAlt1Outlined";
import { useEffect, useRef, useState } from "react";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField";
import { formatMillis } from "@/shared/utils/time.js";
@ -11,7 +12,6 @@ import { HostOrgActionModal } from "@/features/host-org/components/HostOrgAction
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 { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
import { useHostBdsPage } from "@/features/host-org/hooks/useHostBdsPage.js";
import styles from "@/features/host-org/host-org.module.css";
@ -28,6 +28,12 @@ const bdLeaderBaseColumns = [
render: (item, _index, context) => regionName(item, context?.regionOptions),
width: "minmax(130px, 0.75fr)",
},
{
key: "positionAlias",
label: "职位别名",
render: (item, _index, context) => <PositionAliasCell item={item} page={context?.page} />,
width: "minmax(130px, 0.75fr)",
},
{
key: "status",
label: "状态",
@ -84,9 +90,7 @@ export function HostBdLeadersPage() {
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}>
@ -130,14 +134,13 @@ export function HostBdLeadersPage() {
page.setBDLeaderForm({ ...page.bdLeaderForm, targetUserId: event.target.value })
}
/>
<RegionSelect
<TextField
disabled={createDisabled}
emptyLabel="选择区域"
loading={page.loadingRegions}
options={page.regionOptions}
required
value={page.bdLeaderForm.regionId}
onChange={(value) => page.setBDLeaderForm({ ...page.bdLeaderForm, regionId: value })}
label="职位别名"
value={page.bdLeaderForm.positionAlias}
onChange={(event) =>
page.setBDLeaderForm({ ...page.bdLeaderForm, positionAlias: event.target.value })
}
/>
<TextField
disabled={createDisabled}
@ -256,6 +259,96 @@ function BDLeaderStatusSwitch({ item, page }) {
);
}
function PositionAliasCell({ item, page }) {
const inputRef = useRef(null);
const cancelSaveRef = useRef(false);
const originalValue = String(item.positionAlias || "").trim();
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [value, setValue] = useState(originalValue);
const canEdit = Boolean(page?.abilities.canUpdate);
useEffect(() => {
if (!editing) {
setValue(originalValue);
}
}, [editing, originalValue]);
useEffect(() => {
if (editing) {
inputRef.current?.focus();
inputRef.current?.select();
}
}, [editing]);
const save = async () => {
if (cancelSaveRef.current) {
cancelSaveRef.current = false;
setValue(originalValue);
setEditing(false);
return;
}
const nextValue = value.trim();
if (nextValue === originalValue) {
setValue(originalValue);
setEditing(false);
return;
}
setSaving(true);
try {
await page.saveBDLeaderPositionAlias(item, nextValue);
setEditing(false);
} catch {
setValue(originalValue);
setEditing(false);
} finally {
setSaving(false);
}
};
if (editing) {
return (
<input
ref={inputRef}
className={styles.inlineAliasInput}
disabled={saving}
maxLength={64}
value={value}
onBlur={save}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
if (event.key === "Escape") {
cancelSaveRef.current = true;
setValue(originalValue);
setEditing(false);
}
}}
/>
);
}
if (!canEdit) {
return <span className={styles.inlineAliasText}>{originalValue || "-"}</span>;
}
return (
<button
className={styles.inlineAliasButton}
title="点击编辑职位别名"
type="button"
onClick={() => {
cancelSaveRef.current = false;
setEditing(true);
}}
>
{originalValue || "-"}
</button>
);
}
function SubBDCount({ item, page }) {
return (
<button className={styles.contactButton} type="button" onClick={() => page.openLeaderBDs(item)}>

View File

@ -153,7 +153,6 @@ export function HostBdsPage() {
<TextField
disabled={createDisabled}
label="上级 Leader 短 ID"
required
type="number"
value={page.bdForm.parentLeaderUserId}
onChange={(event) => page.setBDForm({ ...page.bdForm, parentLeaderUserId: event.target.value })}

View File

@ -1,5 +1,12 @@
import Add from "@mui/icons-material/Add";
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 ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
import SwapVertOutlined from "@mui/icons-material/SwapVertOutlined";
import Button from "@mui/material/Button";
import MenuItem from "@mui/material/MenuItem";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField";
@ -12,11 +19,14 @@ import {
import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx";
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
import { InlineEditableCell } from "@/shared/ui/InlineEditableCell.jsx";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import { coinSellerStatusFilters } from "@/features/host-org/constants.js";
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
import { HostOrgToolbar } from "@/features/host-org/components/HostOrgToolbar.jsx";
import { useHostCoinSellersPage } from "@/features/host-org/hooks/useHostCoinSellersPage.js";
import { CoinSellerLedgerTable } from "@/features/operations/components/CoinSellerLedgerTable.jsx";
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import styles from "@/features/host-org/host-org.module.css";
@ -40,23 +50,21 @@ const baseColumns = [
render: (item, _index, context) => <SellerContact item={item} page={context?.page} />,
width: "minmax(170px, 1fr)",
},
{
key: "merchantAssetType",
label: "资产类型",
render: (item) => item.merchantAssetType || "-",
width: "minmax(150px, 0.9fr)",
},
{
key: "merchantBalance",
label: "币商余额",
sortable: true,
sortField: "merchant_balance",
render: (item) => formatNumber(item.merchantBalance),
width: "minmax(120px, 0.8fr)",
},
{
key: "createdByUserId",
label: "创建人 ID",
render: (item) => item.createdByUserId || "-",
width: "minmax(110px, 0.7fr)",
key: "totalRechargeUsdt",
label: "累充USDT",
sortable: true,
sortField: "total_recharge_usdt",
render: (item) => formatUSDTMicro(item.totalRechargeUsdtMicro),
width: "minmax(130px, 0.8fr)",
},
{
key: "updatedAtMs",
@ -81,7 +89,7 @@ export function HostCoinSellersPage() {
width: "minmax(90px, 0.6fr)",
},
...baseColumns.slice(2),
page.abilities.canStockCredit
page.abilities.canStockCredit || page.abilities.canLedger
? {
key: "actions",
label: "操作",
@ -113,6 +121,20 @@ export function HostCoinSellersPage() {
}),
};
}
if (column.sortable) {
return {
...column,
header: (
<SortHeader
field={column.sortField}
label={column.label}
sortBy={page.sortBy}
sortDirection={page.sortDirection}
onToggle={page.changeSort}
/>
),
};
}
return column;
});
const regionFilter = createRegionColumnFilter({
@ -121,15 +143,23 @@ export function HostCoinSellersPage() {
value: page.regionId,
onChange: page.changeRegionId,
});
const toolbarActions = page.abilities.canCreate
? [{ icon: <Add fontSize="small" />, label: "添加币商", onClick: page.openCreateSeller, variant: "primary" }]
: [];
const leadingActions = [
page.abilities.canExchangeRate
? { icon: <SettingsOutlined fontSize="small" />, label: "工资兑换比例", onClick: page.openRateSettings }
: null,
].filter(Boolean);
const toolbarActions = [
page.abilities.canCreate
? { icon: <Add fontSize="small" />, label: "添加币商", onClick: page.openCreateSeller, variant: "primary" }
: null,
].filter(Boolean);
return (
<section className={styles.root}>
<div className={styles.contentPanel}>
<HostOrgToolbar
actions={toolbarActions}
leadingActions={leadingActions}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
@ -138,7 +168,7 @@ export function HostCoinSellersPage() {
columns={columns}
context={{ page, regionOptions: page.regionOptions }}
items={items}
minWidth="1040px"
minWidth="930px"
pagination={
total > 0
? {
@ -190,7 +220,7 @@ export function HostCoinSellersPage() {
onSubmit={page.submitEditSeller}
title="编辑联系方式"
>
<TextField disabled label="用户 ID" required type="number" value={page.editForm.targetUserId} />
<TextField disabled label="用户 ID" required value={page.editForm.targetUserId} />
<TextField
disabled={updateDisabled}
label="联系方式"
@ -274,6 +304,104 @@ export function HostCoinSellersPage() {
/>
</AdminFormSection>
</HostOrgActionModal>
<SideDrawer
className={styles.ledgerDrawer}
contentClassName={styles.ledgerDrawerBody}
open={page.activeAction === "ledger"}
title={`币商流水 - ${sellerDrawerTitle(page.selectedSeller)}`}
width="wide"
onClose={page.closeAction}
>
{page.selectedSeller ? (
<CoinSellerLedgerTable lockedSeller={page.selectedSeller} sellerUserId={page.selectedSeller.userId} />
) : null}
</SideDrawer>
<SideDrawer
actions={
<>
<Button onClick={page.closeAction}>取消</Button>
<Button
disabled={!page.abilities.canExchangeRate || page.loadingAction === "coin-seller-rates"}
variant="contained"
onClick={page.submitRateSettings}
>
保存
</Button>
</>
}
open={page.activeAction === "rates"}
title="币商工资兑换比例"
width="wide"
onClose={page.closeAction}
>
<form className={styles.rateDrawerForm} onSubmit={page.submitRateSettings}>
<TextField
disabled={page.loadingRegions || page.loadingRates}
label="区域"
required
select
value={page.rateRegionId}
onChange={(event) => page.changeRateRegionId(event.target.value)}
>
{page.regionOptions.map((region) => (
<MenuItem key={region.value} value={region.value}>
{region.label}
</MenuItem>
))}
</TextField>
<div className={styles.rateTierHeader}>
<span>区间配置</span>
<Button size="small" variant="outlined" onClick={page.addRateTier}>
添加区间
</Button>
</div>
<div className={styles.rateTierList}>
{page.rateTiers.map((tier, index) => (
<div className={styles.rateTierRow} key={`${index}-${tier.sortOrder}`}>
<TextField
label="min USD"
required
value={tier.minUsd}
onChange={(event) => page.updateRateTier(index, { minUsd: event.target.value })}
/>
<TextField
label="max USD"
required
value={tier.maxUsd}
onChange={(event) => page.updateRateTier(index, { maxUsd: event.target.value })}
/>
<TextField
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
label="coin per USD"
required
value={tier.coinPerUsd}
onChange={(event) =>
page.updateRateTier(index, { coinPerUsd: event.target.value })
}
/>
<div className={styles.rateTierSwitch}>
<AdminSwitch
checked={tier.enabled !== false}
checkedLabel="启用"
label="状态"
uncheckedLabel="停用"
onChange={(event) => page.updateRateTier(index, { enabled: event.target.checked })}
/>
</div>
<AdminActionIconButton
disabled={page.rateTiers.length <= 1}
label="删除区间"
onClick={() => page.removeRateTier(index)}
>
<DeleteOutlineOutlined fontSize="small" />
</AdminActionIconButton>
</div>
))}
</div>
</form>
</SideDrawer>
</section>
);
}
@ -310,20 +438,32 @@ function SellerStatusSwitch({ item, page }) {
function SellerContact({ item, page }) {
const contact = String(item.contact || "").trim();
if (!page?.abilities.canUpdate) {
return <span className={contact ? styles.inlineValue : styles.contactPlaceholder}>{contact || "未填写"}</span>;
}
return (
<button className={styles.contactButton} type="button" onClick={() => page.openEditSeller(item)}>
{contact || "填写联系方式"}
</button>
<InlineEditableCell
buttonTitle="点击编辑联系方式"
displayValue={contact || (page?.abilities.canUpdate ? "填写联系方式" : "未填写")}
editable={Boolean(page?.abilities.canUpdate)}
emptyLabel={page?.abilities.canUpdate ? "填写联系方式" : "未填写"}
inputProps={{ maxLength: 128 }}
maxWidth="170px"
normalizeValue={(value) => String(value ?? "").trim()}
saving={Boolean(page?.contactSavingIds?.[item.userId])}
value={contact}
variant="link"
width="100%"
onSave={(nextValue) => page.saveSellerContact(item, nextValue)}
/>
);
}
function SellerActions({ item, page }) {
return (
<AdminRowActions>
{page.abilities.canLedger ? (
<AdminActionIconButton label="币商流水" sx={coinLedgerActionSx} onClick={() => page.openSellerLedger(item)}>
<ReceiptLongOutlined fontSize="small" />
</AdminActionIconButton>
) : null}
{page.abilities.canStockCredit ? (
<AdminActionIconButton
disabled={item.status !== "active" || page.loadingAction === `coin-seller-stock-${item.userId}`}
@ -338,6 +478,15 @@ function SellerActions({ item, page }) {
);
}
function sellerDrawerTitle(seller) {
if (!seller) {
return "";
}
const name = seller.username || "-";
const displayId = seller.displayUserId || seller.userId || "";
return displayId ? `${name} / ${displayId}` : name;
}
function regionName(item, regionOptions = []) {
if (item.regionName) {
return item.regionName;
@ -350,6 +499,59 @@ function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}
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);
}
function SortHeader({ 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>
);
}
const coinLedgerActionSx = {
borderColor: "rgba(37, 99, 235, 0.28)",
backgroundColor: "rgba(37, 99, 235, 0.08)",
color: "#1d4ed8",
"&:hover": {
borderColor: "rgba(37, 99, 235, 0.48)",
backgroundColor: "rgba(37, 99, 235, 0.14)",
color: "#1e40af",
},
"& .MuiSvgIcon-root": {
color: "#2563eb",
},
};
const coinCreditActionSx = {
borderColor: "rgba(245, 158, 11, 0.45)",
backgroundColor: "rgba(251, 191, 36, 0.16)",

View File

@ -0,0 +1,164 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, test, vi } from "vitest";
import { HostCoinSellersPage } from "./HostCoinSellersPage.jsx";
import { useHostCoinSellersPage } from "@/features/host-org/hooks/useHostCoinSellersPage.js";
const mocks = vi.hoisted(() => ({
ledgerTable: vi.fn(),
openSellerLedger: vi.fn(),
}));
vi.mock("@/features/host-org/hooks/useHostCoinSellersPage.js", () => ({
useHostCoinSellersPage: vi.fn(),
}));
vi.mock("@/features/operations/components/CoinSellerLedgerTable.jsx", () => ({
CoinSellerLedgerTable: (props) => {
mocks.ledgerTable(props);
return <div data-testid="coin-seller-ledger-table">{props.sellerUserId}</div>;
},
}));
afterEach(() => {
vi.clearAllMocks();
});
test("coin seller row action opens seller ledger with current seller", () => {
vi.mocked(useHostCoinSellersPage).mockReturnValue(pageFixture());
render(<HostCoinSellersPage />);
const ledgerButton = screen.getAllByLabelText("币商流水").find((node) => node.tagName.toLowerCase() === "button");
fireEvent.click(ledgerButton);
expect(mocks.openSellerLedger).toHaveBeenCalledWith(expect.objectContaining({ userId: "3001" }));
});
test("coin seller table shows cumulative USDT and hides creator id column", () => {
vi.mocked(useHostCoinSellersPage).mockReturnValue(pageFixture());
render(<HostCoinSellersPage />);
expect(screen.queryByText("创建人 ID")).not.toBeInTheDocument();
expect(screen.getByText("累充USDT")).toBeInTheDocument();
expect(screen.getByText("12.5")).toBeInTheDocument();
});
test("coin seller balance and cumulative USDT headers request sorting", () => {
const changeSort = vi.fn();
vi.mocked(useHostCoinSellersPage).mockReturnValue(pageFixture({ changeSort }));
render(<HostCoinSellersPage />);
fireEvent.click(screen.getByLabelText("按币商余额降序排序"));
fireEvent.click(screen.getByLabelText("按累充USDT降序排序"));
expect(changeSort).toHaveBeenCalledWith("merchant_balance");
expect(changeSort).toHaveBeenCalledWith("total_recharge_usdt");
});
test("coin seller contact edits inline and saves on blur", () => {
const saveSellerContact = vi.fn();
vi.mocked(useHostCoinSellersPage).mockReturnValue(
pageFixture({
abilities: { ...pageFixture().abilities, canUpdate: true },
saveSellerContact,
}),
);
render(<HostCoinSellersPage />);
fireEvent.click(screen.getByRole("button", { name: "菲律賓 +63" }));
const input = screen.getByDisplayValue("菲律賓 +63");
fireEvent.change(input, { target: { value: "+63" } });
fireEvent.blur(input);
expect(saveSellerContact).toHaveBeenCalledWith(expect.objectContaining({ userId: "3001" }), "+63");
});
test("coin seller ledger drawer passes locked seller id to shared table", () => {
const seller = sellerFixture();
vi.mocked(useHostCoinSellersPage).mockReturnValue(pageFixture({ activeAction: "ledger", selectedSeller: seller }));
render(<HostCoinSellersPage />);
expect(screen.getByTestId("coin-seller-ledger-table")).toHaveTextContent("3001");
expect(mocks.ledgerTable).toHaveBeenCalledWith(expect.objectContaining({ lockedSeller: seller, sellerUserId: "3001" }));
});
function pageFixture(patch = {}) {
return {
abilities: {
canCreate: false,
canExchangeRate: false,
canLedger: true,
canStockCredit: false,
canUpdate: false,
canView: true,
},
activeAction: "",
addRateTier: vi.fn(),
changeQuery: vi.fn(),
changeRateRegionId: vi.fn(),
changeRegionId: vi.fn(),
changeSort: vi.fn(),
changeStatus: vi.fn(),
closeAction: vi.fn(),
contactSavingIds: {},
createForm: { contact: "", targetUserId: "" },
data: { items: [sellerFixture()], page: 1, pageSize: 50, total: 1 },
editForm: { contact: "", targetUserId: "" },
error: null,
loading: false,
loadingAction: "",
loadingRates: false,
loadingRegions: false,
openCreateSeller: vi.fn(),
openEditSeller: vi.fn(),
openRateSettings: vi.fn(),
openSellerLedger: mocks.openSellerLedger,
openStockCredit: vi.fn(),
page: 1,
query: "",
rateRegionId: "",
rateTiers: [],
regionId: "",
regionOptions: [],
reload: vi.fn(),
removeRateTier: vi.fn(),
resetFilters: vi.fn(),
saveSellerContact: vi.fn(),
selectedSeller: null,
setCreateForm: vi.fn(),
setEditForm: vi.fn(),
setPage: vi.fn(),
setStockForm: vi.fn(),
status: "",
stockForm: { coinAmount: "", reason: "", rechargeAmount: "", type: "usdt_purchase" },
sortBy: "",
sortDirection: "",
submitCreateSeller: vi.fn(),
submitEditSeller: vi.fn(),
submitRateSettings: vi.fn(),
submitStockCredit: vi.fn(),
toggleSeller: vi.fn(),
updateRateTier: vi.fn(),
...patch,
};
}
function sellerFixture() {
return {
avatar: "",
contact: "菲律賓 +63",
displayUserId: "164425",
merchantAssetType: "COIN_SELLER_COIN",
merchantBalance: 9400000,
regionId: 1,
status: "active",
totalRechargeUsdtMicro: 12500000,
updatedAtMs: 1760000000000,
userId: "3001",
username: "Flower92",
};
}

View File

@ -1,8 +1,11 @@
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 { HostOrgEntity, HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
import { HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
import { useHostHostsPage } from "@/features/host-org/hooks/useHostHostsPage.js";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
@ -21,6 +24,12 @@ const hostColumns = [
render: (item) => <HostStatusSwitch item={item} />,
width: "minmax(90px, 0.7fr)",
},
{
key: "diamond",
label: "钻石",
render: (item) => formatNumber(item.diamond),
width: "minmax(110px, 0.7fr)",
},
{
key: "regionId",
label: "区域",
@ -30,21 +39,8 @@ const hostColumns = [
{
key: "currentAgencyId",
label: "当前 Agency",
render: (item) => (
<HostOrgEntity id={item.currentAgencyId || ""} name={item.currentAgencyName || ""} prefix="Agency " />
),
width: "minmax(190px, 1fr)",
},
{
key: "currentMembershipId",
label: "当前 Membership",
render: (item) =>
item.currentMembershipId ? (
<HostOrgEntity id={item.currentMembershipId} name="Membership" prefix="" />
) : (
"-"
),
width: "minmax(170px, 0.9fr)",
render: (item) => <CurrentAgencyOwner item={item} />,
width: "minmax(220px, 1.1fr)",
},
{
key: "source",
@ -102,6 +98,17 @@ export function HostHostsPage() {
}),
};
}
if (column.key === "diamond") {
return {
...column,
header: (
<DiamondSortHeader
direction={page.diamondSortDirection}
onToggle={page.toggleDiamondSort}
/>
),
};
}
return column;
});
@ -134,6 +141,32 @@ export function HostHostsPage() {
);
}
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
@ -145,6 +178,25 @@ function HostUser({ item }) {
);
}
function CurrentAgencyOwner({ item }) {
if (
!item.currentAgencyId &&
!item.currentAgencyOwnerUserId &&
!item.currentAgencyOwnerDisplayUserId &&
!item.currentAgencyOwnerUsername
) {
return "-";
}
return (
<HostOrgPerson
avatar={item.currentAgencyOwnerAvatar}
displayUserId={item.currentAgencyOwnerDisplayUserId}
fallbackId={item.currentAgencyOwnerUserId}
username={item.currentAgencyOwnerUsername || item.currentAgencyName}
/>
);
}
function HostStatusSwitch({ item }) {
return (
<AdminSwitch

View File

@ -28,6 +28,7 @@ export function useAgencyAbilities() {
return {
canCreate: can(PERMISSIONS.agencyCreate),
canDelete: can(PERMISSIONS.agencyDelete),
canStatus: can(PERMISSIONS.agencyStatus),
canView: can(PERMISSIONS.agencyView),
};
@ -56,6 +57,8 @@ export function useCoinSellerAbilities() {
return {
canCreate: can(PERMISSIONS.coinSellerCreate),
canExchangeRate: can(PERMISSIONS.coinSellerExchangeRate),
canLedger: can(PERMISSIONS.coinSellerLedgerView),
canStockCredit: can(PERMISSIONS.coinSellerStockCredit),
canUpdate: can(PERMISSIONS.coinSellerUpdate),
canView: can(PERMISSIONS.coinSellerView),

View File

@ -0,0 +1,42 @@
import { expect, test } from "vitest";
import { agencyCloseSchema, agencyJoinEnabledSchema, coinSellerStatusSchema } from "./schema";
test("coin seller contact accepts plus sign with large internal user id", () => {
const result = coinSellerStatusSchema.safeParse({
commandId: "coin-seller-edit-test",
contact: "+63",
reason: "update contact",
status: "active",
targetUserId: "320756743338463232",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.contact).toBe("+63");
expect(result.data.targetUserId).toBe("320756743338463232");
}
});
test("agency action schemas preserve large agency ids as strings", () => {
const agencyId = "321170072154411009";
const closeResult = agencyCloseSchema.safeParse({
agencyId,
commandId: "agency-close-test",
reason: "close agency",
});
const joinResult = agencyJoinEnabledSchema.safeParse({
agencyId,
commandId: "agency-join-test",
joinEnabled: false,
reason: "disable join",
});
expect(closeResult.success).toBe(true);
expect(joinResult.success).toBe(true);
if (closeResult.success) {
expect(closeResult.data.agencyId).toBe(agencyId);
}
if (joinResult.success) {
expect(joinResult.data.agencyId).toBe(agencyId);
}
});

View File

@ -12,6 +12,27 @@ const commandContactSchema = z.object({
});
const userIdSchema = z.coerce.number().int().positive("请输入有效用户短 ID");
const entityIdSchema = z.preprocess(
(value) => {
if (value === null || value === undefined) {
return "";
}
return String(value).trim();
},
z.string().min(1, "请输入有效用户 ID").regex(/^\d+$/, "请输入有效用户 ID"),
);
const optionalUserIdSchema = z.preprocess(
(value) => {
if (value === null || value === undefined) {
return 0;
}
if (typeof value === "string" && value.trim() === "") {
return 0;
}
return value;
},
z.coerce.number().int().min(0, "请输入有效用户短 ID"),
);
const regionIdSchema = z.coerce.number().int().positive("请输入有效区域 ID");
const sortOrderSchema = z.coerce.number().int().min(0, "排序不能小于 0").default(0);
const optionalTextSchema = (max: number, message: string) => z.string().trim().max(max, message).optional().default("");
@ -57,12 +78,12 @@ const regionBaseSchema = z
});
export const createBDLeaderSchema = commandContactSchema.extend({
regionId: regionIdSchema,
positionAlias: optionalTextSchema(64, "职位别名不能超过 64 个字符"),
targetUserId: userIdSchema,
});
export const createBDSchema = commandBaseSchema.extend({
parentLeaderUserId: userIdSchema,
parentLeaderUserId: optionalUserIdSchema,
targetUserId: userIdSchema,
});
@ -78,7 +99,7 @@ export const createCoinSellerSchema = commandContactSchema.extend({
export const coinSellerStatusSchema = commandContactSchema.extend({
status: z.enum(["active", "disabled"]),
targetUserId: userIdSchema,
targetUserId: entityIdSchema,
});
export const coinSellerStockCreditSchema = z
@ -103,18 +124,17 @@ export const coinSellerStockCreditSchema = z
export const createAgencySchema = commandBaseSchema.extend({
joinEnabled: z.boolean().default(true),
maxHosts: z.coerce.number().int().min(0, "最大主播数不能小于 0").default(0),
name: z.string().trim().min(1, "请输入 Agency 名称").max(80, "Agency 名称不能超过 80 个字符"),
ownerUserId: userIdSchema,
parentBdUserId: userIdSchema,
parentBdUserId: optionalUserIdSchema,
});
export const agencyCloseSchema = commandBaseSchema.extend({
agencyId: z.coerce.number().int().positive("请输入有效 Agency ID"),
agencyId: entityIdSchema,
});
export const agencyJoinEnabledSchema = commandBaseSchema.extend({
agencyId: z.coerce.number().int().positive("请输入有效 Agency ID"),
agencyId: entityIdSchema,
joinEnabled: z.boolean().default(true),
});

View File

@ -318,12 +318,12 @@ function buildTrackPayload(form) {
function nextRuleLevel(levelTrack) {
const levels = (levelTrack?.rules || []).map((rule) => Number(rule.level || 0));
return Math.max(0, ...levels) + 1;
return levels.length > 0 ? Math.max(...levels) + 1 : 0;
}
function nextTierStart(levelTrack) {
const levels = (levelTrack?.tiers || []).map((tier) => Number(tier.maxLevel || 0));
return Math.max(0, ...levels) + 1;
return levels.length > 0 ? Math.max(...levels) + 1 : 0;
}
function optionalId(value) {

View File

@ -283,6 +283,33 @@
white-space: nowrap;
}
.resourceCell {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
}
.resourceThumb {
display: inline-flex;
width: 42px;
height: 42px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card-strong);
color: var(--text-secondary);
}
.resourceThumb img {
width: 100%;
height: 100%;
object-fit: contain;
}
.statusCell {
display: inline-flex;
min-width: 0;

View File

@ -1,5 +1,6 @@
import Add from "@mui/icons-material/Add";
import EditOutlined from "@mui/icons-material/EditOutlined";
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import MenuItem from "@mui/material/MenuItem";
@ -422,7 +423,7 @@ function RuleFormDialog({ page }) {
</TextField>
<TextField
disabled={editing || !page.abilities.canUpdate}
inputProps={{ min: 1 }}
inputProps={{ min: 0 }}
label="等级"
type="number"
value={form.level}
@ -449,7 +450,7 @@ function RuleFormDialog({ page }) {
<ResourceSelect
disabled={!page.abilities.canUpdate}
label="长徽章素材"
resources={page.badgeResources}
resources={levelBadgeResourcesForTrack(page.badgeResources, form.track)}
value={form.longBadgeResourceId}
onChange={changeField(page.setRuleForm, "longBadgeResourceId")}
/>
@ -519,7 +520,7 @@ function TierFormDialog({ page }) {
</TextField>
<TextField
disabled={!page.abilities.canUpdate}
inputProps={{ min: 1 }}
inputProps={{ min: 0 }}
label="起始等级"
type="number"
value={form.minLevel}
@ -527,7 +528,7 @@ function TierFormDialog({ page }) {
/>
<TextField
disabled={!page.abilities.canUpdate}
inputProps={{ min: 1 }}
inputProps={{ min: 0 }}
label="结束等级"
type="number"
value={form.maxLevel}
@ -646,10 +647,16 @@ function ResourceLabel({ id, resources }) {
return <span className={styles.emptyValue}>未配置</span>;
}
const resource = resources.find((item) => Number(item.resourceId) === Number(id));
const preview = resourceImageURL(resource);
return (
<div className={styles.stack}>
<span className={styles.resourceText}>{resource?.name || resource?.resourceCode || `资源 #${id}`}</span>
<span className={styles.meta}>ID {id}</span>
<div className={styles.resourceCell}>
<span className={styles.resourceThumb}>
{preview ? <img alt="" src={preview} /> : <Inventory2Outlined fontSize="small" />}
</span>
<div className={styles.stack}>
<span className={styles.resourceText}>{resource?.name || resource?.resourceCode || `资源 #${id}`}</span>
<span className={styles.meta}>{resource ? resourceMetaLabel(resource, id) : `ID ${id}`}</span>
</div>
</div>
);
}
@ -682,6 +689,78 @@ function withSelectedResource(resources, value) {
return [{ resourceId: selectedId, name: `资源 #${selectedId}` }, ...resources];
}
function levelBadgeResourcesForTrack(resources, track) {
return (resources || []).filter((resource) => {
const badgeForm = resource.badgeForm || badgeFormFromMetadata(resource.metadataJson);
const badgeKind = resource.badgeKind || badgeKindFromMetadata(resource.metadataJson);
const levelTrack = resource.levelTrack || levelTrackFromMetadata(resource.metadataJson);
return badgeForm === "strip" && badgeKind === "level" && levelTrack === track;
});
}
function resourceMetaLabel(resource, id) {
const badgeKind = resource.badgeKind || badgeKindFromMetadata(resource.metadataJson);
const levelTrack = resource.levelTrack || levelTrackFromMetadata(resource.metadataJson);
if (resource.resourceType === "badge" && badgeKind === "level" && levelTrack) {
return `ID ${id} · 等级徽章 · ${levelTrackLabels[levelTrack] || levelTrack}`;
}
return `ID ${id}`;
}
function resourceImageURL(resource) {
return imageURL(resource?.previewUrl) || imageURL(resource?.assetUrl) || imageURL(resource?.animationUrl);
}
function imageURL(value) {
const url = String(value || "").trim();
if (!url) {
return "";
}
return /\.(avif|gif|jpe?g|png|webp)(\?|#|$)/i.test(url) ? url : "";
}
function badgeFormFromMetadata(metadataJson) {
if (!metadataJson) {
return "";
}
try {
const metadata = JSON.parse(metadataJson);
const badgeForm = String(metadata?.badge_form || "")
.trim()
.toLowerCase();
return badgeForm === "strip" || badgeForm === "tile" ? badgeForm : "";
} catch {
return "";
}
}
function badgeKindFromMetadata(metadataJson) {
if (!metadataJson) {
return "";
}
try {
const metadata = JSON.parse(metadataJson);
return String(metadata?.badge_kind || "").trim().toLowerCase() === "level" ? "level" : "normal";
} catch {
return "";
}
}
function levelTrackFromMetadata(metadataJson) {
if (!metadataJson) {
return "";
}
try {
const metadata = JSON.parse(metadataJson);
const levelTrack = String(metadata?.level_track || "")
.trim()
.toLowerCase();
return levelTrack === "wealth" || levelTrack === "game" || levelTrack === "charm" ? levelTrack : "";
} catch {
return "";
}
}
function levelStatusLabel(status) {
return levelStatusLabels[status] || status || "-";
}

View File

@ -27,7 +27,7 @@ export const levelRuleFormSchema = z
track: trackSchema,
})
.superRefine((value, context) => {
validatePositiveInteger(context, "level", value.level, "等级必须是整数");
validateNonNegativeInteger(context, "level", value.level, "等级必须是非负整数");
validateNonNegativeInteger(context, "requiredValue", value.requiredValue, "升级数值必须是非负整数");
validateOptionalId(context, "longBadgeResourceId", value.longBadgeResourceId, "长徽章素材必须是有效资源");
validateOptionalId(context, "rewardResourceGroupId", value.rewardResourceGroupId, "升级礼物必须是有效资源组");
@ -50,8 +50,8 @@ export const levelTierFormSchema = z
const minLevel = Number(value.minLevel);
const maxLevel = Number(value.maxLevel);
validatePositiveInteger(context, "minLevel", value.minLevel, "起始等级必须是整数");
validatePositiveInteger(context, "maxLevel", value.maxLevel, "结束等级必须是整数");
validateNonNegativeInteger(context, "minLevel", value.minLevel, "起始等级必须是非负整数");
validateNonNegativeInteger(context, "maxLevel", value.maxLevel, "结束等级必须是非负整数");
if (Number.isInteger(minLevel) && Number.isInteger(maxLevel) && maxLevel < minLevel) {
context.addIssue({ code: "custom", message: "结束等级不能小于起始等级", path: ["maxLevel"] });
}

View File

@ -33,10 +33,10 @@ const helpText = {
poolId: "奖池 ID 是幸运礼物规则隔离键。不同奖池拥有独立 RTP、窗口和阶段奖档。",
poolRatePercent: "每笔幸运礼物扣费进入基础奖池的比例,必须大于等于 RTP避免奖池长期亏空。",
userHourlyPayoutCap: "同一用户每小时基础返奖上限。达到上限后,该用户会优先进入 0x 或低倍率。",
userDailyPayoutCap: "同一用户每日基础返奖上限。按 UTC 自然日统计。",
userDailyPayoutCap: "同一用户每日基础返奖上限。按服务端自然日统计。",
deviceDailyPayoutCap: "同一设备每日基础返奖上限。用于限制多账号集中领取。",
roomHourlyPayoutCap: "同一房间每小时基础返奖上限。房间触顶后,新用户也会被该房间上限影响。",
anchorDailyPayoutCap: "同一收礼主播每日基础返奖上限。按 UTC 自然日统计。",
anchorDailyPayoutCap: "同一收礼主播每日基础返奖上限。按服务端自然日统计。",
settlementWindowWager: "RTP 结算窗口按金币流水计算,不按抽数计算,低价和高价礼物会自然等比参与控制。",
stages: "新手、正常、高阶三个阶段必须各自配置概率,抽奖引擎按用户阶段读取对应奖档。",
targetRtpPercent: "基础返奖目标。95 表示长期每消耗 100 金币,基础 RTP 目标返还 95 金币。",

View File

@ -5,7 +5,7 @@ import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { AdminActionIconButton } from "@/shared/ui/AdminListLayout.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import { TimeText } from "@/shared/ui/TimeText.jsx";
import { stageOptions } from "@/features/lucky-gift/constants.js";
import { formFromConfig } from "@/features/lucky-gift/configModel.js";
import styles from "@/features/lucky-gift/lucky-gift.module.css";
@ -44,7 +44,7 @@ export function LuckyGiftConfigSummary({
<SummaryItem label="房间小时">{formatNumber(config?.roomHourlyPayoutCap)}</SummaryItem>
<SummaryItem label="阶段">{formatStages(form.stages)}</SummaryItem>
<SummaryItem label="更新时间">
{config?.createdAtMs ? formatMillis(config.createdAtMs) : configLoading ? "加载中" : "-"}
{config?.createdAtMs ? <TimeText value={config.createdAtMs} /> : configLoading ? "加载中" : "-"}
</SummaryItem>
</div>
</div>

View File

@ -2,8 +2,10 @@ import { afterEach, expect, test, vi } from "vitest";
import { setAccessToken } from "@/shared/api/request";
import {
createCoinAdjustment,
exportCoinSellerLedger,
getGiftDiamondRatios,
listCoinAdjustments,
listCoinSellerLedger,
lookupCoinAdjustmentTarget,
updateGiftDiamondRatios,
} from "./api";
@ -69,3 +71,47 @@ test("coin adjustment APIs use generated admin paths", async () => {
expect(String(createUrl)).toContain("/api/v1/admin/operations/coin-adjustments");
expect(createInit?.method).toBe("POST");
});
test("coin seller ledger API uses generated admin path and filters", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () => new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 20, total: 0 } })),
),
);
await listCoinSellerLedger({
ledger_type: "seller_transfer",
page: 1,
page_size: 20,
seller_user_id: "3001",
});
const [listUrl, listInit] = vi.mocked(fetch).mock.calls[0];
expect(String(listUrl)).toContain("/api/v1/admin/operations/coin-seller-ledger?");
expect(String(listUrl)).toContain("seller_user_id=3001");
expect(String(listUrl)).toContain("ledger_type=seller_transfer");
expect(listInit?.method).toBe("GET");
});
test("coin seller ledger export API sends current filters without pagination", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response("seller,amount\n", { status: 200 })));
await exportCoinSellerLedger({
end_at_ms: "2000",
ledger_type: "admin_stock_credit",
seller_keyword: "HUNTER",
start_at_ms: "1000",
});
const [exportUrl, exportInit] = vi.mocked(fetch).mock.calls[0];
expect(String(exportUrl)).toContain("/api/v1/admin/operations/coin-seller-ledger/export?");
expect(String(exportUrl)).toContain("seller_keyword=HUNTER");
expect(String(exportUrl)).toContain("ledger_type=admin_stock_credit");
expect(String(exportUrl)).toContain("start_at_ms=1000");
expect(String(exportUrl)).toContain("end_at_ms=2000");
expect(String(exportUrl)).not.toContain("page=");
expect(exportInit?.method).toBe("GET");
});

View File

@ -7,6 +7,7 @@ import type {
CoinAdjustmentPayload,
CoinLedgerEntryDto,
CoinLedgerUserDto,
CoinSellerLedgerDto,
PageQuery,
ReportDto,
} from "@/shared/api/types";
@ -33,6 +34,22 @@ export function listCoinLedger(query: PageQuery = {}): Promise<ApiPage<CoinLedge
});
}
export function listCoinSellerLedger(query: PageQuery = {}): Promise<ApiPage<CoinSellerLedgerDto>> {
const endpoint = API_ENDPOINTS.listCoinSellerLedger;
return apiRequest<ApiPage<CoinSellerLedgerDto>>(apiEndpointPath(API_OPERATIONS.listCoinSellerLedger), {
method: endpoint.method,
query,
});
}
export function exportCoinSellerLedger(query: PageQuery = {}): Promise<Response> {
return apiRequest("/v1/admin/operations/coin-seller-ledger/export", {
method: "GET",
query,
raw: true,
});
}
export function listCoinAdjustments(query: PageQuery = {}): Promise<ApiPage<CoinAdjustmentDto>> {
const endpoint = API_ENDPOINTS.listCoinAdjustments;
return apiRequest<ApiPage<CoinAdjustmentDto>>(apiEndpointPath(API_OPERATIONS.listCoinAdjustments), {
@ -69,7 +86,7 @@ export function createCoinAdjustment(payload: CoinAdjustmentPayload): Promise<Co
}
export function getGiftDiamondRatios(regionId: string | number): Promise<GiftDiamondRatioResponse> {
return apiRequest<GiftDiamondRatioResponse>("/api/v1/admin/operations/gift-diamond-ratios", {
return apiRequest<GiftDiamondRatioResponse>("/v1/admin/operations/gift-diamond-ratios", {
method: "GET",
query: { region_id: regionId },
});
@ -79,7 +96,7 @@ export function updateGiftDiamondRatios(payload: {
regionId: number;
ratios: Record<string, string>;
}): Promise<GiftDiamondRatioResponse> {
return apiRequest<GiftDiamondRatioResponse, typeof payload>("/api/v1/admin/operations/gift-diamond-ratios", {
return apiRequest<GiftDiamondRatioResponse, typeof payload>("/v1/admin/operations/gift-diamond-ratios", {
body: payload,
method: "PUT",
});

View File

@ -0,0 +1,278 @@
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import { useMemo, useState } from "react";
import { exportCoinSellerLedger, listCoinSellerLedger } from "@/features/operations/api";
import styles from "@/features/operations/operations.module.css";
import { downloadCsv } from "@/shared/api/download";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import {
AdminFilterResetButton,
AdminListBody,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { formatMillis } from "@/shared/utils/time.js";
const pageSize = 50;
const ledgerTypeOptions = [
{ label: "后台入账", value: "admin_stock_credit" },
{ label: "币商转用户", value: "seller_transfer" },
{ label: "工资转币商", value: "salary_transfer_received" },
];
const ledgerTypeLabels = {
admin_stock_credit: "后台入账",
salary_transfer_received: "工资转币商",
seller_transfer: "币商转用户",
};
const bizTypeLabels = {
coin_seller_coin_compensation: "金币补偿",
coin_seller_stock_purchase: "币商进货",
coin_seller_transfer: "币商转用户",
salary_transfer_to_coin_seller: "工资转币商",
};
const baseColumns = [
{
key: "seller",
label: "币商信息",
render: (entry) => <UserCell user={entry.seller} />,
width: "minmax(240px, 1.2fr)",
},
{
key: "ledgerType",
label: "流水类型",
render: (entry) => ledgerTypeLabel(entry),
width: "minmax(150px, 0.75fr)",
},
{
key: "amount",
label: "转账金额",
render: (entry) => <AmountValue entry={entry} />,
width: "minmax(130px, 0.65fr)",
},
{
key: "receiver",
label: "收款人信息",
render: (entry) => <UserCell user={entry.receiver} />,
width: "minmax(240px, 1.2fr)",
},
{
key: "operator",
label: "操作人",
render: (entry) => <OperatorCell entry={entry} />,
width: "minmax(130px, 0.65fr)",
},
{
key: "sellerBalanceAfter",
label: "币商余额",
render: (entry) => formatNumber(entry.sellerBalanceAfter),
width: "minmax(120px, 0.65fr)",
},
{
key: "createdAtMs",
label: "转账时间",
render: (entry) => formatMillis(entry.createdAtMs),
width: "minmax(170px, 0.85fr)",
},
];
export function CoinSellerLedgerTable({ lockedSeller = null, sellerUserId = "" }) {
const fixedSellerUserId = String(sellerUserId || lockedSeller?.userId || "").trim();
const [page, setPage] = useState(1);
const [exporting, setExporting] = useState(false);
const [sellerKeyword, setSellerKeyword] = useState("");
const [ledgerType, setLedgerType] = useState("");
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
const { showToast } = useToast();
const filters = useMemo(
() => ({
end_at_ms: timeRange.endMs || "",
ledger_type: ledgerType,
seller_keyword: fixedSellerUserId ? "" : sellerKeyword,
seller_user_id: fixedSellerUserId,
start_at_ms: timeRange.startMs || "",
}),
[fixedSellerUserId, ledgerType, sellerKeyword, timeRange.endMs, timeRange.startMs],
);
const query = usePaginatedQuery({
errorMessage: "获取币商流水失败",
fetcher: listCoinSellerLedger,
filters,
page,
pageSize,
queryKey: ["coin-seller-ledger", filters, page],
});
const data = query.data || { items: [], total: 0, page, pageSize };
const items = data.items || [];
const total = data.total || 0;
const hasActiveFilters = Boolean(sellerKeyword || ledgerType || timeRange.startMs || timeRange.endMs);
const changeSellerKeyword = (value) => {
setSellerKeyword(value);
setPage(1);
};
const changeLedgerType = (value) => {
setLedgerType(value);
setPage(1);
};
const changeTimeRange = (nextRange) => {
setTimeRange(nextRange);
setPage(1);
};
const resetFilters = () => {
setSellerKeyword("");
setLedgerType("");
setTimeRange({ endMs: "", startMs: "" });
setPage(1);
};
const downloadLedger = async () => {
setExporting(true);
try {
await downloadCsv(() => exportCoinSellerLedger(filters), "hyapp-coin-seller-ledger.csv");
showToast("币商流水已导出", "success");
} catch (err) {
showToast(err?.message || "导出币商流水失败", "error");
} finally {
setExporting(false);
}
};
const tableColumns = baseColumns
.filter((column) => !(column.key === "seller" && fixedSellerUserId))
.map((column) => {
if (column.key === "seller" && !fixedSellerUserId) {
return {
...column,
filter: createTextColumnFilter({
placeholder: "长 ID / 短 ID / 名称",
value: sellerKeyword,
onChange: changeSellerKeyword,
}),
};
}
if (column.key === "ledgerType") {
return {
...column,
filter: createOptionsColumnFilter({
emptyLabel: "全部",
options: ledgerTypeOptions,
placeholder: "流水类型",
value: ledgerType,
onChange: changeLedgerType,
}),
};
}
return {
...column,
};
});
return (
<div className={styles.ledgerTableShell}>
<AdminListToolbar
filters={
<>
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
<AdminFilterResetButton
disabled={!hasActiveFilters}
onClick={resetFilters}
/>
</>
}
actions={
<Button disabled={exporting} startIcon={<FileDownloadOutlined fontSize="small" />} onClick={downloadLedger}>
{exporting ? "导出中" : "导出"}
</Button>
}
/>
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
<AdminListBody>
<DataTable
columns={tableColumns}
emptyLabel="当前无币商流水"
items={items}
minWidth={fixedSellerUserId ? "890px" : "1160px"}
pagination={
total > 0
? {
page,
pageSize: data.pageSize || pageSize,
total,
onPageChange: setPage,
}
: undefined
}
rowKey={(entry) => entry.entryId}
/>
</AdminListBody>
</DataState>
</div>
);
}
function OperatorCell({ entry }) {
if (entry.ledgerType !== "admin_stock_credit") {
return "-";
}
const operator = entry.operator || {};
const operatorId = String(operator.adminId || entry.operatorUserId || "").trim();
const name = operator.username || operator.name || operatorId || "-";
return (
<span className={styles.identityText}>
<span className={styles.primaryText}>{name}</span>
{operatorId && operatorId !== name ? <span className={styles.meta}>{operatorId}</span> : null}
</span>
);
}
function UserCell({ user = {} }) {
const name = user.username || "-";
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.primaryText}>{name}</span>
<span className={styles.meta}>{userIdText(user)}</span>
</span>
</div>
);
}
function AmountValue({ entry }) {
const expense = entry.direction === "expense" || Number(entry.availableDelta || 0) < 0;
const sign = expense ? "-" : "+";
return (
<span className={`${styles.amount} ${expense ? styles.amountExpense : styles.amountIncome}`}>
{sign}
{formatNumber(entry.amount ?? Math.abs(Number(entry.availableDelta || 0)))}
</span>
);
}
function ledgerTypeLabel(entry) {
return ledgerTypeLabels[entry.ledgerType] || bizTypeLabels[entry.bizType] || entry.ledgerType || entry.bizType || "-";
}
function userIdText(user = {}) {
const displayUserId = String(user.displayUserId || "").trim();
const userId = String(user.userId || "").trim();
if (displayUserId && userId && displayUserId !== userId) {
return `${displayUserId} / ${userId}`;
}
return displayUserId || userId || "-";
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}

View File

@ -16,6 +16,26 @@
color: var(--danger);
}
.ledgerTableShell {
display: flex;
flex: 1 1 auto;
min-height: 0;
flex-direction: column;
}
.ledgerTableShell :global(.data-state) {
flex: 1 1 auto;
min-height: 0;
}
.ledgerTableShell :global(.table-frame) {
min-height: 0;
}
.ledgerTableShell :global(.table-scroll) {
overflow: auto;
}
.adjustmentAmount {
font-size: calc(var(--admin-font-size) + 4px);
}
@ -185,28 +205,33 @@
background: var(--bg-card-strong);
}
.ratioBody {
padding: var(--space-5);
background: var(--bg-card);
}
.ratioPanel {
display: grid;
gap: var(--space-5);
max-width: 760px;
width: min(100%, 980px);
gap: var(--space-4);
}
.ratioHeader {
display: flex;
align-items: center;
gap: var(--space-3);
color: var(--text-primary);
}
.ratioHeader h2 {
margin: 0;
font-size: 18px;
font-size: 16px;
font-weight: 800;
line-height: 1.4;
}
.ratioGrid {
display: grid;
grid-template-columns: repeat(3, minmax(160px, 1fr));
grid-template-columns: repeat(3, minmax(180px, 1fr));
gap: var(--space-4);
}

View File

@ -12,8 +12,8 @@ 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 { formatMillis } from "@/shared/utils/time.js";
import styles from "@/features/operations/operations.module.css";
const pageSize = 50;
@ -47,7 +47,7 @@ const columns = [
key: "createdAtMs",
label: "时间",
width: "minmax(170px, 0.85fr)",
render: (entry) => formatMillis(entry.createdAtMs),
render: (entry) => <TimeText value={entry.createdAtMs} />,
},
];
@ -73,7 +73,6 @@ const metadataLabels = {
gift_count: "礼物数量",
gift_id: "礼物 ID",
gift_name: "礼物名称",
gift_point_added: "礼物积分",
heat_value: "热度",
operator_user_id: "操作人",
op_type: "操作类型",
@ -253,7 +252,7 @@ function CoinLedgerDetailDrawer({ entry, onClose, open }) {
detailRow("流水 ID", entry.entryId),
detailRow("业务类型", bizTypeLabel(entry.bizType)),
detailRow("方向", directionLabel(entry.direction)),
detailRow("发生时间", formatMillis(entry.createdAtMs)),
detailRow("发生时间", <TimeText value={entry.createdAtMs} />),
]}
title="交易信息"
/>
@ -335,7 +334,6 @@ function businessContextRows(entry, metadata) {
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, ["gift_point_added", "giftPointAdded"]),
metadataRow("热度", metadata, ["heat_value", "heatValue"]),
metadataRow("账单 ID", metadata, ["billing_receipt_id", "billingReceiptId"]),
metadataRow("价格版本", metadata, ["price_version", "priceVersion"]),

View File

@ -0,0 +1,10 @@
import { CoinSellerLedgerTable } from "@/features/operations/components/CoinSellerLedgerTable.jsx";
import { AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
export function CoinSellerLedgerPage() {
return (
<AdminListPage>
<CoinSellerLedgerTable />
</AdminListPage>
);
}

View File

@ -1,5 +1,5 @@
import PercentOutlined from "@mui/icons-material/PercentOutlined";
import SaveOutlined from "@mui/icons-material/SaveOutlined";
import InputAdornment from "@mui/material/InputAdornment";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { useCallback, useEffect, useMemo, useState } from "react";
@ -8,7 +8,7 @@ import { useGiftDiamondAbilities } from "@/features/operations/permissions.js";
import styles from "@/features/operations/operations.module.css";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
import { AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
@ -119,13 +119,10 @@ export function GiftDiamondRatioPage() {
}
/>
<DataState error={query.error} loading={query.loading || loadingRegions} onRetry={query.reload}>
<AdminListBody>
<div className={styles.ratioBody}>
<section className={styles.ratioPanel}>
<div className={styles.ratioHeader}>
<PercentOutlined fontSize="small" />
<div>
<h2>{selectedRegionLabel}</h2>
</div>
<h2>{selectedRegionLabel}</h2>
</div>
<div className={styles.ratioGrid}>
{giftTypes.map((giftType) => (
@ -136,14 +133,16 @@ export function GiftDiamondRatioPage() {
size="small"
value={form[giftType.value] || ""}
inputProps={{ max: 100, min: 0, step: "0.01" }}
InputProps={{
endAdornment: <InputAdornment position="end">%</InputAdornment>,
}}
disabled={!abilities.canUpdate || saving}
onChange={(event) => changeRatio(giftType.value, event.target.value)}
helperText="%"
/>
))}
</div>
</section>
</AdminListBody>
</div>
</DataState>
</AdminListPage>
);

View File

@ -9,6 +9,14 @@ export const operationsRoutes = [
path: "/operations/coin-ledger",
permission: PERMISSIONS.coinLedgerView,
},
{
label: "币商流水",
loader: () => import("./pages/CoinSellerLedgerPage.jsx").then((module) => module.CoinSellerLedgerPage),
menuCode: MENU_CODES.operationCoinSellerLedger,
pageKey: "operation-coin-seller-ledger",
path: "/operations/coin-seller-ledger",
permission: PERMISSIONS.coinSellerLedgerView,
},
{
label: "金币增减",
loader: () => import("./pages/CoinAdjustmentPage.jsx").then((module) => module.CoinAdjustmentPage),

View File

@ -1,16 +1,29 @@
import { useMemo, useState } from "react";
import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined";
import DownloadOutlined from "@mui/icons-material/DownloadOutlined";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import Tooltip from "@mui/material/Tooltip";
import { useEffect, useMemo, useState } from "react";
import { listRechargeBills } from "@/features/payment/api";
import {
AdminListBody,
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
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 { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import styles from "@/features/payment/payment.module.css";
const pageSize = 50;
const exportPageSize = 100;
const statusOptions = [
["", "全部状态"],
["succeeded", "成功"],
@ -18,57 +31,48 @@ const statusOptions = [
const rechargeTypeOptions = [
["", "全部途径"],
["coin_seller_transfer", "币商充值"],
["google_play_recharge", "谷歌充值"],
];
const columns = [
{
key: "bill",
label: "账单",
width: "minmax(240px, 1.4fr)",
render: (bill) => <Stack primary={bill.transactionId} secondary={bill.commandId || bill.externalRef || "-"} />,
width: "minmax(82px, 0.35fr)",
render: (bill) => <BillCopyButton value={bill.transactionId} />,
},
{
key: "userId",
label: "用户 ID",
width: "minmax(120px, 0.65fr)",
render: (bill) => bill.userId || "-",
label: "用户信息",
width: "minmax(190px, 1fr)",
render: (bill) => <BillUser bill={bill} type="user" />,
},
{
key: "sellerUserId",
label: "币商 ID",
width: "minmax(120px, 0.65fr)",
render: (bill) => bill.sellerUserId || "-",
label: "币商信息",
width: "minmax(190px, 1fr)",
render: (bill) => <BillUser bill={bill} type="seller" />,
},
{
key: "amount",
label: "金币 / 金额",
width: "minmax(150px, 0.75fr)",
render: (bill) => (
<Stack
primary={formatNumber(bill.coinAmount)}
secondary={formatMoney(bill.usdMinorAmount, bill.currencyCode)}
/>
),
key: "coinAmount",
label: "金币",
width: "minmax(120px, 0.6fr)",
render: (bill) => formatNumber(bill.coinAmount),
},
{
key: "policy",
label: "兑换口径",
width: "minmax(170px, 0.9fr)",
render: (bill) => (
<Stack
primary={bill.policyVersion || "-"}
secondary={`${formatNumber(bill.exchangeCoinAmount)} / ${formatMoney(bill.exchangeUsdMinorAmount, bill.currencyCode)}`}
/>
),
key: "money",
label: "金额",
width: "minmax(120px, 0.6fr)",
render: (bill) => billMoneyText(bill),
},
{
key: "region",
label: "区域",
width: "minmax(110px, 0.55fr)",
render: (bill) => (
label: "国家/区域",
width: "minmax(150px, 0.75fr)",
render: (bill, _index, context) => (
<Stack
primary={bill.targetRegionId ? `区域 ${bill.targetRegionId}` : "-"}
secondary={bill.sellerRegionId ? `来源 ${bill.sellerRegionId}` : "-"}
primary={billCountryName(bill)}
secondary={regionName(bill.targetRegionId, context?.regionNames)}
/>
),
},
@ -97,18 +101,27 @@ export function PaymentBillListPage() {
const [keyword, setKeyword] = useState("");
const [status, setStatus] = useState("");
const [rechargeType, setRechargeType] = useState("");
const [regionId, setRegionId] = useState("");
const [userId, setUserId] = useState("");
const [sellerUserId, setSellerUserId] = useState("");
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
const [exporting, setExporting] = useState(false);
const { loadingRegions, regionOptions } = useRegionOptions();
const { showToast } = useToast();
const regionNames = useMemo(() => regionNameMap(regionOptions), [regionOptions]);
const filters = useMemo(
() => ({
end_at_ms: timeRange.endMs,
keyword,
recharge_type: rechargeType,
region_id: regionId,
seller_user_id: sellerUserId,
start_at_ms: timeRange.startMs,
status,
user_id: userId,
}),
[keyword, rechargeType, sellerUserId, status, userId],
[keyword, rechargeType, regionId, sellerUserId, status, timeRange.endMs, timeRange.startMs, userId],
);
const query = usePaginatedQuery({
errorMessage: "获取账单列表失败",
@ -126,6 +139,22 @@ export function PaymentBillListPage() {
setter(value);
setPage(1);
};
const changeTimeRange = (value) => {
setTimeRange(value);
setPage(1);
};
const downloadBills = async () => {
setExporting(true);
try {
const bills = await fetchAllRechargeBills(filters);
downloadTextFile(createBillsCsv(bills, regionNames), `hyapp-payment-bills-${filenameTimestamp()}.csv`);
showToast(`已导出 ${bills.length} 条账单`, "success");
} catch (err) {
showToast(err?.message || "导出账单失败", "error");
} finally {
setExporting(false);
}
};
const tableColumns = columns.map((column) => {
if (column.key === "bill") {
@ -169,6 +198,17 @@ export function PaymentBillListPage() {
}),
};
}
if (column.key === "region") {
return {
...column,
filter: createRegionColumnFilter({
loading: loadingRegions,
options: regionOptions,
value: regionId,
onChange: resetPage(setRegionId),
}),
};
}
if (column.key === "status") {
return {
...column,
@ -185,12 +225,32 @@ export function PaymentBillListPage() {
return (
<AdminListPage>
<AdminListToolbar
actions={
<Button
disabled={exporting}
startIcon={<DownloadOutlined fontSize="small" />}
onClick={downloadBills}
>
{exporting ? "导出中" : "导出"}
</Button>
}
filters={
<TimeRangeFilter
disabled={exporting}
label="创建时间"
value={timeRange}
onChange={changeTimeRange}
/>
}
/>
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
<AdminListBody>
<DataTable
columns={tableColumns}
context={{ regionNames }}
items={items}
minWidth="1260px"
minWidth="1380px"
pagination={
total > 0
? {
@ -218,6 +278,58 @@ function Stack({ primary, secondary }) {
);
}
function BillCopyButton({ value }) {
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) {
return undefined;
}
const timer = window.setTimeout(() => setCopied(false), 1200);
return () => window.clearTimeout(timer);
}, [copied]);
const copyBill = async () => {
if (!value) {
return;
}
await copyText(String(value));
setCopied(true);
};
return (
<Tooltip arrow title={copied ? "已复制" : "复制账单"}>
<span className={styles.copyWrap}>
<IconButton disabled={!value} label="复制账单" onClick={copyBill}>
<ContentCopyOutlined fontSize="small" />
</IconButton>
</span>
</Tooltip>
);
}
function BillUser({ bill, type }) {
const fallbackId = type === "seller" ? bill.sellerUserId : bill.userId;
const profile = normalizeBillUser(type === "seller" ? bill.seller : bill.user, fallbackId);
if (!profile.userId && !profile.displayUserId && !profile.username) {
return "-";
}
const shortId = profile.displayUserId || profile.userId || "-";
const name = profile.username || "-";
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{profile.avatar ? <img alt="" src={profile.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.identityId}>{name}</span>
<span className={styles.identityName}>{shortId}</span>
</span>
</div>
);
}
function StatusBadge({ status }) {
const tone = status === "succeeded" ? "succeeded" : status === "failed" ? "danger" : "stopped";
return (
@ -259,6 +371,160 @@ function formatMoney(value, currency = "USD") {
})}`;
}
function billMoneyText(bill) {
if (bill?.rechargeType === "coin_seller_transfer") {
return "";
}
return formatMoney(bill?.usdMinorAmount, bill?.currencyCode);
}
function normalizeBillUser(profile, fallbackId) {
const source = profile || {};
return {
avatar: source.avatar || "",
countryCode: source.countryCode || source.country_code || "",
countryDisplayName: source.countryDisplayName || source.country_display_name || "",
countryName: source.countryName || source.country_name || "",
displayUserId: source.displayUserId || source.display_user_id || "",
userId: String(source.userId || source.user_id || fallbackId || ""),
username: source.username || source.name || "",
};
}
function billCountryName(bill) {
const user = normalizeBillUser(bill?.user, bill?.userId);
return user.countryDisplayName || user.countryName || user.countryCode || "-";
}
function regionNameMap(regionOptions = []) {
return Object.fromEntries(
regionOptions.map((region) => [String(region.regionId || region.value), region.name || region.label]),
);
}
function regionName(regionId, regionNames = {}) {
if (!regionId) {
return "-";
}
return regionNames[String(regionId)] || `区域 ${regionId}`;
}
async function fetchAllRechargeBills(filters) {
const items = [];
let page = 1;
while (true) {
const data = await listRechargeBills({ ...filters, page, page_size: exportPageSize });
const batch = Array.isArray(data?.items) ? data.items : [];
items.push(...batch);
const total = Number(data?.total || 0);
if (!batch.length || (total > 0 && items.length >= total) || batch.length < exportPageSize) {
return items;
}
page += 1;
}
}
function createBillsCsv(bills, regionNames) {
const headers = [
"账单",
"命令号",
"外部单号",
"用户长ID",
"用户短ID",
"用户名称",
"币商长ID",
"币商短ID",
"币商名称",
"金币",
"金额",
"国家",
"区域",
"充值途径",
"状态",
"创建时间",
];
const rows = bills.map((bill) => {
const user = normalizeBillUser(bill.user, bill.userId);
const seller = normalizeBillUser(bill.seller, bill.sellerUserId);
return [
bill.transactionId,
bill.commandId,
bill.externalRef,
user.userId,
user.displayUserId,
user.username,
seller.userId,
seller.displayUserId,
seller.username,
formatNumber(bill.coinAmount),
billMoneyText(bill),
billCountryName(bill),
regionName(bill.targetRegionId, regionNames),
rechargeTypeLabel(bill.rechargeType),
statusLabel(bill.status),
formatMillis(bill.createdAtMs),
];
});
return [headers, ...rows].map((row) => row.map(csvCell).join(",")).join("\n");
}
function csvCell(value) {
const text = value === 0 || value ? String(value) : "";
if (/[",\n\r]/.test(text)) {
return `"${text.replace(/"/g, '""')}"`;
}
return text;
}
function downloadTextFile(content, filename) {
const blob = new Blob([`\uFEFF${content}`], { type: "text/csv;charset=utf-8" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
window.URL.revokeObjectURL(url);
}
async function copyText(value) {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value);
return;
} catch {
// Clipboard API退
}
}
const input = document.createElement("textarea");
input.value = value;
input.setAttribute("readonly", "");
input.style.position = "fixed";
input.style.opacity = "0";
document.body.appendChild(input);
input.select();
document.execCommand("copy");
input.remove();
}
function filenameTimestamp() {
const now = new Date();
return [
now.getFullYear(),
pad2(now.getMonth() + 1),
pad2(now.getDate()),
"-",
pad2(now.getHours()),
pad2(now.getMinutes()),
pad2(now.getSeconds()),
].join("");
}
function pad2(value) {
return String(value).padStart(2, "0");
}
function digitsOnly(value) {
return value.replace(/\D/g, "");
}

Some files were not shown because too many files have changed in this diff Show More