Compare commits
24 Commits
f7a401a183
...
4b4054a4d0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b4054a4d0 | ||
|
|
66ffb3647c | ||
|
|
62d88d178f | ||
|
|
e2c0565dbe | ||
|
|
2118a2ee4d | ||
|
|
fd80e18aff | ||
|
|
945e2e7047 | ||
|
|
cf062aab5d | ||
|
|
825fe47367 | ||
|
|
af9da06615 | ||
|
|
cf83d334e7 | ||
|
|
dcf5ea2045 | ||
|
|
39d7e527cb | ||
|
|
a33474ec5f | ||
|
|
58be420ae9 | ||
|
|
0f92efea87 | ||
|
|
ca194e46a3 | ||
|
|
a1c7803af8 | ||
|
|
e3ab3cba1e | ||
|
|
d4eaf20c8e | ||
|
|
a0b141c31b | ||
|
|
0c65edfa6f | ||
|
|
c0c81cd97b | ||
|
|
333659f80c |
@ -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",
|
||||
|
||||
@ -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
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
60
databi/src/DatabiApp.test.jsx
Normal file
60
databi/src/DatabiApp.test.jsx
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
62
databi/src/charts/options/createMetricTrendOption.js
Normal file
62
databi/src/charts/options/createMetricTrendOption.js
Normal 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");
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
69
databi/src/components/CountryPerformanceTable.test.jsx
Normal file
69
databi/src/components/CountryPerformanceTable.test.jsx
Normal 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];
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
|
||||
36
databi/src/components/DatabiHeader.test.jsx
Normal file
36
databi/src/components/DatabiHeader.test.jsx
Normal 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");
|
||||
});
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 = [];
|
||||
|
||||
111
databi/src/components/LuckyGiftPoolModal.jsx
Normal file
111
databi/src/components/LuckyGiftPoolModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
databi/src/components/LuckyGiftPoolModal.test.jsx
Normal file
23
databi/src/components/LuckyGiftPoolModal.test.jsx
Normal 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();
|
||||
});
|
||||
@ -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(" ");
|
||||
}
|
||||
|
||||
10
databi/src/components/MetricCard.test.jsx
Normal file
10
databi/src/components/MetricCard.test.jsx
Normal 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();
|
||||
});
|
||||
|
||||
64
databi/src/components/MetricTrendModal.jsx
Normal file
64
databi/src/components/MetricTrendModal.jsx
Normal 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);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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: "土耳其" }
|
||||
];
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
72
databi/src/data/createDashboardModel.test.js
Normal file
72
databi/src/data/createDashboardModel.test.js
Normal 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;
|
||||
}
|
||||
@ -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()
|
||||
};
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
16
databi/src/utils/time.test.js
Normal file
16
databi/src/utils/time.test.js
Normal 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));
|
||||
});
|
||||
@ -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>
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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,
|
||||
|
||||
124
src/app/navigation/menuUsage.js
Normal file
124
src/app/navigation/menuUsage.js
Normal 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;
|
||||
}
|
||||
}
|
||||
69
src/app/navigation/menuUsage.test.js
Normal file
69
src/app/navigation/menuUsage.test.js
Normal 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)),
|
||||
};
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
35
src/features/app-users/api.test.ts
Normal file
35
src/features/app-users/api.test.ts
Normal 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");
|
||||
});
|
||||
@ -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%;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
176
src/features/cumulative-recharge-reward/api.ts
Normal file
176
src/features/cumulative-recharge-reward/api.ts
Normal 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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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)}`;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
11
src/features/cumulative-recharge-reward/permissions.js
Normal file
11
src/features/cumulative-recharge-reward/permissions.js
Normal 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),
|
||||
};
|
||||
}
|
||||
13
src/features/cumulative-recharge-reward/routes.js
Normal file
13
src/features/cumulative-recharge-reward/routes.js
Normal 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,
|
||||
},
|
||||
];
|
||||
46
src/features/dashboard/components/DashboardFrequentMenus.jsx
Normal file
46
src/features/dashboard/components/DashboardFrequentMenus.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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} />;
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 白名单。
|
||||
|
||||
@ -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}`}>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
37
src/features/host-agency-policy/residualRate.js
Normal file
37
src/features/host-agency-policy/residualRate.js
Normal 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";
|
||||
}
|
||||
20
src/features/host-agency-policy/residualRate.test.js
Normal file
20
src/features/host-agency-policy/residualRate.test.js
Normal 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("剩余钻石不折美元");
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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>(
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
23
src/features/host-org/components/HostOrgToolbar.test.jsx
Normal file
23
src/features/host-org/components/HostOrgToolbar.test.jsx
Normal 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);
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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")}`;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)",
|
||||
},
|
||||
};
|
||||
|
||||
@ -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)}>
|
||||
|
||||
@ -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 })}
|
||||
|
||||
@ -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)",
|
||||
|
||||
164
src/features/host-org/pages/HostCoinSellersPage.test.jsx
Normal file
164
src/features/host-org/pages/HostCoinSellersPage.test.jsx
Normal 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",
|
||||
};
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
42
src/features/host-org/schema.test.ts
Normal file
42
src/features/host-org/schema.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
@ -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),
|
||||
});
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 || "-";
|
||||
}
|
||||
|
||||
@ -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"] });
|
||||
}
|
||||
|
||||
@ -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 金币。",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
@ -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",
|
||||
});
|
||||
|
||||
278
src/features/operations/components/CoinSellerLedgerTable.jsx
Normal file
278
src/features/operations/components/CoinSellerLedgerTable.jsx
Normal 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");
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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"]),
|
||||
|
||||
10
src/features/operations/pages/CoinSellerLedgerPage.jsx
Normal file
10
src/features/operations/pages/CoinSellerLedgerPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user