Compare commits
171 Commits
lucky_gift
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f597cb6110 | ||
|
|
8ef5b0c08d | ||
|
|
c206c72612 | ||
|
|
9065382192 | ||
|
|
ef260df749 | ||
|
|
cf98d5bd9a | ||
|
|
c7ca4f23ec | ||
|
|
50da0a569b | ||
|
|
5e9ed4c0c5 | ||
|
|
7711f2a9ee | ||
|
|
ee023df614 | ||
|
|
24e9bfc439 | ||
|
|
46654ac23f | ||
|
|
4874bba9ac | ||
|
|
f12b2767c0 | ||
|
|
53a178f11c | ||
|
|
feefcba3c8 | ||
|
|
08872009e1 | ||
|
|
216031f776 | ||
|
|
46bd69cb01 | ||
|
|
ef90b9ed27 | ||
|
|
cf60f9ce2c | ||
|
|
fb168822cc | ||
|
|
68ef0dfee3 | ||
|
|
a83907fc05 | ||
|
|
cb4dcd87cf | ||
|
|
6ed13c690c | ||
|
|
ea78619f5f | ||
|
|
f6822d2411 | ||
|
|
9fe67dc8d1 | ||
|
|
9dd69b2374 | ||
|
|
b8b5182c67 | ||
|
|
88eae83dbe | ||
|
|
7025ae9e30 | ||
|
|
de910547f1 | ||
|
|
da85f07340 | ||
|
|
6726a21e9c | ||
|
|
f44097570d | ||
|
|
c836d0f702 | ||
|
|
18ad13563b | ||
|
|
8fe2efb344 | ||
|
|
91a7c784c9 | ||
|
|
b0b1a26ae8 | ||
|
|
810ee6721c | ||
|
|
b81c2edf43 | ||
|
|
a952b4262e | ||
|
|
f5e1a20dd9 | ||
|
|
00b8020599 | ||
|
|
7080538a00 | ||
|
|
7d08e7cfca | ||
|
|
9e72ff52c7 | ||
|
|
c08f9e7a83 | ||
|
|
a95330857c | ||
|
|
653fc664dc | ||
|
|
0e87bb24af | ||
|
|
3e8d4b4aea | ||
|
|
f059b0c60c | ||
|
|
7858530fc8 | ||
|
|
63d4ffbacd | ||
|
|
8c9f5d35b2 | ||
|
|
0112303b88 | ||
|
|
c33fb469d9 | ||
|
|
2125f81baf | ||
|
|
9405e2b7d6 | ||
|
|
88ef541b95 | ||
|
|
e51a4401ef | ||
|
|
bc9484c60d | ||
|
|
b6d8af0fa3 | ||
|
|
dae91e892e | ||
|
|
4166fe0786 | ||
|
|
2215b01743 | ||
|
|
6ab21a8baa | ||
|
|
81fa3a25bb | ||
|
|
889e75f6a1 | ||
|
|
05412e477a | ||
|
|
17251552d9 | ||
|
|
8c23d7e6a7 | ||
|
|
57231bc95a | ||
|
|
0789e02c6a | ||
|
|
3b21bd1133 | ||
|
|
d876ff6985 | ||
|
|
743e4dc1c8 | ||
|
|
28051f2acd | ||
|
|
ec252cec2c | ||
|
|
ee0a14d5b7 | ||
|
|
9d930da859 | ||
|
|
4af2c311bf | ||
|
|
4d831bbdd6 | ||
|
|
a9b0c93c1b | ||
|
|
723258a3aa | ||
|
|
0be921409d | ||
|
|
97915b5980 | ||
|
|
f5d06e1e52 | ||
|
|
7c4bdf8d77 | ||
|
|
e25258f233 | ||
|
|
64cfa472b8 | ||
|
|
1447de8563 | ||
|
|
aa7ce50e4f | ||
|
|
79da033dc9 | ||
|
|
001eb0d4dc | ||
|
|
1beca72cb1 | ||
|
|
3900866df0 | ||
|
|
282c9f1734 | ||
|
|
3983e355fb | ||
|
|
ee4da04c61 | ||
|
|
a8614398a4 | ||
|
|
fd8c5bbd78 | ||
|
|
480b6e0825 | ||
|
|
2ebacae7c2 | ||
|
|
ac5d3a53d9 | ||
|
|
5400f1dd57 | ||
|
|
300d53a0cd | ||
|
|
764da8affc | ||
|
|
087e379910 | ||
|
|
91498b7557 | ||
|
|
4b16f93930 | ||
|
|
103b70f5e5 | ||
|
|
7a6f0db859 | ||
|
|
53b74f9dc2 | ||
|
|
ebb4545ad6 | ||
|
|
7d6418e43c | ||
|
|
54fdc867fe | ||
|
|
7b094f2b6f | ||
|
|
e2d8d03260 | ||
|
|
0b3589a701 | ||
|
|
e423adc738 | ||
|
|
7740d611b6 | ||
|
|
4e56597adb | ||
|
|
fcd44f8c13 | ||
|
|
8a6e9dc18d | ||
|
|
8175726223 | ||
|
|
4ad96f07be | ||
|
|
ae8ae7db55 | ||
|
|
4520e16470 | ||
|
|
5bdbe33f8f | ||
|
|
2f7e3bb86a | ||
|
|
a7509e1f9a | ||
|
|
4b4054a4d0 | ||
|
|
66ffb3647c | ||
|
|
62d88d178f | ||
|
|
e2c0565dbe | ||
|
|
2118a2ee4d | ||
|
|
fd80e18aff | ||
|
|
945e2e7047 | ||
|
|
cf062aab5d | ||
|
|
825fe47367 | ||
|
|
af9da06615 | ||
|
|
cf83d334e7 | ||
|
|
dcf5ea2045 | ||
|
|
39d7e527cb | ||
|
|
a33474ec5f | ||
|
|
58be420ae9 | ||
|
|
0f92efea87 | ||
|
|
ca194e46a3 | ||
|
|
a1c7803af8 | ||
|
|
e3ab3cba1e | ||
|
|
d4eaf20c8e | ||
|
|
a0b141c31b | ||
|
|
0c65edfa6f | ||
|
|
c0c81cd97b | ||
|
|
333659f80c | ||
|
|
f7a401a183 | ||
|
|
79ed201975 | ||
|
|
38a417a8bd | ||
|
|
1a1c6ecc88 | ||
|
|
b8983e7b4e | ||
|
|
6ddf5ee211 | ||
|
|
49f54e0463 | ||
|
|
efaaef7e86 | ||
|
|
c22b23cb8c | ||
|
|
b96fdb4f6a |
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "admin-platform",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 7001
|
||||
}
|
||||
]
|
||||
}
|
||||
1
.env.production
Normal file
1
.env.production
Normal file
@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=/api
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -4,3 +4,4 @@ node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.log
|
||||
.waylog/
|
||||
|
||||
18
AGENTS.md
18
AGENTS.md
@ -17,6 +17,8 @@
|
||||
13. 左侧菜单 active 样式优先级必须高于 hover 样式。
|
||||
14. 侧边栏展开/收起按钮必须跟随状态切换图标。
|
||||
15. 展开态 Logo 区域和侧边栏宽度保持 248px。
|
||||
16. 所有逻辑功能配置默认打开;只有密钥、地址、三方账号等必须由环境提供的敏感值使用占位符或环境配置,不在默认配置里伪造可用凭证。
|
||||
17. 所有业务逻辑、状态流转、权限判断、支付链路、异步流程都保持高密度逻辑注释,注释解释为什么这样处理和失败分支,不复述语法。
|
||||
|
||||
## 当前实现方式
|
||||
|
||||
@ -27,7 +29,8 @@
|
||||
- 页面:按业务放在 `src/features/*/pages/`。
|
||||
- 应用壳:`src/app/App.jsx`、`src/app/layout/AdminLayout.jsx`、`src/app/router/routeConfig.ts`。
|
||||
- 页面加载占位:`src/shared/ui/PageSkeleton.jsx`。
|
||||
- 全局资源组选择抽屉:`src/shared/ui/ResourceGroupSelectDrawer.jsx`,资源组字段优先使用该组件,不在业务页面里重复手写普通下拉。
|
||||
- 公共时间选择器:`src/shared/ui/TimeRangeFilter.jsx`,使用 `{ startMs, endMs }` 毫秒值作为输入输出。
|
||||
- 全局资源组选择抽屉:`src/shared/ui/ResourceGroupSelectDrawer.jsx`,业务表单资源组字段统一使用其导出的 `ResourceGroupSelectField`,不在业务页面里重复手写普通下拉或资源组 ID 输入框。
|
||||
- 顶部搜索弹窗:`src/features/search/components/SearchDialog.jsx`。
|
||||
- MUI 主题:`src/theme.js`。
|
||||
- CSS token:`src/styles/tokens.css`。
|
||||
@ -76,10 +79,10 @@ import { Refresh } from "@mui/icons-material";
|
||||
## 样式规则
|
||||
|
||||
- 状态色不要随意改:
|
||||
- `--success`:运行中
|
||||
- `--warning`:警告
|
||||
- `--danger`:异常/危险操作
|
||||
- `--stopped`:已停止
|
||||
- `--success`:运行中
|
||||
- `--warning`:警告
|
||||
- `--danger`:异常/危险操作
|
||||
- `--stopped`:已停止
|
||||
- 新增颜色优先写入 token 或 theme,不要在多个组件里重复硬编码。
|
||||
- 基础组件的视觉兼容优先改封装组件,不要让页面直接依赖 MUI 细节。
|
||||
- 不添加卡片套卡片。
|
||||
@ -91,11 +94,12 @@ import { Refresh } from "@mui/icons-material";
|
||||
- 表格里的启用/禁用状态优先使用行内 Switch 快捷操作,不使用单独的启用/禁用图标按钮。
|
||||
- 表格状态筛选使用 Select 下拉框,默认值为 `全部状态`,不使用状态 chip 组。
|
||||
- 后台列表页的时间区间筛选必须使用统一的单入口时间区间筛选框(`src/shared/ui/TimeRangeFilter.jsx`);不要在工具栏里并排放两个开始/结束时间输入框,也不要使用浏览器原生日期/时间输入样式。时间区间弹层内使用快捷项、日历和时分下拉点选,不使用手写时间文本框。
|
||||
- 资源组选择必须优先使用统一的 `src/shared/ui/ResourceGroupSelectDrawer.jsx`,点击输入框打开右侧资源组图标抽屉,点击资源组后自动回填并关闭,不要在业务页面里重复实现资源组 Select 下拉。
|
||||
- 后台表单里的生效时间、有效期、投放时间等时间区间必须使用统一公共时间选择器(`src/shared/ui/TimeRangeFilter.jsx`);不要并排放两个开始/结束时间输入框,也不要使用浏览器原生日期/时间输入样式。
|
||||
- 资源组选择必须优先使用统一的 `src/shared/ui/ResourceGroupSelectDrawer.jsx` / `ResourceGroupSelectField`,点击输入框打开右侧资源组图标抽屉,点击资源组后自动回填并关闭,不要在业务页面里重复实现资源组 Select 下拉,也不要用普通文本框填写资源组 ID。
|
||||
- 区域编辑弹窗必须同时包含区域基础字段和国家码字段;国家码使用可搜索多选下拉框,不再拆成独立“区域国家”弹窗。
|
||||
- 头像、图片、封面等图片资源字段必须使用 `src/shared/ui/UploadField.jsx` 上传表单组件,不使用普通文本输入框承载 URL。
|
||||
- 图片上传表单必须提供统一预览;`webp` 使用浏览器原生动效预览,`svga` 使用 SVGA 播放器预览,`pag` 使用 libpag + wasm 预览。
|
||||
- BD Leader 创建弹窗必须使用区域下拉选择 `regionId`;目标用户原区域可以不同,创建成功后后端会把目标用户当前区域同步到所选区域。
|
||||
- BD Leader 创建弹窗不选择区域、不提交 `regionId`;创建结果按目标用户当前区域生成,若区域不对,先修复用户国家/区域映射,再创建 BD Leader。
|
||||
- 区域创建和区域启用/禁用调用后台管理后端接口,不要求 user-service 新增对应 RPC。
|
||||
- 国家创建和国家启用/禁用调用后台管理后端接口,不要求 user-service 新增对应 RPC。
|
||||
|
||||
|
||||
72
README.md
72
README.md
@ -54,40 +54,40 @@ pnpm build
|
||||
|
||||
- `src/App.jsx` 只负责应用状态、页面切换、抽屉挂载和数据分发。
|
||||
- 页面模块放在 `src/pages/`:
|
||||
- `OverviewPage.jsx`
|
||||
- `ServicesPage.jsx`
|
||||
- `UserManagementPage.jsx`
|
||||
- `OverviewPage.jsx`
|
||||
- `ServicesPage.jsx`
|
||||
- `UserManagementPage.jsx`
|
||||
- 基础组件放在 `src/components/base/`,对 MUI 做项目级封装:
|
||||
- `Button`
|
||||
- `IconButton`
|
||||
- `Card`
|
||||
- `SearchBox`
|
||||
- `FilterChip`
|
||||
- `KpiCard`
|
||||
- `SegmentControl`
|
||||
- 当前共享后台组件统一放在 `src/shared/ui/`;资源组字段使用 `ResourceGroupSelectDrawer.jsx`,点击输入框打开右侧资源组图标抽屉,选择后自动回填。
|
||||
- `Button`
|
||||
- `IconButton`
|
||||
- `Card`
|
||||
- `SearchBox`
|
||||
- `FilterChip`
|
||||
- `KpiCard`
|
||||
- `SegmentControl`
|
||||
- 当前共享后台组件统一放在 `src/shared/ui/`;资源组字段统一使用 `ResourceGroupSelectDrawer.jsx` 导出的 `ResourceGroupSelectField`,点击输入框打开右侧资源组图标抽屉,选择后自动回填,不在业务页面里手写资源组下拉或资源组 ID 输入框。
|
||||
- 布局组件放在 `src/components/layout/`:
|
||||
- `Header`
|
||||
- `Sidebar`
|
||||
- `PageHead`
|
||||
- `PageSkeleton`
|
||||
- `Header`
|
||||
- `Sidebar`
|
||||
- `PageHead`
|
||||
- `PageSkeleton`
|
||||
- 图表组件放在 `src/components/charts/`,统一封装 ECharts:
|
||||
- `EChart`
|
||||
- `LineChart`
|
||||
- `BarChart`
|
||||
- `StatusDonut`
|
||||
- `ChartCard`
|
||||
- `EChart`
|
||||
- `LineChart`
|
||||
- `BarChart`
|
||||
- `StatusDonut`
|
||||
- `ChartCard`
|
||||
- 服务业务组件放在 `src/components/service/`:
|
||||
- `ServiceTable`
|
||||
- `ServerRail`
|
||||
- `DetailPanel`
|
||||
- `RowActions`
|
||||
- `ResourceCell`
|
||||
- `StatusBadge`
|
||||
- `ServiceTable`
|
||||
- `ServerRail`
|
||||
- `DetailPanel`
|
||||
- `RowActions`
|
||||
- `ResourceCell`
|
||||
- `StatusBadge`
|
||||
- 常量、模拟数据和工具函数分别放在:
|
||||
- `src/constants/`
|
||||
- `src/data/`
|
||||
- `src/utils/`
|
||||
- `src/constants/`
|
||||
- `src/data/`
|
||||
- `src/utils/`
|
||||
|
||||
## 样式约定
|
||||
|
||||
@ -97,10 +97,10 @@ pnpm build
|
||||
- MUI 主题集中在 `src/theme.js`,优先在主题层统一颜色、圆角、hover、focus 和组件兼容样式。
|
||||
- 避免在页面里直接散落 MUI 组件样式;优先改项目封装组件。
|
||||
- 状态色必须保持语义一致:
|
||||
- 运行中:`--success`
|
||||
- 警告:`--warning`
|
||||
- 异常:`--danger`
|
||||
- 已停止:`--stopped`
|
||||
- 运行中:`--success`
|
||||
- 警告:`--warning`
|
||||
- 异常:`--danger`
|
||||
- 已停止:`--stopped`
|
||||
|
||||
## 按需导入约定
|
||||
|
||||
@ -109,9 +109,9 @@ pnpm build
|
||||
- 不使用 `@mui/material` 或 `@mui/icons-material` 总入口导入。
|
||||
- ECharts 通过封装组件按需加载所需图表和组件能力。
|
||||
- 页面使用 `React.lazy` 拆分:
|
||||
- `OverviewPage`
|
||||
- `ServicesPage`
|
||||
- `DetailDrawer`
|
||||
- `OverviewPage`
|
||||
- `ServicesPage`
|
||||
- `DetailDrawer`
|
||||
|
||||
## 动画与加载
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
12
databi/index.html
Normal file
12
databi/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>HYApp 数据中心</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="databi-root"></div>
|
||||
<script type="module" src="/databi/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
12
databi/social/index.html
Normal file
12
databi/social/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>运营数据总览</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="databi-root"></div>
|
||||
<script type="module" src="/databi/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
417
databi/src/DatabiApp.jsx
Normal file
417
databi/src/DatabiApp.jsx
Normal file
@ -0,0 +1,417 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { fetchFilterOptions, fetchSelfGameStatisticsOverview, fetchStatisticsOverview, getCurrentAppCode, getDefaultAppOptions, 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 { ReportOverview } from "./components/ReportOverview.jsx";
|
||||
import { EmptyReportSection, ReportSidebar } from "./components/ReportSidebar.jsx";
|
||||
import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
|
||||
import { SelfGameScreen } from "./components/SelfGameScreen.jsx";
|
||||
import { SocialBiApp } from "./social/SocialBiApp.jsx";
|
||||
import { createDashboardModel } from "./data/createDashboardModel.js";
|
||||
import { lastDaysRange, rangeEndMs, rangeStartMs, readStoredTimeZone, sameRange, thisMonthRange, TIME_ZONE_OPTIONS, todayRange, writeStoredTimeZone } from "./utils/time.js";
|
||||
|
||||
const initialFilterOptions = {
|
||||
appOptions: getDefaultAppOptions(),
|
||||
countryOptions: [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }],
|
||||
regionOptions: [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }]
|
||||
};
|
||||
|
||||
export function DatabiApp() {
|
||||
if (isSocialBiPath()) {
|
||||
return <SocialBiApp />;
|
||||
}
|
||||
return <DatabiOverviewApp />;
|
||||
}
|
||||
|
||||
function DatabiOverviewApp() {
|
||||
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 [regionId, setRegionId] = useState("all");
|
||||
const [appCode, setAppCode] = useState(() => getCurrentAppCode());
|
||||
const [viewMode, setViewMode] = useState("report");
|
||||
const [activeReportSection, setActiveReportSection] = useState("data");
|
||||
const [activeScreen, setActiveScreen] = useState("overview");
|
||||
const [gameId, setGameId] = useState("all");
|
||||
const [filterOptions, setFilterOptions] = useState(initialFilterOptions);
|
||||
const [filtersLoading, setFiltersLoading] = useState(false);
|
||||
const [overview, setOverview] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [selfGameOverview, setSelfGameOverview] = useState(null);
|
||||
const [selfGameLoading, setSelfGameLoading] = useState(false);
|
||||
const [selfGameError, setSelfGameError] = useState("");
|
||||
const [poolModalOpen, setPoolModalOpen] = useState(false);
|
||||
const [trendModalItem, setTrendModalItem] = useState(null);
|
||||
const overviewRequestIdRef = useRef(0);
|
||||
const selfGameRequestIdRef = useRef(0);
|
||||
const overviewRef = useRef(null);
|
||||
const selfGameOverviewRef = useRef(null);
|
||||
const scopedCountries = useMemo(() => {
|
||||
return filterOptions.countryOptions.filter((country) => Number(country.id) > 0 && countryBelongsToRegion(country, regionId));
|
||||
}, [filterOptions.countryOptions, regionId]);
|
||||
const isDataReportSection = activeReportSection === "data";
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
selfGameOverviewRef.current = selfGameOverview;
|
||||
}, [selfGameOverview]);
|
||||
|
||||
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,
|
||||
regionId,
|
||||
seriesEndMs: rangeEndMs(seriesRange, timeZone),
|
||||
seriesStartMs: rangeStartMs(seriesRange, timeZone),
|
||||
startMs,
|
||||
statTz: timeZone
|
||||
});
|
||||
const nextOverview = enrichCountryBreakdown(data || {}, scopedCountries);
|
||||
if (requestId === overviewRequestIdRef.current) {
|
||||
setOverview(nextOverview);
|
||||
}
|
||||
} catch (err) {
|
||||
if (requestId === overviewRequestIdRef.current) {
|
||||
if (!keepCurrentOverview) {
|
||||
setOverview(null);
|
||||
}
|
||||
setError(err.message || "请求失败");
|
||||
}
|
||||
} finally {
|
||||
if (requestId === overviewRequestIdRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [appCode, countryId, range, regionId, scopedCountries, timeZone]);
|
||||
|
||||
const loadSelfGameOverview = useCallback(async ({ preserveData = false } = {}) => {
|
||||
const requestId = selfGameRequestIdRef.current + 1;
|
||||
selfGameRequestIdRef.current = requestId;
|
||||
const keepCurrentOverview = preserveData && selfGameOverviewRef.current;
|
||||
setSelfGameLoading(true);
|
||||
setSelfGameError("");
|
||||
if (!keepCurrentOverview) {
|
||||
setSelfGameOverview(null);
|
||||
}
|
||||
try {
|
||||
const data = await fetchSelfGameStatisticsOverview({
|
||||
appCode,
|
||||
countryId,
|
||||
endMs: rangeEndMs(range, timeZone),
|
||||
gameId,
|
||||
regionId,
|
||||
startMs: rangeStartMs(range, timeZone),
|
||||
statTz: timeZone
|
||||
});
|
||||
if (requestId === selfGameRequestIdRef.current) {
|
||||
setSelfGameOverview(data || {});
|
||||
}
|
||||
} catch (err) {
|
||||
if (requestId === selfGameRequestIdRef.current) {
|
||||
if (!keepCurrentOverview) {
|
||||
setSelfGameOverview(null);
|
||||
}
|
||||
setSelfGameError(err.message || "请求失败");
|
||||
}
|
||||
} finally {
|
||||
if (requestId === selfGameRequestIdRef.current) {
|
||||
setSelfGameLoading(false);
|
||||
}
|
||||
}
|
||||
}, [appCode, countryId, gameId, range, regionId, timeZone]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDataReportSection) {
|
||||
return undefined;
|
||||
}
|
||||
void loadFilterOptions();
|
||||
}, [isDataReportSection, loadFilterOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDataReportSection) {
|
||||
return undefined;
|
||||
}
|
||||
if (activeScreen === "selfGames") {
|
||||
void loadSelfGameOverview();
|
||||
const timer = window.setInterval(() => void loadSelfGameOverview({ preserveData: true }), 60_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}
|
||||
void loadOverview();
|
||||
const timer = window.setInterval(() => void loadOverview({ preserveData: true }), 60_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [activeScreen, isDataReportSection, loadOverview, loadSelfGameOverview]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
if (!isDataReportSection) {
|
||||
return;
|
||||
}
|
||||
if (activeScreen === "selfGames") {
|
||||
void loadSelfGameOverview({ preserveData: true });
|
||||
return;
|
||||
}
|
||||
void loadOverview({ preserveData: true });
|
||||
}, [activeScreen, isDataReportSection, loadOverview, loadSelfGameOverview]);
|
||||
|
||||
const model = useMemo(() => createDashboardModel(overview, { appCode, countryId, range, timeZone }), [appCode, countryId, overview, range, timeZone]);
|
||||
const showSkeleton = loading && !overview;
|
||||
const isReportView = viewMode === "report";
|
||||
const openLuckyGiftPools = useCallback(() => {
|
||||
setPoolModalOpen(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className={`databi-shell databi-shell--${viewMode}`}>
|
||||
<ReportSidebar activeSection={activeReportSection} onSectionChange={setActiveReportSection} />
|
||||
<div className="databi-shell-content">
|
||||
{isDataReportSection ? (
|
||||
<section className={[
|
||||
"databi-screen",
|
||||
isReportView ? "databi-screen--report" : "databi-screen--bigscreen",
|
||||
activeScreen === "selfGames" ? "databi-screen--self-game" : ""
|
||||
].filter(Boolean).join(" ")}>
|
||||
<DatabiHeader
|
||||
activeScreen={activeScreen}
|
||||
appCode={appCode}
|
||||
appOptions={filterOptions.appOptions}
|
||||
countryOptions={filterOptions.countryOptions}
|
||||
countryId={countryId}
|
||||
error={activeScreen === "selfGames" ? selfGameError : error}
|
||||
filtersLoading={filtersLoading}
|
||||
loading={activeScreen === "selfGames" ? selfGameLoading : loading}
|
||||
onAppChange={handleAppChange}
|
||||
onCountryChange={setCountryId}
|
||||
onRefresh={handleRefresh}
|
||||
onRangeChange={setRange}
|
||||
onRegionChange={handleRegionChange}
|
||||
onScreenChange={setActiveScreen}
|
||||
onTimeZoneChange={handleTimeZoneChange}
|
||||
range={range}
|
||||
regionId={regionId}
|
||||
regionOptions={filterOptions.regionOptions}
|
||||
timeZone={timeZone}
|
||||
timeZoneOptions={TIME_ZONE_OPTIONS}
|
||||
updatedAt={model.updatedAt}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
||||
{activeScreen === "selfGames" ? (
|
||||
<SelfGameScreen gameId={gameId} loading={selfGameLoading && !selfGameOverview} onGameChange={setGameId} overview={selfGameOverview} />
|
||||
) : isReportView ? (
|
||||
<ReportOverview
|
||||
grantDrilldownQuery={{
|
||||
appCode,
|
||||
countryId,
|
||||
endMs: rangeEndMs(range, timeZone),
|
||||
regionId,
|
||||
startMs: rangeStartMs(range, timeZone),
|
||||
statTz: timeZone
|
||||
}}
|
||||
loading={showSkeleton}
|
||||
model={model}
|
||||
onMetricTrend={setTrendModalItem}
|
||||
onOpenLuckyGiftPools={openLuckyGiftPools}
|
||||
/>
|
||||
) : (
|
||||
<BigscreenOverview
|
||||
loading={showSkeleton}
|
||||
model={model}
|
||||
onMetricTrend={setTrendModalItem}
|
||||
onOpenLuckyGiftPools={openLuckyGiftPools}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<EmptyReportSection section={activeReportSection} />
|
||||
)}
|
||||
|
||||
{isDataReportSection && activeScreen === "overview" && !isReportView ? (
|
||||
<CountryPerformanceTable gameRanking={model.gameRanking} giftRanking={model.giftRanking} items={model.countryBreakdown} loading={showSkeleton} />
|
||||
) : null}
|
||||
{isDataReportSection && activeScreen === "overview" && poolModalOpen ? (
|
||||
<LuckyGiftPoolModal items={model.luckyGiftPools} onClose={() => setPoolModalOpen(false)} />
|
||||
) : null}
|
||||
{isDataReportSection && activeScreen === "overview" && trendModalItem ? (
|
||||
<MetricTrendModal item={trendModalItem} onClose={() => setTrendModalItem(null)} />
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function BigscreenOverview({ loading, model, onMetricTrend, onOpenLuckyGiftPools }) {
|
||||
return (
|
||||
<>
|
||||
<section className="metric-grid" aria-label="核心指标">
|
||||
{model.kpis.map((item) => (
|
||||
<MetricCard key={item.label} item={item} loading={loading} onClick={() => onMetricTrend(item)} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="business-metric-grid" aria-label="业务指标">
|
||||
{model.businessKpis.map((item) => (
|
||||
<MetricCard
|
||||
key={item.label}
|
||||
item={item}
|
||||
loading={loading}
|
||||
onClick={item.detailKey === "luckyGiftPools" ? onOpenLuckyGiftPools : undefined}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="business-metric-grid robot-gift-metric-grid" aria-label="真人房机器人送礼指标">
|
||||
{model.robotGiftKpis.map((item) => (
|
||||
<MetricCard key={item.label} item={item} loading={loading} onClick={() => onMetricTrend(item)} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="databi-grid databi-grid--top">
|
||||
<GlobalOverviewPanel countryBreakdown={model.countryBreakdown} loading={loading} topCountries={model.topCountries} />
|
||||
<RevenueTrendPanel loading={loading} revenueSeries={model.revenueSeries} />
|
||||
<FunnelPanel funnel={model.funnel} loading={loading} sideMetrics={model.sideMetrics} />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSelectedAppCode(options, current) {
|
||||
const normalized = String(current || "").trim().toLowerCase();
|
||||
if (options.some((item) => optionAppCode(item) === normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
if (options.length) {
|
||||
return optionAppCode(options[0]);
|
||||
}
|
||||
return normalized || "lalu";
|
||||
}
|
||||
|
||||
function optionAppCode(option) {
|
||||
return String(option?.code ?? option?.value ?? "").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) &&
|
||||
(!Array.isArray(data?.daily_country_breakdown) || !data.daily_country_breakdown.length)) {
|
||||
return data;
|
||||
}
|
||||
const countryById = new Map(countries.map((country) => [Number(country.id), country]));
|
||||
const enrichRows = (rows) => rows.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
|
||||
};
|
||||
});
|
||||
return {
|
||||
...data,
|
||||
country_breakdown: Array.isArray(data.country_breakdown) ? enrichRows(data.country_breakdown) : data.country_breakdown,
|
||||
daily_country_breakdown: Array.isArray(data.daily_country_breakdown) ? enrichRows(data.daily_country_breakdown) : data.daily_country_breakdown
|
||||
};
|
||||
}
|
||||
|
||||
function isSocialBiPath() {
|
||||
return window.location.pathname.replace(/\/+$/, "") === "/databi/social";
|
||||
}
|
||||
569
databi/src/DatabiApp.test.jsx
Normal file
569
databi/src/DatabiApp.test.jsx
Normal file
@ -0,0 +1,569 @@
|
||||
import { act, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { DatabiApp } from "./DatabiApp.jsx";
|
||||
import {
|
||||
fetchFilterOptions,
|
||||
fetchSelfGameStatisticsOverview,
|
||||
fetchSocialBiFunnel,
|
||||
fetchSocialBiKpi,
|
||||
fetchSocialBiMaster,
|
||||
fetchSocialBiOverview,
|
||||
fetchSocialBiRequirements,
|
||||
fetchStatisticsOverview
|
||||
} from "./api.js";
|
||||
|
||||
vi.mock("./api.js", () => ({
|
||||
fetchFilterOptions: vi.fn(),
|
||||
fetchSocialBiFilterOptions: vi.fn(),
|
||||
fetchSocialBiFunnel: vi.fn(),
|
||||
fetchSocialBiKpi: vi.fn(),
|
||||
fetchSocialBiMaster: vi.fn(),
|
||||
fetchSocialBiOverview: vi.fn(),
|
||||
fetchSocialBiRequirements: vi.fn(),
|
||||
fetchSelfGameStatisticsOverview: vi.fn(),
|
||||
fetchStatisticsOverview: vi.fn(),
|
||||
getCurrentAppCode: vi.fn(() => "lalu"),
|
||||
getDefaultAppOptions: vi.fn(() => [
|
||||
{ code: "", label: "全部", value: "" },
|
||||
{ code: "lalu", label: "Lalu", value: "lalu" },
|
||||
{ code: "aslan", label: "Aslan", value: "aslan" },
|
||||
{ code: "yumi", label: "Yumi", value: "yumi" }
|
||||
]),
|
||||
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: "", label: "全部", value: "" },
|
||||
{ code: "lalu", label: "Lalu", value: "lalu" },
|
||||
{ code: "aslan", label: "Aslan", value: "aslan" },
|
||||
{ code: "yumi", label: "Yumi", value: "yumi" }
|
||||
],
|
||||
countryOptions: [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }],
|
||||
regionOptions: [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }]
|
||||
});
|
||||
fetchSelfGameStatisticsOverview.mockResolvedValue({});
|
||||
fetchSocialBiMaster.mockResolvedValue({
|
||||
access: { all: true, scopes: [] },
|
||||
apps: [],
|
||||
operators: [],
|
||||
permissions: {},
|
||||
regions: []
|
||||
});
|
||||
fetchSocialBiOverview.mockResolvedValue({ access: { all: true, scopes: [] }, apps: [] });
|
||||
fetchSocialBiFunnel.mockResolvedValue({ access: { all: true, scopes: [] }, apps: [] });
|
||||
fetchSocialBiKpi.mockResolvedValue({ items: [], period_month: "2026-06", summary: {} });
|
||||
fetchSocialBiRequirements.mockResolvedValue({ columns: [], daily_rows: [], section: "new_users", total: null });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.history.pushState(null, "", "/");
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
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(screen.getByLabelText("数据报表")).toBeTruthy();
|
||||
expect(document.querySelector(".report-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(".report-metric-card.is-loading")).toBeNull();
|
||||
});
|
||||
|
||||
test("queries China natural day statistics when display timezone is Beijing", async () => {
|
||||
fetchStatisticsOverview.mockResolvedValue({ active_users: 10, paid_users: 2, recharge_usd_minor: 100, updated_at_ms: 1 });
|
||||
|
||||
render(<DatabiApp />);
|
||||
|
||||
await flushEffects();
|
||||
|
||||
expect(fetchStatisticsOverview).toHaveBeenCalledWith(expect.objectContaining({
|
||||
endMs: Date.UTC(2026, 5, 6, 15, 59, 59),
|
||||
seriesEndMs: Date.UTC(2026, 5, 6, 15, 59, 59),
|
||||
seriesStartMs: Date.UTC(2026, 4, 30, 16, 0, 0),
|
||||
startMs: Date.UTC(2026, 5, 5, 16, 0, 0),
|
||||
statTz: "Asia/Shanghai"
|
||||
}));
|
||||
});
|
||||
|
||||
test("renders legacy app metric columns in report view", async () => {
|
||||
fetchStatisticsOverview.mockResolvedValue({
|
||||
coin_seller_stock_coin: 400_000,
|
||||
coin_seller_transfer_coin: 160_000,
|
||||
salary_transfer_coin: 88_000,
|
||||
country_breakdown: [
|
||||
{
|
||||
coin_seller_stock_coin: 400_000,
|
||||
coin_seller_transfer_coin: 160_000,
|
||||
country: "巴西",
|
||||
country_id: 86,
|
||||
salary_transfer_coin: 88_000
|
||||
}
|
||||
],
|
||||
updated_at_ms: 1
|
||||
});
|
||||
|
||||
render(<DatabiApp />);
|
||||
|
||||
await flushEffects();
|
||||
|
||||
expect(screen.getByRole("columnheader", { name: "币商充值金币" })).toBeTruthy();
|
||||
expect(screen.getByRole("columnheader", { name: "币商出货金币" })).toBeTruthy();
|
||||
expect(screen.getByRole("columnheader", { name: "工资兑换金币" })).toBeTruthy();
|
||||
expect(screen.getAllByText("400,000").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("160,000").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("88,000").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("switches report sidebar sections and stops data report polling on empty sections", async () => {
|
||||
fetchStatisticsOverview.mockResolvedValue({ active_users: 10, paid_users: 2, recharge_usd_minor: 100, updated_at_ms: 1 });
|
||||
|
||||
render(<DatabiApp />);
|
||||
|
||||
await flushEffects();
|
||||
|
||||
const reportTypeTabs = screen.getByRole("tablist", { name: "报表类型" });
|
||||
expect(within(reportTypeTabs).getByRole("tab", { name: "数据报表" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("region", { name: "数据报表" })).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
within(reportTypeTabs).getByRole("tab", { name: "财务报表" }).click();
|
||||
});
|
||||
|
||||
expect(within(reportTypeTabs).getByRole("tab", { name: "财务报表" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("region", { name: "财务报表" })).toBeTruthy();
|
||||
expect(screen.queryByRole("region", { name: "数据报表" })).toBeNull();
|
||||
const requestCountAfterFinanceSwitch = fetchStatisticsOverview.mock.calls.length;
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(60_000);
|
||||
});
|
||||
|
||||
expect(fetchStatisticsOverview).toHaveBeenCalledTimes(requestCountAfterFinanceSwitch);
|
||||
|
||||
await act(async () => {
|
||||
within(reportTypeTabs).getByRole("tab", { name: "运营报表" }).click();
|
||||
});
|
||||
|
||||
expect(within(reportTypeTabs).getByRole("tab", { name: "运营报表" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("region", { name: "运营报表" })).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
within(reportTypeTabs).getByRole("tab", { name: "数据报表" }).click();
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(screen.getByRole("region", { name: "数据报表" })).toBeTruthy();
|
||||
expect(fetchStatisticsOverview.mock.calls.length).toBeGreaterThan(requestCountAfterFinanceSwitch);
|
||||
});
|
||||
|
||||
test("loads self game statistics from the big screen switch", async () => {
|
||||
fetchStatisticsOverview.mockResolvedValue({ active_users: 10, paid_users: 2, recharge_usd_minor: 100, updated_at_ms: 1 });
|
||||
fetchSelfGameStatisticsOverview.mockResolvedValue({
|
||||
events: [{ event_name: "page_open", event_count: 8, user_count: 5 }],
|
||||
match_totals: { created_matches: 3, matched_matches: 2, settled_matches: 1 },
|
||||
retention: { cohort_users: 5, day1_rate: 0.2, day7_rate: 0.1 }
|
||||
});
|
||||
|
||||
render(<DatabiApp />);
|
||||
|
||||
await flushEffects();
|
||||
await act(async () => {
|
||||
screen.getByRole("button", { name: "自研游戏" }).click();
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(fetchSelfGameStatisticsOverview).toHaveBeenCalledWith(expect.objectContaining({ appCode: "lalu", gameId: "all", statTz: "Asia/Shanghai" }));
|
||||
expect(screen.getByText("Dice / Rock H5 聚合统计")).toBeTruthy();
|
||||
expect(screen.getByText("创建局")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("renders real-room robot gift cards in a separate row", async () => {
|
||||
fetchStatisticsOverview.mockResolvedValue({
|
||||
real_room_robot_avg_room_gift_coin: 300,
|
||||
real_room_robot_gift_room_count: 2,
|
||||
real_room_robot_lucky_gift_coin: 200,
|
||||
real_room_robot_normal_gift_coin: 100,
|
||||
real_room_robot_super_gift_coin: 300,
|
||||
updated_at_ms: 1
|
||||
});
|
||||
|
||||
render(<DatabiApp />);
|
||||
|
||||
await flushEffects();
|
||||
await act(async () => {
|
||||
screen.getByRole("tab", { name: "大屏 BI" }).click();
|
||||
});
|
||||
|
||||
const businessSection = screen.getByLabelText("业务指标");
|
||||
const robotGiftSection = screen.getByLabelText("真人房机器人送礼指标");
|
||||
expect(within(businessSection).queryByText("真人房机器人普通礼物")).toBeNull();
|
||||
expect(within(robotGiftSection).getByText("真人房机器人普通礼物")).toBeTruthy();
|
||||
expect(within(robotGiftSection).getByText("真人房机器人幸运礼物")).toBeTruthy();
|
||||
expect(within(robotGiftSection).getByText("真人房机器人超级幸运礼物")).toBeTruthy();
|
||||
expect(within(robotGiftSection).getByText("真人房机器人房均礼物")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("routes databi social page to Social BI and loads real overview rows", async () => {
|
||||
window.history.pushState(null, "", "/databi/social/");
|
||||
const laluTotal = {
|
||||
active_users: 20,
|
||||
arpu_usd_minor: 410,
|
||||
arppu_usd_minor: 615,
|
||||
avg_mic_online_ms: 120_000,
|
||||
consumed_coin: 900_000,
|
||||
consume_output_delta: 450_000,
|
||||
consume_output_ratio: 0.5,
|
||||
d1_retention_rate: 0.25,
|
||||
game_profit_rate: 0.17,
|
||||
game_turnover: 300_000,
|
||||
gift_coin_spent: 123_456,
|
||||
google_recharge_usd_minor: 700,
|
||||
mifapay_recharge_usd_minor: 800,
|
||||
new_users: 3,
|
||||
output_coin: 450_000,
|
||||
paid_conversion_rate: 0.2,
|
||||
paid_users: 4,
|
||||
recharge_usd_minor: 12_300,
|
||||
recharge_users: 4
|
||||
};
|
||||
const aslanRegionRow = {
|
||||
active_users: 10,
|
||||
new_users: 1,
|
||||
paid_users: 2,
|
||||
recharge_usd_minor: 5000,
|
||||
recharge_users: 2,
|
||||
region_code: "MENA",
|
||||
region_id: 11,
|
||||
region_name: "中东大区"
|
||||
};
|
||||
fetchSocialBiMaster.mockResolvedValue({
|
||||
access: { all: true, scopes: [] },
|
||||
apps: [
|
||||
{ app_code: "lalu", app_name: "Lalu", kind: "hyapp" },
|
||||
{ app_code: "aslan", app_name: "Aslan", kind: "legacy" }
|
||||
],
|
||||
operators: [
|
||||
{ account: "omar", name: "Omar", scopes: [{ app_code: "aslan", region_id: 11 }], team: "中东运营部", team_id: 2, user_id: 9 }
|
||||
],
|
||||
permissions: {},
|
||||
regions: [{ app_code: "aslan", countries: ["SA"], region_code: "MENA", region_id: 11, region_name: "中东大区" }]
|
||||
});
|
||||
fetchSocialBiOverview.mockResolvedValue({
|
||||
access: { all: true, scopes: [] },
|
||||
apps: [
|
||||
{
|
||||
app_code: "lalu",
|
||||
app_name: "Lalu",
|
||||
country_breakdown: [],
|
||||
daily_country_breakdown: [],
|
||||
daily_region_breakdown: [],
|
||||
daily_series: [],
|
||||
kind: "hyapp",
|
||||
region_breakdown: [],
|
||||
total: laluTotal
|
||||
},
|
||||
{
|
||||
app_code: "aslan",
|
||||
app_name: "Aslan",
|
||||
country_breakdown: [],
|
||||
daily_country_breakdown: [],
|
||||
daily_region_breakdown: [],
|
||||
daily_series: [],
|
||||
kind: "legacy",
|
||||
region_breakdown: [aslanRegionRow],
|
||||
total: { active_users: 10, new_users: 1, paid_users: 2, recharge_usd_minor: 5000, recharge_users: 2 }
|
||||
}
|
||||
]
|
||||
});
|
||||
fetchSocialBiKpi.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
app_code: "aslan",
|
||||
app_name: "Aslan",
|
||||
month_recharge_usd_minor: 90_000,
|
||||
operator_account: "omar",
|
||||
operator_name: "Omar",
|
||||
operator_user_id: 9,
|
||||
range_new_users: 1,
|
||||
range_recharge_usd_minor: 5000,
|
||||
region_id: 11,
|
||||
region_name: "中东大区",
|
||||
team: "中东运营部",
|
||||
team_id: 2
|
||||
}
|
||||
],
|
||||
period_month: "2026-06",
|
||||
summary: { month_recharge_usd_minor: 90_000, operator_count: 1, range_recharge_usd_minor: 5000 }
|
||||
});
|
||||
|
||||
render(<DatabiApp />);
|
||||
|
||||
await flushEffects();
|
||||
|
||||
// v2 Shell:主视图容器 + 侧栏视图 tab,默认选中“经营概览”。
|
||||
expect(screen.getByLabelText("Social BI 数据中心")).toBeTruthy();
|
||||
const viewTabs = screen.getByRole("tablist", { name: "数据视图" });
|
||||
["经营概览", "地区洞察", "运营中心", "留存质量", "数据明细", "埋点漏斗"].forEach((name) => {
|
||||
expect(within(viewTabs).getByRole("tab", { name })).toBeTruthy();
|
||||
});
|
||||
expect(within(viewTabs).getByRole("tab", { name: "经营概览" })).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
expect(fetchSocialBiMaster).toHaveBeenCalledTimes(1);
|
||||
expect(fetchSocialBiOverview).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
|
||||
expect(fetchSocialBiFunnel).not.toHaveBeenCalled();
|
||||
expect(fetchSocialBiKpi).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
|
||||
|
||||
// 经营概览:App 名、Hero 汇总充值(12,300 + 5,000 USD 分 = $173)、
|
||||
// App 对比里 Lalu 的单 App 充值($123)、区域 Top5 里的中东大区。
|
||||
expect(screen.getAllByText("Lalu").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("充值 ($)").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("付费用户")).toBeTruthy();
|
||||
expect(screen.getAllByText("$173").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("$123").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("中东大区").length).toBeGreaterThan(0);
|
||||
|
||||
// 侧栏切换:地区洞察 —— 区域下钻表出现,Aslan 中东大区行充值 $50。
|
||||
await act(async () => {
|
||||
within(viewTabs).getByRole("tab", { name: "地区洞察" }).click();
|
||||
});
|
||||
expect(within(viewTabs).getByRole("tab", { name: "地区洞察" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByText("区域下钻")).toBeTruthy();
|
||||
expect(screen.getAllByText("中东大区").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("$50").length).toBeGreaterThan(0);
|
||||
|
||||
// 侧栏切换:运营中心 —— 运营人员 Omar 上榜,只展示负责范围内的充值数据。
|
||||
await act(async () => {
|
||||
within(viewTabs).getByRole("tab", { name: "运营中心" }).click();
|
||||
});
|
||||
expect(within(viewTabs).getByRole("tab", { name: "运营中心" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getAllByText("Omar").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("$900").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("renders social BI data requirements table with section filters and CSV export", async () => {
|
||||
window.history.pushState(null, "", "/databi/social/?view=table®ions=lalu:9,lalu:11");
|
||||
fetchSocialBiMaster.mockResolvedValue({
|
||||
access: { all: true, scopes: [] },
|
||||
apps: [{ app_code: "lalu", app_name: "Lalu", kind: "hyapp" }],
|
||||
operators: [],
|
||||
permissions: {},
|
||||
regions: []
|
||||
});
|
||||
fetchSocialBiOverview.mockResolvedValue({
|
||||
access: { all: true, scopes: [] },
|
||||
apps: [{ app_code: "lalu", app_name: "Lalu", daily_series: [], kind: "hyapp", total: {} }]
|
||||
});
|
||||
fetchSocialBiRequirements.mockImplementation(async ({ section }) => {
|
||||
if (section === "revenue") {
|
||||
return {
|
||||
access: { all: true, scopes: [] },
|
||||
apps: [{
|
||||
app_code: "lalu",
|
||||
app_name: "Lalu",
|
||||
kind: "hyapp",
|
||||
sections: [{
|
||||
columns: [
|
||||
{ key: "recharge_users", label: "充值用户", type: "count" },
|
||||
{ key: "recharge_usd_minor", label: "充值金额", type: "money_minor" }
|
||||
],
|
||||
daily_series: [{ metrics: { recharge_users: 2, recharge_usd_minor: 12300 }, stat_day: "2026-06-05" }],
|
||||
key: section,
|
||||
total: { label: "汇总", metrics: { recharge_users: 2, recharge_usd_minor: 12300 }, stat_day: "total" }
|
||||
}]
|
||||
}]
|
||||
};
|
||||
}
|
||||
return {
|
||||
access: { all: true, scopes: [] },
|
||||
apps: [{
|
||||
app_code: "lalu",
|
||||
app_name: "Lalu",
|
||||
kind: "hyapp",
|
||||
sections: [{
|
||||
columns: [
|
||||
{ key: "app_download_users", label: "下载APP人数", type: "count" },
|
||||
{ key: "registration_success_rate", label: "注册成功率", type: "ratio" }
|
||||
],
|
||||
daily_series: [{ metrics: { app_download_users: 12, registration_success_rate: 0.6666 }, stat_day: "2026-06-05" }],
|
||||
key: section,
|
||||
total: { label: "汇总", metrics: { app_download_users: 12, registration_success_rate: 0.6666 }, stat_day: "total" }
|
||||
}]
|
||||
}]
|
||||
};
|
||||
});
|
||||
|
||||
render(<DatabiApp />);
|
||||
await flushEffects();
|
||||
|
||||
const viewTabs = screen.getByRole("tablist", { name: "数据视图" });
|
||||
expect(within(viewTabs).getByRole("tab", { name: "数据明细" })).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await act(async () => {
|
||||
screen.getByRole("radio", { name: "数据需求" }).click();
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(fetchSocialBiRequirements).toHaveBeenCalledWith(expect.objectContaining({
|
||||
payerType: "all",
|
||||
regionIds: [9, 11],
|
||||
section: "new_users",
|
||||
userRole: "all"
|
||||
}));
|
||||
expect(screen.getByRole("tab", { name: "新用户" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByText("下载APP人数")).toBeTruthy();
|
||||
expect(screen.getByText("注册成功率")).toBeTruthy();
|
||||
expect(screen.getAllByText("12").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("66.66%").length).toBeGreaterThan(0);
|
||||
expect(screen.queryByRole("radiogroup", { name: "用户身份" })).toBeNull();
|
||||
expect(screen.queryByRole("radiogroup", { name: "付费身份" })).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
screen.getByRole("tab", { name: "营收" }).click();
|
||||
});
|
||||
await flushEffects();
|
||||
expect(screen.getByRole("radiogroup", { name: "用户身份" })).toBeTruthy();
|
||||
expect(screen.getByRole("radiogroup", { name: "付费身份" })).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
screen.getByRole("radio", { name: "主播" }).click();
|
||||
});
|
||||
await flushEffects();
|
||||
await act(async () => {
|
||||
screen.getByRole("radio", { name: "已付费" }).click();
|
||||
});
|
||||
await flushEffects();
|
||||
|
||||
expect(fetchSocialBiRequirements).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
payerType: "paid",
|
||||
section: "revenue",
|
||||
userRole: "host"
|
||||
}));
|
||||
expect(screen.getByText("充值用户")).toBeTruthy();
|
||||
expect(screen.getByText("充值金额")).toBeTruthy();
|
||||
expect(screen.getAllByText("$123").length).toBeGreaterThan(0);
|
||||
|
||||
const urlMock = { createObjectURL: vi.fn(() => "blob:social-bi-requirements"), revokeObjectURL: vi.fn() };
|
||||
const anchorClick = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
vi.stubGlobal("URL", urlMock);
|
||||
await act(async () => {
|
||||
screen.getByRole("button", { name: "导出 CSV" }).click();
|
||||
});
|
||||
expect(urlMock.createObjectURL).toHaveBeenCalledTimes(1);
|
||||
expect(anchorClick).toHaveBeenCalledTimes(1);
|
||||
anchorClick.mockRestore();
|
||||
});
|
||||
|
||||
test("renders social BI funnel view and limits funnel apps to supported apps", async () => {
|
||||
window.history.pushState(null, "", "/databi/social/?view=funnel");
|
||||
fetchSocialBiMaster.mockResolvedValue({
|
||||
access: { all: true, scopes: [] },
|
||||
apps: [
|
||||
{ app_code: "lalu", app_name: "Lalu", kind: "hyapp" },
|
||||
{ app_code: "huwaa", app_name: "Huwaa", kind: "hyapp" },
|
||||
{ app_code: "fami", app_name: "Fami", kind: "hyapp" },
|
||||
{ app_code: "aslan", app_name: "Aslan", kind: "legacy" },
|
||||
{ app_code: "yumi", app_name: "Yumi", kind: "legacy" }
|
||||
],
|
||||
operators: [],
|
||||
permissions: {},
|
||||
regions: []
|
||||
});
|
||||
fetchSocialBiFunnel.mockResolvedValue({
|
||||
access: { all: true, scopes: [] },
|
||||
apps: [
|
||||
{
|
||||
app_code: "lalu",
|
||||
app_name: "Lalu",
|
||||
cohorts: [
|
||||
{
|
||||
base_users: 1,
|
||||
d1_retention_rate: 1,
|
||||
d1_retention_users: 1,
|
||||
dimension: "country",
|
||||
label: "US",
|
||||
steps: [
|
||||
{ event_name: "login_success", user_count: 1 },
|
||||
{ event_name: "profile_complete", user_count: 1 },
|
||||
{ event_name: "room_join_success", user_count: 1 },
|
||||
{ event_name: "stay_3m", user_count: 1 },
|
||||
{ event_name: "send_message", user_count: 1 }
|
||||
],
|
||||
value: "US"
|
||||
}
|
||||
],
|
||||
kind: "hyapp",
|
||||
steps: [
|
||||
{ event_name: "login_start", event_count: 2, label: "登录开始", user_count: 2 },
|
||||
{ event_name: "login_success", event_count: 1, label: "登录成功", user_count: 1 },
|
||||
{ event_name: "room_join_success", event_count: 1, label: "进房成功", user_count: 1 },
|
||||
{ event_name: "room_join_fail", event_count: 1, is_failure: true, label: "进房失败", user_count: 1 },
|
||||
{ event_name: "stay_3m", event_count: 1, label: "停留 3 分钟", user_count: 1 },
|
||||
{ event_name: "send_message", event_count: 1, label: "发消息", user_count: 1 }
|
||||
],
|
||||
totals: {
|
||||
d1_retention_base_users: 1,
|
||||
d1_retention_rate: 1,
|
||||
d1_retention_users: 1,
|
||||
login_start_users: 2,
|
||||
login_success_users: 1,
|
||||
room_join_fail_users: 1,
|
||||
room_join_success_users: 1
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
render(<DatabiApp />);
|
||||
|
||||
await flushEffects();
|
||||
|
||||
expect(screen.getByRole("tab", { name: "埋点漏斗" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByRole("button", { name: "全部漏斗 App" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /Lalu/ })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /Huwaa/ })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /Fami/ })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: /Aslan/ })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /Yumi/ })).toBeNull();
|
||||
expect(fetchSocialBiFunnel).toHaveBeenCalledWith(expect.objectContaining({ appCodes: ["lalu", "huwaa", "fami"] }));
|
||||
|
||||
expect(screen.getByText("主路径转化")).toBeTruthy();
|
||||
expect(screen.getByText("事件明细")).toBeTruthy();
|
||||
expect(screen.getByText("D1 Cohort")).toBeTruthy();
|
||||
expect(screen.getByText("login_start")).toBeTruthy();
|
||||
expect(screen.getByText("room_join_fail")).toBeTruthy();
|
||||
expect(screen.getByText("US")).toBeTruthy();
|
||||
expect(screen.getAllByText("100.0%").length).toBeGreaterThan(0);
|
||||
expect(screen.getByTestId("databi-chart")).toBeTruthy();
|
||||
});
|
||||
|
||||
async function flushEffects() {
|
||||
await act(async () => {
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
499
databi/src/api.js
Normal file
499
databi/src/api.js
Normal file
@ -0,0 +1,499 @@
|
||||
import { API_OPERATIONS, apiEndpointPath } from "../../src/shared/api/generated/endpoints.ts";
|
||||
import { API_BASE_URL } from "../../src/shared/config/env.js";
|
||||
import { countryFlag, stripCountryFlagPrefix } from "./data/countryMeta.js";
|
||||
|
||||
const TOKEN_KEY = "hyapp-admin.access-token";
|
||||
const APP_CODE_KEY = "hyapp-admin.app-code";
|
||||
const APP_CODE_HEADER = "X-App-Code";
|
||||
const DEFAULT_APP_CODE = "lalu";
|
||||
const ALL_APP_OPTION = { code: "", label: "全部", value: "" };
|
||||
const DEFAULT_APP_OPTION = { code: DEFAULT_APP_CODE, label: "Lalu", value: DEFAULT_APP_CODE };
|
||||
const FIXED_APP_OPTIONS = [
|
||||
{ code: "aslan", label: "Aslan", value: "aslan" },
|
||||
{ code: "yumi", label: "Yumi", value: "yumi" }
|
||||
];
|
||||
const SOCIAL_BI_ALL_VALUE = "all";
|
||||
const SOCIAL_BI_OPERATION_TEAM_ID = 2;
|
||||
|
||||
export function getDefaultAppOptions() {
|
||||
return mergeAppOptions([ALL_APP_OPTION, DEFAULT_APP_OPTION, ...FIXED_APP_OPTIONS]);
|
||||
}
|
||||
|
||||
export function getCurrentAppCode() {
|
||||
const stored = window.localStorage.getItem(APP_CODE_KEY);
|
||||
return stored === null ? DEFAULT_APP_CODE : normalizeAppCode(stored);
|
||||
}
|
||||
|
||||
export function setCurrentAppCode(value) {
|
||||
const appCode = normalizeAppCode(value);
|
||||
window.localStorage.setItem(APP_CODE_KEY, appCode);
|
||||
return appCode;
|
||||
}
|
||||
|
||||
export async function fetchStatisticsOverview({ appCode, countryId, endMs, regionId, seriesEndMs, seriesStartMs, startMs, statTz }) {
|
||||
const query = appScopedQuery(appCode);
|
||||
if (statTz) {
|
||||
query.stat_tz = statTz;
|
||||
}
|
||||
if (startMs) {
|
||||
query.start_ms = String(startMs);
|
||||
}
|
||||
if (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.country_id = String(countryId);
|
||||
}
|
||||
if (regionId && regionId !== "all") {
|
||||
query.region_id = String(regionId);
|
||||
}
|
||||
|
||||
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.statisticsOverview), { appCode, query });
|
||||
}
|
||||
|
||||
export async function fetchPlatformGrantUsers({ appCode, countryId, endMs, page, pageSize, regionId, startMs, statDay, statTz }) {
|
||||
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.listPlatformGrantUsers), {
|
||||
appCode,
|
||||
query: platformGrantQuery({ appCode, countryId, endMs, page, pageSize, regionId, startMs, statDay, statTz })
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchPlatformGrantRecords({ appCode, countryId, endMs, page, pageSize, regionId, source, startMs, statDay, statTz, userId }) {
|
||||
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.listPlatformGrantRecords), {
|
||||
appCode,
|
||||
query: platformGrantQuery({ appCode, countryId, endMs, page, pageSize, regionId, source, startMs, statDay, statTz, userId })
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchSelfGameStatisticsOverview({ appCode, countryId, endMs, gameId, regionId, startMs, statTz }) {
|
||||
const query = appScopedQuery(appCode);
|
||||
if (statTz) {
|
||||
query.stat_tz = statTz;
|
||||
}
|
||||
if (startMs) {
|
||||
query.start_ms = String(startMs);
|
||||
}
|
||||
if (endMs) {
|
||||
query.end_ms = String(endMs);
|
||||
}
|
||||
if (countryId) {
|
||||
query.country_id = String(countryId);
|
||||
}
|
||||
if (regionId && regionId !== "all") {
|
||||
query.region_id = String(regionId);
|
||||
}
|
||||
if (gameId && gameId !== "all") {
|
||||
query.game_id = String(gameId);
|
||||
}
|
||||
|
||||
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.selfGameStatisticsOverview), { appCode, query });
|
||||
}
|
||||
|
||||
function platformGrantQuery({ appCode, countryId, endMs, page, pageSize, regionId, source, startMs, statDay, statTz, userId }) {
|
||||
const query = appScopedQuery(appCode);
|
||||
if (statTz) {
|
||||
query.stat_tz = statTz;
|
||||
}
|
||||
if (startMs) {
|
||||
query.start_ms = String(startMs);
|
||||
}
|
||||
if (endMs) {
|
||||
query.end_ms = String(endMs);
|
||||
}
|
||||
if (statDay) {
|
||||
query.stat_day = statDay;
|
||||
}
|
||||
if (countryId) {
|
||||
query.country_id = String(countryId);
|
||||
}
|
||||
if (regionId && regionId !== "all") {
|
||||
query.region_id = String(regionId);
|
||||
}
|
||||
if (userId) {
|
||||
query.user_id = String(userId);
|
||||
}
|
||||
if (source && source !== "all") {
|
||||
query.source = String(source);
|
||||
}
|
||||
if (page) {
|
||||
query.page = String(page);
|
||||
}
|
||||
if (pageSize) {
|
||||
query.page_size = String(pageSize);
|
||||
}
|
||||
return 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 });
|
||||
}
|
||||
|
||||
export async function fetchSocialBiFilterOptions({ appCode } = {}) {
|
||||
const [apps, operators] = await Promise.all([
|
||||
fetchDatabiData(apiEndpointPath(API_OPERATIONS.listApps), { appCode }),
|
||||
fetchDatabiData(apiEndpointPath(API_OPERATIONS.listTeamUsers, { team_id: SOCIAL_BI_OPERATION_TEAM_ID }), {
|
||||
appCode,
|
||||
query: { page_size: 200, status: "active" }
|
||||
})
|
||||
]);
|
||||
|
||||
return {
|
||||
appOptions: normalizeSocialBiAppOptions(apps),
|
||||
operatorOptions: normalizeSocialBiOperatorOptions(operators)
|
||||
};
|
||||
}
|
||||
|
||||
// 社交 BI 主数据:按当前登录用户的数据范围返回可见 App、区域目录、运营人员及权限开关。
|
||||
export async function fetchSocialBiMaster() {
|
||||
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiMaster));
|
||||
}
|
||||
|
||||
export async function fetchSocialBiOverview({ appCodes, endMs, regionId, startMs, statTz }) {
|
||||
const query = {};
|
||||
if (statTz) {
|
||||
query.stat_tz = statTz;
|
||||
}
|
||||
if (startMs) {
|
||||
query.start_ms = String(startMs);
|
||||
}
|
||||
if (endMs) {
|
||||
query.end_ms = String(endMs);
|
||||
}
|
||||
if (appCodes?.length) {
|
||||
query.app_codes = appCodes.join(",");
|
||||
}
|
||||
if (regionId) {
|
||||
query.region_id = String(regionId);
|
||||
}
|
||||
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiOverview), { query });
|
||||
}
|
||||
|
||||
export async function fetchSocialBiRequirements({ appCodes, endMs, newUserType, payerType, regionId, regionIds, section, startMs, statTz, userRole }) {
|
||||
const query = {};
|
||||
if (statTz) {
|
||||
query.stat_tz = statTz;
|
||||
}
|
||||
if (startMs) {
|
||||
query.start_ms = String(startMs);
|
||||
}
|
||||
if (endMs) {
|
||||
query.end_ms = String(endMs);
|
||||
}
|
||||
if (appCodes?.length) {
|
||||
query.app_codes = appCodes.join(",");
|
||||
}
|
||||
if (regionId) {
|
||||
query.region_id = String(regionId);
|
||||
} else if (regionIds?.length) {
|
||||
query.region_ids = regionIds.join(",");
|
||||
}
|
||||
if (section) {
|
||||
query.section = section;
|
||||
}
|
||||
if (userRole) {
|
||||
query.user_role = userRole;
|
||||
}
|
||||
if (payerType) {
|
||||
query.payer_type = payerType;
|
||||
}
|
||||
if (newUserType) {
|
||||
query.new_user_type = newUserType;
|
||||
}
|
||||
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiRequirements), { query });
|
||||
}
|
||||
|
||||
export async function fetchSocialBiFunnel({ appCodes, endMs, regionId, startMs, statTz }) {
|
||||
const query = {};
|
||||
if (statTz) {
|
||||
query.stat_tz = statTz;
|
||||
}
|
||||
if (startMs) {
|
||||
query.start_ms = String(startMs);
|
||||
}
|
||||
if (endMs) {
|
||||
query.end_ms = String(endMs);
|
||||
}
|
||||
if (appCodes?.length) {
|
||||
query.app_codes = appCodes.join(",");
|
||||
}
|
||||
if (regionId) {
|
||||
query.region_id = String(regionId);
|
||||
}
|
||||
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiFunnel), { query });
|
||||
}
|
||||
|
||||
export async function fetchSocialBiKpi({ appCodes, endMs, operatorUserId, periodMonth, startMs, statTz }) {
|
||||
const query = {};
|
||||
if (statTz) {
|
||||
query.stat_tz = statTz;
|
||||
}
|
||||
if (startMs) {
|
||||
query.start_ms = String(startMs);
|
||||
}
|
||||
if (endMs) {
|
||||
query.end_ms = String(endMs);
|
||||
}
|
||||
if (periodMonth) {
|
||||
query.period_month = periodMonth;
|
||||
}
|
||||
if (appCodes?.length) {
|
||||
query.app_codes = appCodes.join(",");
|
||||
}
|
||||
if (operatorUserId) {
|
||||
query.operator_user_id = String(operatorUserId);
|
||||
}
|
||||
return fetchDatabiData(apiEndpointPath(API_OPERATIONS.getSocialBiKpi), { query });
|
||||
}
|
||||
|
||||
async function fetchDatabiData(path, { appCode, body, method, query } = {}) {
|
||||
const headers = requestHeaders(appCode);
|
||||
const init = {
|
||||
credentials: "include",
|
||||
headers,
|
||||
method: method || "GET"
|
||||
};
|
||||
if (body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
const response = await fetch(buildURL(path, query), init);
|
||||
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);
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
const normalized = normalizeAppCode(appCode);
|
||||
if (normalized) {
|
||||
headers[APP_CODE_HEADER] = normalized;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function readJSON(response) {
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return { code: response.ok ? 0 : response.status, message: text };
|
||||
}
|
||||
}
|
||||
|
||||
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 appScopedQuery(appCode) {
|
||||
const normalized = normalizeAppCode(appCode);
|
||||
// “全部”使用空 appCode,让请求层省略 X-App-Code 和 app_code,避免把 all 当成真实 App 传给后端。
|
||||
return normalized ? { app_code: normalized } : {};
|
||||
}
|
||||
|
||||
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 apiAppOptions = 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);
|
||||
const appOptions = mergeAppOptions([ALL_APP_OPTION, ...apiAppOptions, DEFAULT_APP_OPTION, ...FIXED_APP_OPTIONS]);
|
||||
|
||||
return {
|
||||
appOptions,
|
||||
countryOptions,
|
||||
regionOptions
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSocialBiAppOptions(apps) {
|
||||
const options = normalizeList(apps)
|
||||
.map((app) => {
|
||||
const code = normalizeAppCode(app.appCode ?? app.app_code ?? app.code);
|
||||
if (!code) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
label: app.appName || app.app_name || app.name || code,
|
||||
value: code
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
const fixedOptions = FIXED_APP_OPTIONS.map((app) => ({ label: app.label, value: app.value }));
|
||||
return [{ label: "全部 App", value: SOCIAL_BI_ALL_VALUE }, ...dedupeOptions([...options, ...fixedOptions])];
|
||||
}
|
||||
|
||||
function normalizeSocialBiOperatorOptions(operators) {
|
||||
const options = normalizeList(operators)
|
||||
.map((user) => {
|
||||
const id = user.id ?? user.userId ?? user.user_id ?? user.account;
|
||||
const label = user.name || user.nickname || user.account || String(id || "");
|
||||
if (!id || !label) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
label,
|
||||
searchText: [label, user.account, user.teamName, user.team].filter(Boolean).join(" ").toLowerCase(),
|
||||
value: String(id)
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
return [{ label: "全部人员", searchText: "all 全部人员", value: SOCIAL_BI_ALL_VALUE }, ...dedupeOptions(options)];
|
||||
}
|
||||
|
||||
function dedupeOptions(options) {
|
||||
const seen = new Set();
|
||||
return options.filter((option) => {
|
||||
const key = String(option.value || option.label || "").trim().toLowerCase();
|
||||
if (!key || seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function mergeAppOptions(options) {
|
||||
const seen = new Set();
|
||||
return options.filter((option) => {
|
||||
const code = normalizeAppCode(option.code ?? option.value);
|
||||
if (seen.has(code)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(code);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
148
databi/src/api.test.js
Normal file
148
databi/src/api.test.js
Normal file
@ -0,0 +1,148 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import {
|
||||
fetchFilterOptions,
|
||||
fetchSocialBiFilterOptions,
|
||||
fetchSocialBiRequirements,
|
||||
fetchStatisticsOverview,
|
||||
getCurrentAppCode,
|
||||
getDefaultAppOptions,
|
||||
setCurrentAppCode
|
||||
} from "./api.js";
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("stores the last selected databi app including all apps", () => {
|
||||
expect(getCurrentAppCode()).toBe("lalu");
|
||||
|
||||
expect(setCurrentAppCode("YUMI")).toBe("yumi");
|
||||
expect(window.localStorage.getItem("hyapp-admin.app-code")).toBe("yumi");
|
||||
expect(getCurrentAppCode()).toBe("yumi");
|
||||
|
||||
expect(setCurrentAppCode("")).toBe("");
|
||||
expect(window.localStorage.getItem("hyapp-admin.app-code")).toBe("");
|
||||
expect(getCurrentAppCode()).toBe("");
|
||||
});
|
||||
|
||||
test("adds fixed databi app options without losing API apps", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/v1/admin/apps")) {
|
||||
return jsonResponse({ items: [{ appCode: "huwaa", appName: "Huwaa" }, { app_code: "lalu", name: "Lalu" }] });
|
||||
}
|
||||
if (url.includes("/v1/admin/regions")) {
|
||||
return jsonResponse({ items: [] });
|
||||
}
|
||||
if (url.includes("/v1/admin/countries")) {
|
||||
return jsonResponse({ items: [] });
|
||||
}
|
||||
return jsonResponse(null, 404);
|
||||
})
|
||||
);
|
||||
|
||||
const options = await fetchFilterOptions({ appCode: "lalu" });
|
||||
|
||||
expect(options.appOptions).toEqual([
|
||||
{ code: "", label: "全部", value: "" },
|
||||
{ code: "huwaa", label: "Huwaa", value: "huwaa" },
|
||||
{ code: "lalu", label: "Lalu", value: "lalu" },
|
||||
{ code: "aslan", label: "Aslan", value: "aslan" },
|
||||
{ code: "yumi", label: "Yumi", value: "yumi" }
|
||||
]);
|
||||
});
|
||||
|
||||
test("provides static databi app options before filter API loads", () => {
|
||||
expect(getDefaultAppOptions()).toEqual([
|
||||
{ code: "", label: "全部", value: "" },
|
||||
{ code: "lalu", label: "Lalu", value: "lalu" },
|
||||
{ code: "aslan", label: "Aslan", value: "aslan" },
|
||||
{ code: "yumi", label: "Yumi", value: "yumi" }
|
||||
]);
|
||||
});
|
||||
|
||||
test("loads social BI apps and operation users from admin APIs", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/v1/admin/apps")) {
|
||||
return jsonResponse({ items: [{ appCode: "huwaa", appName: "Huwaa" }] });
|
||||
}
|
||||
if (url.includes("/v1/teams/2/users")) {
|
||||
return jsonResponse({ items: [{ account: "ava", id: 7, name: "Ava Chen", teamName: "运营一部" }] });
|
||||
}
|
||||
return jsonResponse(null, 404);
|
||||
})
|
||||
);
|
||||
|
||||
const options = await fetchSocialBiFilterOptions({ appCode: "lalu" });
|
||||
|
||||
expect(options.appOptions).toEqual([
|
||||
{ label: "全部 App", value: "all" },
|
||||
{ label: "Huwaa", value: "huwaa" },
|
||||
{ label: "Aslan", value: "aslan" },
|
||||
{ label: "Yumi", value: "yumi" }
|
||||
]);
|
||||
expect(options.operatorOptions).toEqual([
|
||||
{ label: "全部人员", searchText: "all 全部人员", value: "all" },
|
||||
{ label: "Ava Chen", searchText: "ava chen ava 运营一部", value: "7" }
|
||||
]);
|
||||
expect(String(fetch.mock.calls[1][0])).toContain("/api/v1/teams/2/users?");
|
||||
expect(String(fetch.mock.calls[1][0])).toContain("status=active");
|
||||
});
|
||||
|
||||
test("loads social BI requirements with section role and payer filters", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ section: "revenue", total: { recharge_users: 2 } })));
|
||||
|
||||
const data = await fetchSocialBiRequirements({
|
||||
appCodes: ["lalu", "huwaa"],
|
||||
endMs: 20,
|
||||
payerType: "paid",
|
||||
regionIds: [9, 11],
|
||||
section: "revenue",
|
||||
startMs: 10,
|
||||
statTz: "Asia/Shanghai",
|
||||
userRole: "host"
|
||||
});
|
||||
|
||||
const requestURL = new URL(String(fetch.mock.calls[0][0]));
|
||||
expect(requestURL.pathname).toBe("/api/v1/admin/databi/social/requirements");
|
||||
expect(requestURL.searchParams.get("app_codes")).toBe("lalu,huwaa");
|
||||
expect(requestURL.searchParams.get("section")).toBe("revenue");
|
||||
expect(requestURL.searchParams.get("user_role")).toBe("host");
|
||||
expect(requestURL.searchParams.get("payer_type")).toBe("paid");
|
||||
expect(requestURL.searchParams.get("region_ids")).toBe("9,11");
|
||||
expect(requestURL.searchParams.get("stat_tz")).toBe("Asia/Shanghai");
|
||||
expect(data.total.recharge_users).toBe(2);
|
||||
});
|
||||
|
||||
test("omits app scope headers and query params for all databi apps", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ updated_at_ms: 1 })));
|
||||
|
||||
await fetchStatisticsOverview({
|
||||
appCode: "",
|
||||
countryId: 0,
|
||||
endMs: 2,
|
||||
regionId: "all",
|
||||
seriesEndMs: 2,
|
||||
seriesStartMs: 1,
|
||||
startMs: 1,
|
||||
statTz: "Asia/Shanghai"
|
||||
});
|
||||
|
||||
const [url, init] = fetch.mock.calls[0];
|
||||
const requestURL = new URL(String(url));
|
||||
expect(requestURL.searchParams.has("app_code")).toBe(false);
|
||||
expect(init.headers["X-App-Code"]).toBeUndefined();
|
||||
});
|
||||
|
||||
function jsonResponse(data, status = 200) {
|
||||
return new Response(JSON.stringify({ code: status === 200 ? 0 : status, data, message: status === 200 ? "" : "error" }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
status
|
||||
});
|
||||
}
|
||||
45
databi/src/charts/EChart.jsx
Normal file
45
databi/src/charts/EChart.jsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { BarChart, EffectScatterChart, FunnelChart, LineChart, LinesChart, MapChart, PieChart, ScatterChart } from "echarts/charts";
|
||||
import { GeoComponent, GridComponent, LegendComponent, TooltipComponent, VisualMapComponent } from "echarts/components";
|
||||
import * as echarts from "echarts/core";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import worldMap from "../data/world.json";
|
||||
|
||||
echarts.use([BarChart, EffectScatterChart, FunnelChart, GeoComponent, GridComponent, LegendComponent, LineChart, LinesChart, MapChart, PieChart, ScatterChart, TooltipComponent, VisualMapComponent, CanvasRenderer]);
|
||||
echarts.registerMap("databi-world", worldMap);
|
||||
|
||||
export function EChart({ className = "", option }) {
|
||||
const elementRef = useRef(null);
|
||||
const chartRef = useRef(null);
|
||||
const optionRef = useRef(option);
|
||||
|
||||
useEffect(() => {
|
||||
optionRef.current = option;
|
||||
chartRef.current?.setOption(option, true);
|
||||
}, [option]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = elementRef.current;
|
||||
if (!element) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const chart = echarts.init(element, null, { renderer: "canvas" });
|
||||
chartRef.current = chart;
|
||||
chart.setOption(optionRef.current, true);
|
||||
|
||||
const resize = () => chart.resize();
|
||||
const observer = typeof ResizeObserver === "function" ? new ResizeObserver(resize) : null;
|
||||
observer?.observe(element);
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
return () => {
|
||||
observer?.disconnect();
|
||||
window.removeEventListener("resize", resize);
|
||||
chart.dispose();
|
||||
chartRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div className={["databi-chart", className].filter(Boolean).join(" ")} ref={elementRef} />;
|
||||
}
|
||||
23
databi/src/charts/options/createDonutOption.js
Normal file
23
databi/src/charts/options/createDonutOption.js
Normal file
@ -0,0 +1,23 @@
|
||||
import { formatCoin } from "../../utils/format.js";
|
||||
|
||||
export function createDonutOption(items) {
|
||||
return {
|
||||
animationDuration: 650,
|
||||
color: ["#2dd7ef", "#1dc9aa", "#367be8", "#9a9cfb"],
|
||||
series: [
|
||||
{
|
||||
data: items,
|
||||
itemStyle: { borderColor: "#062039", borderWidth: 2 },
|
||||
label: { show: false },
|
||||
radius: ["56%", "82%"],
|
||||
type: "pie"
|
||||
}
|
||||
],
|
||||
tooltip: {
|
||||
backgroundColor: "#08243a",
|
||||
borderColor: "#1a5f82",
|
||||
formatter: ({ name, percent, value }) => `${name}<br/>${formatCoin(value)} · ${percent}%`,
|
||||
textStyle: { color: "#e9f6ff" }
|
||||
}
|
||||
};
|
||||
}
|
||||
24
databi/src/charts/options/createFunnelOption.js
Normal file
24
databi/src/charts/options/createFunnelOption.js
Normal file
@ -0,0 +1,24 @@
|
||||
export function createFunnelOption(items) {
|
||||
return {
|
||||
animationDuration: 650,
|
||||
color: ["#277be8", "#10a6c4", "#13b5b0", "#17c99d"],
|
||||
series: [
|
||||
{
|
||||
bottom: 6,
|
||||
data: items.map((item) => ({ name: item.name, value: Math.max(item.rate * 100, 8) })),
|
||||
gap: 9,
|
||||
itemStyle: { borderColor: "rgba(220, 250, 255, 0.92)", borderWidth: 1.2, shadowBlur: 14, shadowColor: "rgba(39, 228, 245, 0.16)" },
|
||||
label: { color: "#e8f6ff", distance: 16, formatter: "{b}", fontSize: 12, lineHeight: 16, position: "right" },
|
||||
labelLine: { show: false },
|
||||
left: "5%",
|
||||
maxSize: "88%",
|
||||
minSize: "42%",
|
||||
sort: "descending",
|
||||
top: 4,
|
||||
type: "funnel",
|
||||
width: "58%"
|
||||
}
|
||||
],
|
||||
tooltip: { backgroundColor: "#08243a", borderColor: "#1a5f82", textStyle: { color: "#e9f6ff" } }
|
||||
};
|
||||
}
|
||||
84
databi/src/charts/options/createGeoOption.js
Normal file
84
databi/src/charts/options/createGeoOption.js
Normal file
@ -0,0 +1,84 @@
|
||||
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)
|
||||
.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: {
|
||||
center: [38, 12],
|
||||
emphasis: { disabled: true },
|
||||
itemStyle: {
|
||||
areaColor: "#18354e",
|
||||
borderColor: "rgba(62, 139, 184, 0.52)",
|
||||
borderWidth: 0.65
|
||||
},
|
||||
label: { show: false },
|
||||
layoutCenter: ["50%", "51%"],
|
||||
layoutSize: "112%",
|
||||
map: "databi-world",
|
||||
roam: false,
|
||||
silent: true,
|
||||
zoom: 1.04
|
||||
},
|
||||
series: [
|
||||
{
|
||||
coordinateSystem: "geo",
|
||||
data: points,
|
||||
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",
|
||||
z: 2
|
||||
},
|
||||
{
|
||||
coordinateSystem: "geo",
|
||||
data: points,
|
||||
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",
|
||||
z: 3
|
||||
}
|
||||
],
|
||||
tooltip: {
|
||||
backgroundColor: "#08243a",
|
||||
borderColor: "#1a5f82",
|
||||
formatter: ({ value }) => `${value[3]}<br/>${formatMoney(value[2])}`,
|
||||
textStyle: { color: "#e9f6ff" }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
72
databi/src/charts/options/createReportRevenueOption.js
Normal file
72
databi/src/charts/options/createReportRevenueOption.js
Normal file
@ -0,0 +1,72 @@
|
||||
import { compactMoneyAxis, moneyMajor } from "../../utils/format.js";
|
||||
import { niceAxis } from "./createMetricTrendOption.js";
|
||||
|
||||
export function createReportRevenueOption(series) {
|
||||
const labels = series.map((item) => item.label);
|
||||
const channelValues = series.map((item) => moneyMajor(item.google) + moneyMajor(item.mifapay) + moneyMajor(item.coin_seller));
|
||||
const totalValues = series.map((item) => moneyMajor(item.lineTotal ?? item.total));
|
||||
const channelAxis = niceAxis(channelValues);
|
||||
const totalAxis = niceAxis(totalValues);
|
||||
|
||||
return {
|
||||
animationDuration: 520,
|
||||
color: ["#1688d9", "#18a389", "#6377e8", "#172033"],
|
||||
grid: { bottom: 30, left: 54, right: 54, top: 42 },
|
||||
legend: [
|
||||
{ data: ["Google", "MifaPay", "币商"], icon: "roundRect", itemGap: 16, itemHeight: 8, itemWidth: 12, left: 18, textStyle: { color: "#5f6f86", fontSize: 12 }, top: 0 },
|
||||
{ data: ["总计 (USD)"], itemGap: 8, itemHeight: 8, itemWidth: 18, right: 18, textStyle: { color: "#172033", fontSize: 12 }, top: 0 }
|
||||
],
|
||||
tooltip: {
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "#d8e2ed",
|
||||
extraCssText: "box-shadow: 0 14px 34px rgba(15, 23, 42, .14);",
|
||||
textStyle: { color: "#172033" },
|
||||
trigger: "axis"
|
||||
},
|
||||
xAxis: {
|
||||
axisLabel: { color: "#6b7a90", margin: 10 },
|
||||
axisLine: { lineStyle: { color: "#dbe5ef" } },
|
||||
axisTick: { show: false },
|
||||
boundaryGap: true,
|
||||
data: labels,
|
||||
type: "category"
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
axisLabel: { color: "#6b7a90", formatter: compactMoneyAxis },
|
||||
axisTick: { show: false },
|
||||
interval: channelAxis.interval,
|
||||
max: channelAxis.max,
|
||||
min: 0,
|
||||
splitLine: { lineStyle: { color: "rgba(104, 123, 148, 0.16)", type: "dashed" } },
|
||||
type: "value"
|
||||
},
|
||||
{
|
||||
axisLabel: { color: "#6b7a90", formatter: compactMoneyAxis },
|
||||
axisTick: { show: false },
|
||||
interval: totalAxis.interval,
|
||||
max: totalAxis.max,
|
||||
min: 0,
|
||||
splitLine: { show: false },
|
||||
type: "value"
|
||||
}
|
||||
],
|
||||
series: [
|
||||
{ barGap: "-100%", barWidth: 26, data: series.map((item) => moneyMajor(item.google)), emphasis: { focus: "series" }, name: "Google", stack: "recharge", type: "bar" },
|
||||
{ barWidth: 26, data: series.map((item) => moneyMajor(item.mifapay)), emphasis: { focus: "series" }, name: "MifaPay", stack: "recharge", type: "bar" },
|
||||
{ barWidth: 26, 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: "#172033", shadowBlur: 8, shadowColor: "rgba(23, 32, 51, .14)", width: 3 },
|
||||
name: "总计 (USD)",
|
||||
smooth: 0.42,
|
||||
symbol: "circle",
|
||||
symbolSize: 7,
|
||||
itemStyle: { borderColor: "#ffffff", borderWidth: 2, color: "#172033" },
|
||||
type: "line",
|
||||
yAxisIndex: 1,
|
||||
z: 4
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
42
databi/src/charts/options/createRevenueOption.js
Normal file
42
databi/src/charts/options/createRevenueOption.js
Normal file
@ -0,0 +1,42 @@
|
||||
import { compactMoneyAxis, moneyMajor } from "../../utils/format.js";
|
||||
import { niceAxis } from "./createMetricTrendOption.js";
|
||||
|
||||
export function createRevenueOption(series) {
|
||||
const labels = series.map((item) => item.label);
|
||||
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: ["总计 (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: 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" },
|
||||
{
|
||||
data: series.map((item) => moneyMajor(item.lineTotal ?? item.total)),
|
||||
lineStyle: { color: "#f2f6ff", shadowBlur: 10, shadowColor: "rgba(242,246,255,.42)", width: 3 },
|
||||
name: "总计 (USD)",
|
||||
smooth: 0.42,
|
||||
symbol: "circle",
|
||||
symbolSize: 7,
|
||||
itemStyle: { borderColor: "#f2f6ff", borderWidth: 2, color: "#f2f6ff" },
|
||||
type: "line",
|
||||
yAxisIndex: 1,
|
||||
z: 4
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
322
databi/src/components/CountryPerformanceTable.jsx
Normal file
322
databi/src/components/CountryPerformanceTable.jsx
Normal file
@ -0,0 +1,322 @@
|
||||
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: "游戏排行" }
|
||||
];
|
||||
|
||||
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: "registrations", 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,
|
||||
registrations: (item) => item.registrations,
|
||||
trend_rate: (item) => item.trend_rate
|
||||
};
|
||||
|
||||
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}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function TableTabs({ activeTab, onChange }) {
|
||||
return (
|
||||
<div className="table-tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button className={activeTab === tab.key ? "is-active" : ""} key={tab.key} type="button" onClick={() => onChange(tab.key)}>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
{countryColumns.map((column) => (
|
||||
<SortableHeader column={column} key={column.key} onSort={onSort} sortState={sortState} />
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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>
|
||||
<td>{formatNumber(item.registrations)}</td>
|
||||
<td>{formatNumber(item.active_users)}</td>
|
||||
<td>{formatNumber(item.paid_users)}</td>
|
||||
<td>{formatNumber(item.recharge_users)}</td>
|
||||
<td>{formatMoneyFull(item.recharge_usd_minor)}</td>
|
||||
<td>{formatMoneyFull(item.new_user_recharge_usd_minor)}</td>
|
||||
<td>{formatMoney(item.arpu_usd_minor)}</td>
|
||||
<td>{formatMoney(item.arppu_usd_minor)}</td>
|
||||
<td>{formatPercent(item.payer_rate)}</td>
|
||||
<td>{formatPercent(item.recharge_conversion_rate)}</td>
|
||||
<td>{formatMoney(item.avg_recharge_usd_minor)}</td>
|
||||
<td className="positive">+{formatPercent(item.trend_rate)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
{giftColumns.map((column) => (
|
||||
<SortableHeader column={column} key={column.key} onSort={onSort} sortState={sortState} />
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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={item.trend_rate >= 0 ? "positive" : "negative"}>{formatSignedPercent(item.trend_rate)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
{gameColumns.map((column) => (
|
||||
<SortableHeader column={column} key={column.key} onSort={onSort} sortState={sortState} />
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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_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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</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,
|
||||
registrations: 10
|
||||
}),
|
||||
createCountryRow({
|
||||
country: "巴西",
|
||||
flag: "🇧🇷",
|
||||
recharge_usd_minor: 10000,
|
||||
registrations: 30
|
||||
}),
|
||||
createCountryRow({
|
||||
country: "中国",
|
||||
flag: "🇨🇳",
|
||||
recharge_usd_minor: 30000,
|
||||
registrations: 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,
|
||||
registrations: 0,
|
||||
trend_rate: 0,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function firstCountryCell() {
|
||||
const [, firstDataRow] = screen.getAllByRole("row");
|
||||
|
||||
return within(firstDataRow).getAllByRole("cell")[1];
|
||||
}
|
||||
466
databi/src/components/DatabiHeader.jsx
Normal file
466
databi/src/components/DatabiHeader.jsx
Normal file
@ -0,0 +1,466 @@
|
||||
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 { 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({
|
||||
activeScreen = "overview",
|
||||
appCode,
|
||||
appOptions = [],
|
||||
countryId,
|
||||
countryOptions = defaultCountryOptions,
|
||||
error = "",
|
||||
filtersLoading = false,
|
||||
loading = false,
|
||||
onScreenChange,
|
||||
onAppChange,
|
||||
onCountryChange,
|
||||
onRangeChange,
|
||||
onRegionChange,
|
||||
onTimeZoneChange,
|
||||
onViewModeChange,
|
||||
range,
|
||||
regionId,
|
||||
regionOptions = defaultRegionOptions,
|
||||
timeZone,
|
||||
timeZoneOptions = TIME_ZONE_OPTIONS,
|
||||
viewMode = "report"
|
||||
}) {
|
||||
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("");
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="databi-header" ref={headerRef}>
|
||||
<div className="databi-header-left">
|
||||
<div className="brand-lockup">
|
||||
<div>
|
||||
<h1>数据中心</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="view-mode-switch" role="tablist" aria-label="视图切换">
|
||||
<button aria-selected={viewMode === "report"} className={viewMode === "report" ? "is-active" : ""} onClick={() => onViewModeChange?.("report")} role="tab" type="button">
|
||||
报表视图
|
||||
</button>
|
||||
<button aria-selected={viewMode === "bigscreen"} className={viewMode === "bigscreen" ? "is-active" : ""} onClick={() => onViewModeChange?.("bigscreen")} role="tab" type="button">
|
||||
大屏 BI
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="screen-switch" aria-label="数据大屏切换">
|
||||
<button className={activeScreen === "overview" ? "is-active" : ""} onClick={() => onScreenChange?.("overview")} type="button">
|
||||
运营总览
|
||||
</button>
|
||||
<button className={activeScreen === "selfGames" ? "is-active" : ""} onClick={() => onScreenChange?.("selfGames")} type="button">
|
||||
自研游戏
|
||||
</button>
|
||||
</nav>
|
||||
<div className="header-controls">
|
||||
<FilterMenu
|
||||
className="filter-control--timezone"
|
||||
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={error ? "live-dot live-dot--warn" : "live-dot"} title={error || ""}>
|
||||
{error ? "异常" : loading ? "刷新中" : "实时"}
|
||||
</span>
|
||||
{error ? <span className="header-error">{error}</span> : null}
|
||||
</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">
|
||||
<span className="filter-label">
|
||||
{icon ? <span className="filter-icon">{icon}</span> : null}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
<span className="filter-value">{valueLabel}</span>
|
||||
<KeyboardArrowDownOutlined className="filter-chevron" fontSize="small" />
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<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">
|
||||
<span className="filter-label">
|
||||
{icon ? <span className="filter-icon">{icon}</span> : null}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
<span className="filter-value">{valueLabel}</span>
|
||||
<KeyboardArrowDownOutlined className="filter-chevron" fontSize="small" />
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<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-label">
|
||||
<CalendarMonthOutlined fontSize="inherit" />
|
||||
<span>{activeOption?.label || "时间范围"}</span>
|
||||
</span>
|
||||
<span className="date-range-values">
|
||||
<b>{range.start}</b>
|
||||
<i>~</i>
|
||||
<b>{range.end}</b>
|
||||
</span>
|
||||
<KeyboardArrowDownOutlined className="date-range-chevron" fontSize="small" />
|
||||
</button>
|
||||
{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");
|
||||
}
|
||||
67
databi/src/components/DatabiHeader.test.jsx
Normal file
67
databi/src/components/DatabiHeader.test.jsx
Normal file
@ -0,0 +1,67 @@
|
||||
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");
|
||||
});
|
||||
|
||||
test("selects all apps from app filter", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAppChange = vi.fn();
|
||||
|
||||
render(
|
||||
<DatabiHeader
|
||||
appCode="lalu"
|
||||
appOptions={[
|
||||
{ code: "", label: "全部", value: "" },
|
||||
{ code: "lalu", label: "Lalu", value: "lalu" },
|
||||
{ code: "aslan", label: "Aslan", value: "aslan" },
|
||||
{ code: "yumi", label: "Yumi", value: "yumi" }
|
||||
]}
|
||||
countryId={0}
|
||||
onAppChange={onAppChange}
|
||||
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: /App\s*Lalu/ }));
|
||||
await user.click(within(screen.getByRole("listbox")).getByRole("option", { name: "全部" }));
|
||||
|
||||
expect(onAppChange).toHaveBeenCalledWith("");
|
||||
});
|
||||
32
databi/src/components/FunnelPanel.jsx
Normal file
32
databi/src/components/FunnelPanel.jsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { formatNumber, formatPercent } from "../utils/format.js";
|
||||
import { MiniStat } from "./MiniStat.jsx";
|
||||
import { Panel } from "./Panel.jsx";
|
||||
|
||||
export function FunnelPanel({ funnel, loading = false, sideMetrics }) {
|
||||
return (
|
||||
<Panel title="付费转化漏斗">
|
||||
<div className={["funnel-layout", loading ? "skeleton-panel" : ""].filter(Boolean).join(" ")} aria-busy={loading || undefined}>
|
||||
<div className="funnel-stack">
|
||||
{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">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
37
databi/src/components/GameEconomyPanel.jsx
Normal file
37
databi/src/components/GameEconomyPanel.jsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { formatCoin, formatPercent } from "../utils/format.js";
|
||||
import { FourMetricRow } from "./MiniStat.jsx";
|
||||
import { Panel } from "./Panel.jsx";
|
||||
|
||||
export function GameEconomyPanel({ gameMetrics, gameRanking }) {
|
||||
return (
|
||||
<Panel title="游戏经济">
|
||||
<FourMetricRow items={gameMetrics} />
|
||||
<GameRankTable items={gameRanking} />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function GameRankTable({ items }) {
|
||||
const max = Math.max(...items.map((item) => item.turnover_coin), 1);
|
||||
const total = items.reduce((sum, item) => sum + item.turnover_coin, 0) || 1;
|
||||
return (
|
||||
<div className="game-table">
|
||||
<div className="game-table-head">
|
||||
<span>游戏流水排行</span>
|
||||
<span>流水 (USD)</span>
|
||||
<span>占比</span>
|
||||
</div>
|
||||
{items.map((item, index) => (
|
||||
<div className="game-row" key={`${item.platform_code || "game"}-${item.game_id}`}>
|
||||
<span>{index + 1}</span>
|
||||
<em>{item.game_label || item.game_id || "-"}</em>
|
||||
<div className="bar-track">
|
||||
<i style={{ width: `${Math.max(5, (item.turnover_coin / max) * 100)}%` }} />
|
||||
</div>
|
||||
<b>{formatCoin(item.turnover_coin)}</b>
|
||||
<strong>{formatPercent(item.turnover_coin / total)}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
databi/src/components/GlobalOverviewPanel.jsx
Normal file
125
databi/src/components/GlobalOverviewPanel.jsx
Normal file
@ -0,0 +1,125 @@
|
||||
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";
|
||||
|
||||
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="全球概览">
|
||||
{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>
|
||||
)}
|
||||
</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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRankMoney(value) {
|
||||
const amount = moneyMajor(value);
|
||||
if (Math.abs(amount) >= 100_000) {
|
||||
return `$${(amount / 1_000_000).toFixed(2)}M`;
|
||||
}
|
||||
return formatMoney(value);
|
||||
}
|
||||
|
||||
function hasRecharge(item) {
|
||||
return Number(item?.recharge_usd_minor) > 0;
|
||||
}
|
||||
523
databi/src/components/GlobeMap.jsx
Normal file
523
databi/src/components/GlobeMap.jsx
Normal file
@ -0,0 +1,523 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import worldMap from "../data/world.json";
|
||||
import { formatMoney, formatNumber } from "../utils/format.js";
|
||||
|
||||
const DEGREE = Math.PI / 180;
|
||||
const INITIAL_ROTATION = { lat: 8, lon: 65 };
|
||||
const MAX_LATITUDE = 58;
|
||||
const LAND_RINGS = extractRings(worldMap.features);
|
||||
|
||||
export function GlobeMap({ items }) {
|
||||
const canvasRef = useRef(null);
|
||||
const wrapRef = useRef(null);
|
||||
const dragRef = useRef(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [hovered, setHovered] = useState(null);
|
||||
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;
|
||||
if (!element) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const syncSize = () => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
setSize({ height: Math.max(1, Math.round(rect.height)), width: Math.max(1, Math.round(rect.width)) });
|
||||
};
|
||||
|
||||
syncSize();
|
||||
const observer = typeof ResizeObserver === "function" ? new ResizeObserver(syncSize) : null;
|
||||
observer?.observe(element);
|
||||
window.addEventListener("resize", syncSize);
|
||||
|
||||
return () => {
|
||||
observer?.disconnect();
|
||||
window.removeEventListener("resize", syncSize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !size.width || !size.height) {
|
||||
return;
|
||||
}
|
||||
drawGlobe(canvas, size, rotation, points);
|
||||
}, [points, rotation, size]);
|
||||
|
||||
const onPointerDown = (event) => {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
dragRef.current = {
|
||||
lat: rotation.lat,
|
||||
lon: rotation.lon,
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
};
|
||||
setHovered(null);
|
||||
setDragging(true);
|
||||
};
|
||||
|
||||
const onPointerMove = (event) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag) {
|
||||
setHovered(findHoveredPoint(event, wrapRef.current, size, rotation, points));
|
||||
return;
|
||||
}
|
||||
const dx = event.clientX - drag.x;
|
||||
const dy = event.clientY - drag.y;
|
||||
setRotation({
|
||||
lat: clamp(drag.lat + dy * 0.24, -MAX_LATITUDE, MAX_LATITUDE),
|
||||
lon: normalizeLongitude(drag.lon - dx * 0.34)
|
||||
});
|
||||
};
|
||||
|
||||
const endDrag = (event) => {
|
||||
if (dragRef.current) {
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
}
|
||||
dragRef.current = null;
|
||||
setDragging(false);
|
||||
setHovered(findHoveredPoint(event, wrapRef.current, size, rotation, points));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label="可旋转全球充值分布"
|
||||
className={["globe-map", dragging ? "is-dragging" : ""].filter(Boolean).join(" ")}
|
||||
onPointerCancel={endDrag}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerLeave={() => setHovered(null)}
|
||||
ref={wrapRef}
|
||||
role="img"
|
||||
>
|
||||
<canvas className="globe-canvas" ref={canvasRef} />
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
function drawGlobe(canvas, size, rotation, points) {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.round(size.width * dpr);
|
||||
canvas.height = Math.round(size.height * dpr);
|
||||
canvas.style.width = `${size.width}px`;
|
||||
canvas.style.height = `${size.height}px`;
|
||||
|
||||
const context = canvas.getContext("2d");
|
||||
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
context.clearRect(0, 0, size.width, size.height);
|
||||
|
||||
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));
|
||||
|
||||
drawAtmosphere(context, cx, cy, radius);
|
||||
|
||||
context.save();
|
||||
context.beginPath();
|
||||
context.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
context.clip();
|
||||
|
||||
drawGraticule(context, rotation, cx, cy, radius);
|
||||
drawLand(context, rotation, cx, cy, radius);
|
||||
drawRoutes(context, points, rotation, cx, cy, radius);
|
||||
drawPoints(context, points, rotation, cx, cy, radius);
|
||||
drawRankMarkers(context, points, rotation, cx, cy, radius);
|
||||
|
||||
context.restore();
|
||||
drawRim(context, cx, cy, radius);
|
||||
}
|
||||
|
||||
function drawAtmosphere(context, cx, cy, radius) {
|
||||
context.save();
|
||||
context.shadowBlur = 34;
|
||||
context.shadowColor = "rgba(35, 228, 255, 0.22)";
|
||||
context.fillStyle = "rgba(5, 31, 52, 0.92)";
|
||||
context.beginPath();
|
||||
context.arc(cx, cy, radius + 1, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.restore();
|
||||
|
||||
const ocean = context.createRadialGradient(cx - radius * 0.42, cy - radius * 0.48, radius * 0.08, cx, cy, radius);
|
||||
ocean.addColorStop(0, "#144766");
|
||||
ocean.addColorStop(0.46, "#082c49");
|
||||
ocean.addColorStop(0.76, "#061f36");
|
||||
ocean.addColorStop(1, "#04182d");
|
||||
context.fillStyle = ocean;
|
||||
context.beginPath();
|
||||
context.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
|
||||
function drawGraticule(context, rotation, cx, cy, radius) {
|
||||
context.save();
|
||||
context.lineWidth = 0.65;
|
||||
context.strokeStyle = "rgba(102, 184, 222, 0.16)";
|
||||
|
||||
for (let lat = -60; lat <= 60; lat += 30) {
|
||||
drawProjectedLine(
|
||||
context,
|
||||
Array.from({ length: 91 }, (_, index) => [-180 + index * 4, lat]),
|
||||
rotation,
|
||||
cx,
|
||||
cy,
|
||||
radius
|
||||
);
|
||||
}
|
||||
|
||||
for (let lon = -150; lon <= 180; lon += 30) {
|
||||
drawProjectedLine(
|
||||
context,
|
||||
Array.from({ length: 41 }, (_, index) => [lon, -80 + index * 4]),
|
||||
rotation,
|
||||
cx,
|
||||
cy,
|
||||
radius
|
||||
);
|
||||
}
|
||||
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function drawLand(context, rotation, cx, cy, radius) {
|
||||
context.save();
|
||||
context.fillStyle = "rgba(25, 63, 91, 0.9)";
|
||||
context.strokeStyle = "rgba(75, 148, 190, 0.58)";
|
||||
context.lineWidth = 0.62;
|
||||
|
||||
for (const ring of LAND_RINGS) {
|
||||
drawRing(context, ring, rotation, cx, cy, radius);
|
||||
}
|
||||
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function drawRoutes(context, points, rotation, cx, cy, radius) {
|
||||
if (points.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hub = points[0];
|
||||
const hubPoint = project(hub.point, rotation, cx, cy, radius);
|
||||
if (!hubPoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.save();
|
||||
context.lineWidth = 0.8;
|
||||
context.strokeStyle = "rgba(43, 230, 246, 0.22)";
|
||||
|
||||
for (const target of points.slice(1, 8)) {
|
||||
const targetPoint = project(target.point, rotation, cx, cy, radius);
|
||||
if (!targetPoint) {
|
||||
continue;
|
||||
}
|
||||
const midX = (hubPoint.x + targetPoint.x) / 2;
|
||||
const midY = (hubPoint.y + targetPoint.y) / 2;
|
||||
const lift = Math.min(36, Math.hypot(targetPoint.x - hubPoint.x, targetPoint.y - hubPoint.y) * 0.22);
|
||||
context.beginPath();
|
||||
context.moveTo(hubPoint.x, hubPoint.y);
|
||||
context.quadraticCurveTo(midX, midY - lift, targetPoint.x, targetPoint.y);
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
context.restore();
|
||||
}
|
||||
|
||||
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 = 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, 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 = 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 = colorForStrength(strength, 1);
|
||||
context.shadowBlur = 16;
|
||||
context.shadowColor = colorForStrength(strength, 0.82);
|
||||
context.beginPath();
|
||||
context.arc(point.x, point.y, core, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.shadowBlur = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function drawRankMarkers(context, points, rotation, cx, cy, radius) {
|
||||
const visible = points
|
||||
.map((item, index) => ({ index, item, point: project(item.point, rotation, cx, cy, radius) }))
|
||||
.filter((item) => item.point)
|
||||
.filter((item) => item.index < 5);
|
||||
|
||||
if (!visible.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.save();
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.font = "800 11px Inter, Arial, sans-serif";
|
||||
|
||||
for (const marker of visible) {
|
||||
const rank = marker.index + 1;
|
||||
const markerRadius = rank <= 2 ? 10.5 : 9.5;
|
||||
|
||||
context.shadowBlur = 16;
|
||||
context.shadowColor = "rgba(47, 232, 243, 0.55)";
|
||||
context.fillStyle = "rgba(4, 31, 50, 0.92)";
|
||||
context.beginPath();
|
||||
context.arc(marker.point.x, marker.point.y, markerRadius, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.shadowBlur = 0;
|
||||
|
||||
context.lineWidth = 1.5;
|
||||
context.strokeStyle = rank <= 2 ? "rgba(143, 255, 255, 0.92)" : "rgba(76, 224, 241, 0.72)";
|
||||
context.stroke();
|
||||
|
||||
context.fillStyle = rank <= 2 ? "#f0fcff" : "#bdf9ff";
|
||||
context.fillText(String(rank), marker.point.x, marker.point.y + 0.5);
|
||||
}
|
||||
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function drawRim(context, cx, cy, radius) {
|
||||
context.save();
|
||||
const rim = context.createRadialGradient(cx, cy, radius * 0.7, cx, cy, radius * 1.04);
|
||||
rim.addColorStop(0, "rgba(37, 228, 245, 0)");
|
||||
rim.addColorStop(0.78, "rgba(37, 228, 245, 0.02)");
|
||||
rim.addColorStop(1, "rgba(37, 228, 245, 0.34)");
|
||||
context.fillStyle = rim;
|
||||
context.beginPath();
|
||||
context.arc(cx, cy, radius * 1.04, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
|
||||
context.lineWidth = 1.1;
|
||||
context.strokeStyle = "rgba(86, 186, 226, 0.42)";
|
||||
context.beginPath();
|
||||
context.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function drawRing(context, ring, rotation, cx, cy, radius) {
|
||||
context.beginPath();
|
||||
let started = false;
|
||||
let hasPoint = false;
|
||||
|
||||
for (const coordinate of ring) {
|
||||
const point = project(coordinate, rotation, cx, cy, radius);
|
||||
if (!point) {
|
||||
started = false;
|
||||
continue;
|
||||
}
|
||||
if (!started) {
|
||||
context.moveTo(point.x, point.y);
|
||||
started = true;
|
||||
} else {
|
||||
context.lineTo(point.x, point.y);
|
||||
}
|
||||
hasPoint = true;
|
||||
}
|
||||
|
||||
if (hasPoint) {
|
||||
context.fill();
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function drawProjectedLine(context, coordinates, rotation, cx, cy, radius) {
|
||||
context.beginPath();
|
||||
let started = false;
|
||||
|
||||
for (const coordinate of coordinates) {
|
||||
const point = project(coordinate, rotation, cx, cy, radius);
|
||||
if (!point) {
|
||||
started = false;
|
||||
continue;
|
||||
}
|
||||
if (!started) {
|
||||
context.moveTo(point.x, point.y);
|
||||
started = true;
|
||||
} else {
|
||||
context.lineTo(point.x, point.y);
|
||||
}
|
||||
}
|
||||
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
function project(coordinate, rotation, cx, cy, radius) {
|
||||
const lon = coordinate[0] * DEGREE;
|
||||
const lat = coordinate[1] * DEGREE;
|
||||
const centerLon = rotation.lon * DEGREE;
|
||||
const centerLat = rotation.lat * DEGREE;
|
||||
const deltaLon = lon - centerLon;
|
||||
const cosc = Math.sin(centerLat) * Math.sin(lat) + Math.cos(centerLat) * Math.cos(lat) * Math.cos(deltaLon);
|
||||
|
||||
if (cosc <= 0.02) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: cx + radius * Math.cos(lat) * Math.sin(deltaLon),
|
||||
y: cy - radius * (Math.cos(centerLat) * Math.sin(lat) - Math.sin(centerLat) * Math.cos(lat) * Math.cos(deltaLon))
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
paidUsers: Number(item.paid_users) || 0,
|
||||
point: item.geo_point,
|
||||
value: Number(item.recharge_usd_minor) || 0
|
||||
}))
|
||||
.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;
|
||||
}
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
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));
|
||||
let nearest = null;
|
||||
|
||||
for (const item of points) {
|
||||
const projected = project(item.point, rotation, cx, cy, radius);
|
||||
if (!projected) {
|
||||
continue;
|
||||
}
|
||||
const distance = Math.hypot(projected.x - x, projected.y - y);
|
||||
if (distance <= 18 && (!nearest || distance < nearest.distance)) {
|
||||
nearest = { distance, item, x: projected.x, y: projected.y };
|
||||
}
|
||||
}
|
||||
|
||||
if (!nearest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
item: nearest.item,
|
||||
x: clamp(nearest.x + 14, 8, size.width - 136),
|
||||
y: clamp(nearest.y - 76, 8, size.height - 96)
|
||||
};
|
||||
}
|
||||
|
||||
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 = [];
|
||||
for (const feature of features) {
|
||||
const geometry = feature.geometry;
|
||||
if (!geometry) {
|
||||
continue;
|
||||
}
|
||||
if (geometry.type === "Polygon") {
|
||||
rings.push(...geometry.coordinates);
|
||||
}
|
||||
if (geometry.type === "MultiPolygon") {
|
||||
for (const polygon of geometry.coordinates) {
|
||||
rings.push(...polygon);
|
||||
}
|
||||
}
|
||||
}
|
||||
return rings;
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function normalizeLongitude(value) {
|
||||
return ((((value + 180) % 360) + 360) % 360) - 180;
|
||||
}
|
||||
20
databi/src/components/HorizontalBars.jsx
Normal file
20
databi/src/components/HorizontalBars.jsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { formatCoin } from "../utils/format.js";
|
||||
|
||||
export function HorizontalBars({ items }) {
|
||||
const max = Math.max(...items.map((item) => item.value), 1);
|
||||
return (
|
||||
<div className="bar-list">
|
||||
<div className="section-label">礼物消费排行</div>
|
||||
{items.map((item, index) => (
|
||||
<div className="bar-row" key={item.name}>
|
||||
<span>{index + 1}</span>
|
||||
<em>{item.name}</em>
|
||||
<div className="bar-track">
|
||||
<i style={{ width: `${Math.max(4, (item.value / max) * 100)}%` }} />
|
||||
</div>
|
||||
<b>{formatCoin(item.value)}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
databi/src/components/LuckyGiftPanel.jsx
Normal file
43
databi/src/components/LuckyGiftPanel.jsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { EChart } from "../charts/EChart.jsx";
|
||||
import { createDonutOption } from "../charts/options/createDonutOption.js";
|
||||
import { formatCoin, formatPercent, formatWholeMoney } from "../utils/format.js";
|
||||
import { FiveMetricRow } from "./MiniStat.jsx";
|
||||
import { Panel } from "./Panel.jsx";
|
||||
|
||||
export function LuckyGiftPanel({ luckyMetrics, payoutDistribution }) {
|
||||
return (
|
||||
<Panel title="幸运礼物返奖">
|
||||
<FiveMetricRow items={luckyMetrics} />
|
||||
<div className="donut-layout">
|
||||
<div className="donut-wrap">
|
||||
<EChart className="donut-chart" option={createDonutOption(payoutDistribution)} />
|
||||
<div className="donut-center">
|
||||
<strong>{formatWholeMoney(totalValue(payoutDistribution))}</strong>
|
||||
<span>返奖总额</span>
|
||||
</div>
|
||||
</div>
|
||||
<DistributionList items={payoutDistribution} />
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function totalValue(items) {
|
||||
return items.reduce((sum, item) => sum + item.value, 0);
|
||||
}
|
||||
|
||||
function DistributionList({ items }) {
|
||||
const total = items.reduce((sum, item) => sum + item.value, 0) || 1;
|
||||
return (
|
||||
<div className="distribution-list">
|
||||
{items.map((item) => (
|
||||
<div className="distribution-row" key={item.name}>
|
||||
<span />
|
||||
<em>{item.name}</em>
|
||||
<strong>{formatPercent(item.value / total)}</strong>
|
||||
<b>{formatCoin(item.value)}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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();
|
||||
});
|
||||
104
databi/src/components/MetricCard.jsx
Normal file
104
databi/src/components/MetricCard.jsx
Normal file
@ -0,0 +1,104 @@
|
||||
export function MetricCard({ item, loading = false, onClick }) {
|
||||
const Icon = item.icon;
|
||||
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();
|
||||
});
|
||||
|
||||
48
databi/src/components/MetricIcons.jsx
Normal file
48
databi/src/components/MetricIcons.jsx
Normal file
@ -0,0 +1,48 @@
|
||||
export function CoinIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 28 28" aria-hidden="true">
|
||||
<path d="M6 8c0-2.2 3.6-4 8-4s8 1.8 8 4-3.6 4-8 4-8-1.8-8-4Zm0 5c0 2.2 3.6 4 8 4s8-1.8 8-4M6 18c0 2.2 3.6 4 8 4s8-1.8 8-4" />
|
||||
<path d="M22 13v5M6 8v10" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserPlusIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 28 28" aria-hidden="true">
|
||||
<path d="M11 14a5 5 0 1 0 0-10 5 5 0 0 0 0 10ZM3 24c1-4.2 4-6.5 8-6.5s7 2.3 8 6.5M22 8v8M18 12h8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActiveUsersIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 28 28" aria-hidden="true">
|
||||
<path d="M9 14a4.5 4.5 0 1 0 0-9 4.5 4.5 0 0 0 0 9ZM19 14a3.8 3.8 0 1 0 0-7.6 3.8 3.8 0 0 0 0 7.6ZM2.8 24c.8-4 3.2-6 6.2-6s5.4 2 6.2 6M15.5 18.5c2.8.2 5 2 5.7 5.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CrownIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 28 28" aria-hidden="true">
|
||||
<path d="m4 9 5.5 4.5L14 6l4.5 7.5L24 9l-2.2 12H6.2L4 9ZM7 24h14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrendIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 28 28" aria-hidden="true">
|
||||
<path d="M4 23h20M7 19v-5M13 19V9M19 19V5M6 11l6-5 5 4 6-8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function StarUserIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 28 28" aria-hidden="true">
|
||||
<path d="M10 13a4.5 4.5 0 1 0 0-9 4.5 4.5 0 0 0 0 9ZM3 24c.8-4.4 3.5-7 7-7 1.4 0 2.7.4 3.8 1.2M21 15l1.5 3 3.3.5-2.4 2.3.6 3.2-3-1.6-3 1.6.6-3.2-2.4-2.3 3.3-.5L21 15Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
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);
|
||||
}
|
||||
53
databi/src/components/MiniStat.jsx
Normal file
53
databi/src/components/MiniStat.jsx
Normal file
@ -0,0 +1,53 @@
|
||||
export function MiniStat({ item }) {
|
||||
const caption = parseCaption(item.caption);
|
||||
const deltaTone = item.deltaTone || (caption.delta.startsWith("-") ? "down" : "up");
|
||||
return (
|
||||
<article className="mini-stat">
|
||||
<span>{item.label}</span>
|
||||
<strong>{item.value}</strong>
|
||||
<small className={`mini-stat-delta mini-stat-delta--${deltaTone}`}>
|
||||
<span>{caption.label}</span>
|
||||
{caption.delta ? <b>{`${deltaTone === "down" ? "↓" : "↑"} ${caption.delta.replace(/^[-+]/, "")}`}</b> : null}
|
||||
</small>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function parseCaption(caption = "") {
|
||||
const normalized = String(caption).trim().replace(/\s+/g, " ");
|
||||
const match = normalized.match(/^(.*)\s([+-][\d.]+(?:%|pp)?)$/);
|
||||
if (!match) {
|
||||
return { delta: "", label: normalized };
|
||||
}
|
||||
return { delta: match[2], label: match[1] };
|
||||
}
|
||||
|
||||
export function TwoMetricRow({ items }) {
|
||||
return (
|
||||
<div className="two-metric-row">
|
||||
{items.map((item) => (
|
||||
<MiniStat item={item} key={item.label} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FourMetricRow({ items }) {
|
||||
return (
|
||||
<div className="four-metric-row">
|
||||
{items.map((item) => (
|
||||
<MiniStat item={item} key={item.label} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FiveMetricRow({ items }) {
|
||||
return (
|
||||
<div className="five-metric-row">
|
||||
{items.map((item) => (
|
||||
<MiniStat item={item} key={item.label} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
databi/src/components/Panel.jsx
Normal file
11
databi/src/components/Panel.jsx
Normal file
@ -0,0 +1,11 @@
|
||||
export function Panel({ children, className = "", title }) {
|
||||
return (
|
||||
<section className={["databi-panel", className].filter(Boolean).join(" ")}>
|
||||
<div className="panel-head">
|
||||
<div className="panel-title">{title}</div>
|
||||
<span className="panel-info">i</span>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
16
databi/src/components/RankList.jsx
Normal file
16
databi/src/components/RankList.jsx
Normal file
@ -0,0 +1,16 @@
|
||||
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>
|
||||
{item.flag ? <i className="rank-flag">{item.flag}</i> : null}
|
||||
<strong>{item.country}</strong>
|
||||
<b>{valueFormatter(item.recharge_usd_minor)}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1104
databi/src/components/ReportOverview.jsx
Normal file
1104
databi/src/components/ReportOverview.jsx
Normal file
File diff suppressed because it is too large
Load Diff
175
databi/src/components/ReportOverview.test.jsx
Normal file
175
databi/src/components/ReportOverview.test.jsx
Normal file
@ -0,0 +1,175 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
import { fetchPlatformGrantRecords, fetchPlatformGrantUsers } from "../api.js";
|
||||
import { createDashboardModel } from "../data/createDashboardModel.js";
|
||||
import { ReportOverview } from "./ReportOverview.jsx";
|
||||
|
||||
vi.mock("../api.js", () => ({
|
||||
fetchPlatformGrantRecords: vi.fn(),
|
||||
fetchPlatformGrantUsers: vi.fn()
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchPlatformGrantRecords).mockReset();
|
||||
vi.mocked(fetchPlatformGrantUsers).mockReset();
|
||||
});
|
||||
|
||||
test("shows daily totals first and expands daily country details on demand", async () => {
|
||||
const user = userEvent.setup();
|
||||
const model = createDashboardModel({
|
||||
country_breakdown: [
|
||||
{ country: "巴西", country_id: 86, active_users: 30, recharge_usd_minor: 30000 },
|
||||
{ country: "沙特", country_id: 966, active_users: 12, recharge_usd_minor: 12000 }
|
||||
],
|
||||
daily_country_breakdown: [
|
||||
{
|
||||
active_users: 10,
|
||||
country: "巴西",
|
||||
country_id: 86,
|
||||
recharge_usd_minor: 10000,
|
||||
stat_day: "2026-06-05"
|
||||
},
|
||||
{
|
||||
active_users: 5,
|
||||
country: "沙特",
|
||||
country_id: 966,
|
||||
recharge_usd_minor: 5000,
|
||||
stat_day: "2026-06-05"
|
||||
},
|
||||
{
|
||||
active_users: 20,
|
||||
country: "巴西",
|
||||
country_id: 86,
|
||||
recharge_usd_minor: 20000,
|
||||
stat_day: "2026-06-06"
|
||||
}
|
||||
],
|
||||
daily_series: [
|
||||
{ active_users: 15, recharge_usd_minor: 15000, stat_day: "2026-06-05" },
|
||||
{ active_users: 20, recharge_usd_minor: 20000, stat_day: "2026-06-06" }
|
||||
],
|
||||
recharge_usd_minor: 35000
|
||||
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-06", start: "2026-06-05" } });
|
||||
|
||||
const { container } = render(<ReportOverview model={model} />);
|
||||
|
||||
expect(screen.queryByText("巴西")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("沙特")).not.toBeInTheDocument();
|
||||
|
||||
const stickyDateHeader = container.querySelector(".report-table--wide th.is-sticky-left");
|
||||
expect(stickyDateHeader).toHaveTextContent("日期");
|
||||
expect(screen.getByRole("columnheader", { name: "用户金币数" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "产出金币" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "消耗产出比" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("columnheader", { name: "金币数量(总和)" })).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".report-table--wide td.is-sticky-left .report-date-cell")).toBeTruthy();
|
||||
|
||||
const june5Row = screen.getByRole("button", { name: "展开 2026-06-05 国家明细" });
|
||||
expect(june5Row).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
await user.click(june5Row);
|
||||
|
||||
expect(june5Row).toHaveAttribute("aria-expanded", "true");
|
||||
const detailRows = within(screen.getByRole("table", { name: "2026-06-05 国家明细" })).getAllByRole("row");
|
||||
expect(detailRows).toHaveLength(3);
|
||||
expect(container.querySelector(".report-subtable .is-sticky-left")).toBeNull();
|
||||
expect(screen.getByText("巴西")).toBeInTheDocument();
|
||||
expect(screen.getByText("沙特")).toBeInTheDocument();
|
||||
|
||||
await user.click(june5Row);
|
||||
|
||||
expect(june5Row).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByText("巴西")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("drills platform grant coin from total to country users and user records", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(fetchPlatformGrantUsers).mockResolvedValue({
|
||||
items: [{
|
||||
display_user_id: "163001",
|
||||
last_granted_at_ms: Date.UTC(2026, 5, 5, 10, 20, 30),
|
||||
record_count: 2,
|
||||
total_coin: 55,
|
||||
user_id: "7001",
|
||||
username: "Ana"
|
||||
}],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1
|
||||
});
|
||||
const taskRecord = { amount: 33, event_id: "grant:task:7001", event_type: "WalletTaskRewardCredited", occurred_at_ms: Date.UTC(2026, 5, 5, 10, 20, 30), user_id: "7001" };
|
||||
const resourceRecord = { amount: 22, event_id: "grant:resource:7001", event_type: "WalletBalanceChanged", occurred_at_ms: Date.UTC(2026, 5, 5, 9, 20, 30), user_id: "7001" };
|
||||
vi.mocked(fetchPlatformGrantRecords).mockResolvedValue({
|
||||
items: [
|
||||
taskRecord,
|
||||
resourceRecord
|
||||
],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 2
|
||||
});
|
||||
const model = createDashboardModel({
|
||||
country_breakdown: [
|
||||
{ country: "巴西", country_id: 86, platform_grant_coin: 55, region_id: 10 },
|
||||
{ country: "沙特", country_id: 966, platform_grant_coin: 12, region_id: 20 }
|
||||
],
|
||||
daily_country_breakdown: [
|
||||
{ country: "巴西", country_id: 86, platform_grant_coin: 33, region_id: 10, stat_day: "2026-06-05" },
|
||||
{ country: "巴西", country_id: 86, platform_grant_coin: 22, region_id: 10, stat_day: "2026-06-06" },
|
||||
{ country: "沙特", country_id: 966, platform_grant_coin: 12, region_id: 20, stat_day: "2026-06-06" }
|
||||
],
|
||||
daily_series: [
|
||||
{ platform_grant_coin: 33, stat_day: "2026-06-05" },
|
||||
{ platform_grant_coin: 34, stat_day: "2026-06-06" }
|
||||
],
|
||||
platform_grant_coin: 67
|
||||
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-06", start: "2026-06-05" } });
|
||||
|
||||
render(
|
||||
<ReportOverview
|
||||
grantDrilldownQuery={{
|
||||
appCode: "lalu",
|
||||
countryId: 0,
|
||||
endMs: Date.UTC(2026, 5, 7),
|
||||
regionId: "all",
|
||||
startMs: Date.UTC(2026, 5, 5),
|
||||
statTz: "Asia/Shanghai"
|
||||
}}
|
||||
model={model}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "查看 全部 平台发放金币国家明细" }));
|
||||
expect(screen.getByRole("dialog", { name: "平台发放金币" })).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "查看 巴西 平台发放金币用户明细" }));
|
||||
await waitFor(() => expect(fetchPlatformGrantUsers).toHaveBeenCalledWith(expect.objectContaining({
|
||||
appCode: "lalu",
|
||||
countryId: 86,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
regionId: 10,
|
||||
statTz: "Asia/Shanghai"
|
||||
})));
|
||||
expect(await screen.findByRole("button", { name: "查看 163001 平台发放金币来源明细" })).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "查看 163001 平台发放金币来源明细" }));
|
||||
await waitFor(() => expect(fetchPlatformGrantRecords).toHaveBeenCalledWith(expect.objectContaining({
|
||||
countryId: 86,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
regionId: 10,
|
||||
userId: "7001"
|
||||
})));
|
||||
expect(await screen.findByRole("cell", { name: "任务奖励" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("cell", { name: "资源发放" })).toBeInTheDocument();
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("来源筛选"), "WalletTaskRewardCredited");
|
||||
await waitFor(() => expect(fetchPlatformGrantRecords).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
source: "WalletTaskRewardCredited",
|
||||
userId: "7001"
|
||||
})));
|
||||
});
|
||||
44
databi/src/components/ReportSidebar.jsx
Normal file
44
databi/src/components/ReportSidebar.jsx
Normal file
@ -0,0 +1,44 @@
|
||||
import AccountBalanceWalletOutlined from "@mui/icons-material/AccountBalanceWalletOutlined";
|
||||
import AssessmentOutlined from "@mui/icons-material/AssessmentOutlined";
|
||||
import CampaignOutlined from "@mui/icons-material/CampaignOutlined";
|
||||
import StorageOutlined from "@mui/icons-material/StorageOutlined";
|
||||
|
||||
const reportSections = [
|
||||
{ icon: AssessmentOutlined, key: "data", label: "数据报表" },
|
||||
{ icon: AccountBalanceWalletOutlined, key: "finance", label: "财务报表" },
|
||||
{ icon: CampaignOutlined, key: "operations", label: "运营报表" }
|
||||
];
|
||||
|
||||
export function ReportSidebar({ activeSection = "data", onSectionChange }) {
|
||||
return (
|
||||
<aside className="databi-report-sidebar" aria-label="报表侧边栏">
|
||||
<div className="report-sidebar-brand" aria-hidden="true">
|
||||
<StorageOutlined fontSize="small" />
|
||||
</div>
|
||||
<nav className="report-sidebar-nav" role="tablist" aria-label="报表类型">
|
||||
{reportSections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
const isActive = activeSection === section.key;
|
||||
return (
|
||||
<button
|
||||
aria-selected={isActive}
|
||||
className={isActive ? "report-sidebar-item is-active" : "report-sidebar-item"}
|
||||
key={section.key}
|
||||
onClick={() => onSectionChange?.(section.key)}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
<Icon fontSize="small" />
|
||||
<span>{section.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyReportSection({ section }) {
|
||||
const label = reportSections.find((item) => item.key === section)?.label || "报表";
|
||||
return <section className="databi-empty-report-section" aria-label={label} />;
|
||||
}
|
||||
20
databi/src/components/RevenueTrendPanel.jsx
Normal file
20
databi/src/components/RevenueTrendPanel.jsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { EChart } from "../charts/EChart.jsx";
|
||||
import { createRevenueOption } from "../charts/options/createRevenueOption.js";
|
||||
import { Panel } from "./Panel.jsx";
|
||||
|
||||
export function RevenueTrendPanel({ loading = false, revenueSeries }) {
|
||||
return (
|
||||
<Panel title="充值趋势">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
12
databi/src/components/RoomGiftPanel.jsx
Normal file
12
databi/src/components/RoomGiftPanel.jsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { HorizontalBars } from "./HorizontalBars.jsx";
|
||||
import { TwoMetricRow } from "./MiniStat.jsx";
|
||||
import { Panel } from "./Panel.jsx";
|
||||
|
||||
export function RoomGiftPanel({ giftRanking, roomMetrics }) {
|
||||
return (
|
||||
<Panel title="房间与礼物">
|
||||
<TwoMetricRow items={roomMetrics} />
|
||||
<HorizontalBars items={giftRanking} />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
438
databi/src/components/SelfGameScreen.jsx
Normal file
438
databi/src/components/SelfGameScreen.jsx
Normal file
@ -0,0 +1,438 @@
|
||||
import { MetricCard } from "./MetricCard.jsx";
|
||||
import { Panel } from "./Panel.jsx";
|
||||
import { formatCoin, formatNumber, formatPercent } from "../utils/format.js";
|
||||
import { numberValue } from "../utils/number.js";
|
||||
|
||||
const gameOptions = [
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "Dice", value: "dice" },
|
||||
{ label: "Rock", value: "rock" }
|
||||
];
|
||||
|
||||
const eventLabels = {
|
||||
page_open: "页面打开",
|
||||
config_load_success: "配置成功",
|
||||
config_load_failed: "配置失败",
|
||||
gesture_select: "选择手势",
|
||||
match_api: "匹配接口",
|
||||
match_cancel: "取消匹配",
|
||||
stake_selected: "选择档位",
|
||||
stake_select: "选择档位",
|
||||
match_click: "点击匹配",
|
||||
match_success: "匹配成功",
|
||||
animation_start: "进入开奖",
|
||||
preload_complete: "预加载完成",
|
||||
reveal_enter: "进入开奖",
|
||||
roll_api: "开奖接口",
|
||||
settlement_show: "结算展示",
|
||||
play_again: "再来一局",
|
||||
play_again_click: "再来一局",
|
||||
exit_click: "退出"
|
||||
};
|
||||
|
||||
export function SelfGameScreen({ gameId = "all", loading = false, onGameChange, overview }) {
|
||||
const model = buildSelfGameModel(overview);
|
||||
const showSkeleton = loading && !overview;
|
||||
|
||||
return (
|
||||
<section className="self-game-screen" aria-label="自研游戏统计">
|
||||
<div className="self-game-toolbar">
|
||||
<div className="self-game-title">
|
||||
<strong>自研游戏</strong>
|
||||
<span>Dice / Rock H5 聚合统计</span>
|
||||
</div>
|
||||
<div className="game-switch" role="tablist" aria-label="游戏筛选">
|
||||
{gameOptions.map((item) => (
|
||||
<button className={gameId === item.value ? "is-active" : ""} key={item.value} onClick={() => onGameChange?.(item.value)} type="button">
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="self-game-kpi-grid" aria-label="自研游戏核心指标">
|
||||
{model.kpis.map((item) => (
|
||||
<MetricCard key={item.label} item={item} loading={showSkeleton} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="self-game-panel-grid self-game-panel-grid--top">
|
||||
<FunnelTable funnel={model.funnel} loading={showSkeleton} />
|
||||
<StakeTable items={model.stakes} loading={showSkeleton} />
|
||||
<PoolTable items={model.pools} loading={showSkeleton} />
|
||||
</section>
|
||||
|
||||
<section className="self-game-panel-grid">
|
||||
<ParticipantTable items={model.participants} loading={showSkeleton} />
|
||||
<WinLossTable summary={model.winLoss} loading={showSkeleton} />
|
||||
<RiskTable items={model.risks} loading={showSkeleton} />
|
||||
</section>
|
||||
|
||||
<section className="self-game-panel-grid self-game-panel-grid--single">
|
||||
<MetricDefinitionList items={model.definitions} loading={showSkeleton} />
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function buildSelfGameModel(overview) {
|
||||
const events = eventIndex(overview?.events);
|
||||
const totals = overview?.match_totals || {};
|
||||
const retention = overview?.retention || {};
|
||||
const totalStake = numberValue(totals.user_stake_coin) + numberValue(totals.robot_stake_coin);
|
||||
const participants = Array.isArray(overview?.participant_distribution) ? overview.participant_distribution : [];
|
||||
const pools = Array.isArray(overview?.pool_stats) ? overview.pool_stats : [];
|
||||
const userResult = realUserResultCounts(participants, totals);
|
||||
const userNetWin = numberValue(totals.payout_coin) + numberValue(totals.refund_coin) - numberValue(totals.user_stake_coin);
|
||||
const poolBalance = latestPoolBalance(pools);
|
||||
const settlementEvent = events.get("settlement_show");
|
||||
const matchClickEvent = events.get("match_click");
|
||||
const matchSuccessEvent = events.get("match_success");
|
||||
const pageOpenEvent = events.get("page_open");
|
||||
const winLoss = {
|
||||
countNote: userResult.countNote,
|
||||
drawCount: userResult.draw,
|
||||
loserCount: userResult.lose,
|
||||
payoutCoin: numberValue(totals.payout_coin),
|
||||
refundCoin: numberValue(totals.refund_coin),
|
||||
userNetWin,
|
||||
userStakeCoin: numberValue(totals.user_stake_coin),
|
||||
winnerCount: userResult.win
|
||||
};
|
||||
|
||||
return {
|
||||
kpis: [
|
||||
metric("游戏 PV / UV", `${formatNumber(pageOpenEvent.event_count)} / ${formatNumber(pageOpenEvent.user_count)}`, "进入 dice、rock H5 的次数和人数"),
|
||||
metric("开局人数", `点击 ${formatNumber(matchClickEvent.user_count)} / 成功 ${formatNumber(matchSuccessEvent.user_count)}`, `${formatNumber(matchClickEvent.event_count)} 次点击`),
|
||||
metric("创建局", formatNumber(totals.created_matches), "服务端创建的对局数"),
|
||||
metric("匹配成功局", formatNumber(totals.matched_matches), `${formatPercent(overview?.conversion?.match_click_to_success_rate)} 点击后成功`),
|
||||
metric("结算完成 UV", formatNumber(settlementEvent.user_count), `${formatPercent(overview?.conversion?.page_to_settlement_rate)} 页面到结算`),
|
||||
metric("已结算局", formatNumber(totals.settled_matches), "已经完成派奖或退款的局"),
|
||||
metric("取消 / 失败局", `${formatNumber(totals.canceled_matches)} / ${formatNumber(totals.failed_matches)}`, `${formatPercent(totals.cancel_rate)} / ${formatPercent(totals.fail_rate)}`),
|
||||
metric("参与人次", formatNumber(numberValue(totals.human_participants) + numberValue(totals.robot_participants)), `真人 ${formatNumber(totals.human_participants)} / 机器人 ${formatNumber(totals.robot_participants)}`),
|
||||
metric("总下注金币", formatCoin(totalStake), `真人 ${formatCoin(totals.user_stake_coin)} / 机器人 ${formatCoin(totals.robot_stake_coin)}`),
|
||||
metric("派奖金额", formatCoin(totals.payout_coin), `退款 ${formatCoin(totals.refund_coin)}`),
|
||||
metric("平台抽水", formatCoin(totals.platform_profit_coin), formatPercent(totals.platform_profit_rate)),
|
||||
metric("奖池变化", formatCoin(totals.pool_delta_coin), `余额 ${formatCoin(poolBalance)} / 强制 ${formatNumber(totals.forced_player_lose_matches)} 次`),
|
||||
metric("用户输赢", `${formatNumber(winLoss.winnerCount)} / ${formatNumber(winLoss.loserCount)} / ${formatNumber(winLoss.drawCount)}`, `真实用户净输赢 ${formatCoin(winLoss.userNetWin)}`),
|
||||
metric("次日 / 7日留存", `${formatPercent(retention.day1_rate)} / ${formatPercent(retention.day7_rate)}`, `样本 ${formatNumber(retention.cohort_users)} 人`)
|
||||
],
|
||||
definitions: overview?.metric_definitions || [],
|
||||
funnel: overview?.funnel || [],
|
||||
participants,
|
||||
pools,
|
||||
risks: overview?.user_risk || [],
|
||||
stakes: overview?.stake_breakdown || [],
|
||||
winLoss
|
||||
};
|
||||
}
|
||||
|
||||
function eventIndex(items) {
|
||||
const index = new Map();
|
||||
(Array.isArray(items) ? items : []).forEach((item) => {
|
||||
const name = item.event_name || "";
|
||||
const current = index.get(name) || emptyEvent();
|
||||
index.set(name, {
|
||||
event_count: numberValue(current.event_count) + numberValue(item.event_count),
|
||||
user_count: numberValue(current.user_count) + numberValue(item.user_count)
|
||||
});
|
||||
});
|
||||
return {
|
||||
get(name) {
|
||||
return index.get(name) || emptyEvent();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function emptyEvent() {
|
||||
return { event_count: 0, user_count: 0 };
|
||||
}
|
||||
|
||||
function metric(label, value, caption) {
|
||||
return { caption, label, value };
|
||||
}
|
||||
|
||||
function FunnelTable({ funnel, loading }) {
|
||||
return (
|
||||
<Panel className="panel--self-game" title="转化漏斗">
|
||||
<DataTable
|
||||
emptyText="暂无漏斗数据"
|
||||
loading={loading}
|
||||
rows={funnel}
|
||||
columns={[
|
||||
{ key: "event_name", label: "节点", render: (row) => eventLabels[row.event_name] || row.event_name || "--" },
|
||||
{ key: "user_count", label: "UV", render: (row) => formatNumber(row.user_count) },
|
||||
{ key: "event_count", label: "次数", render: (row) => formatNumber(row.event_count) }
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function StakeTable({ items, loading }) {
|
||||
return (
|
||||
<Panel className="panel--self-game" title="下注档位">
|
||||
<DataTable
|
||||
emptyText="暂无档位数据"
|
||||
loading={loading}
|
||||
rows={items}
|
||||
wide
|
||||
columns={[
|
||||
{ key: "stake_coin", label: "档位", render: (row) => formatCoin(row.stake_coin) },
|
||||
{ key: "created_matches", label: "创建局", render: (row) => formatNumber(row.created_matches) },
|
||||
{ key: "matched_matches", label: "匹配局", render: (row) => formatNumber(row.matched_matches) },
|
||||
{ key: "settled_matches", label: "结算局", render: (row) => formatNumber(row.settled_matches) },
|
||||
{ key: "user_stake_coin", label: "真人下注", render: (row) => formatCoin(row.user_stake_coin) },
|
||||
{ key: "robot_stake_coin", label: "机器人下注", render: (row) => formatCoin(row.robot_stake_coin) },
|
||||
{ key: "stake", label: "总下注", render: (row) => formatCoin(numberValue(row.user_stake_coin) + numberValue(row.robot_stake_coin)) },
|
||||
{ key: "payout_coin", label: "派奖", render: (row) => formatCoin(row.payout_coin) },
|
||||
{ key: "profit", label: "盈利", render: (row) => formatCoin(row.platform_profit_coin) }
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function PoolTable({ items, loading }) {
|
||||
return (
|
||||
<Panel className="panel--self-game" title="奖池流水">
|
||||
<DataTable
|
||||
emptyText="暂无奖池流水"
|
||||
loading={loading}
|
||||
rows={items}
|
||||
columns={[
|
||||
{ key: "direction", label: "方向", render: (row) => directionLabel(row.direction) },
|
||||
{ key: "reason", label: "原因", render: (row) => row.reason || "--" },
|
||||
{ key: "flow", label: "入 / 出", render: (row) => `${formatCoin(row.in_coin)} / ${formatCoin(row.out_coin)}` },
|
||||
{ key: "balance_after_coin", label: "余额", render: (row) => formatCoin(row.balance_after_coin) }
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function ParticipantTable({ items, loading }) {
|
||||
return (
|
||||
<Panel className="panel--self-game" title="玩法分布">
|
||||
<DataTable
|
||||
emptyText="暂无玩法数据"
|
||||
loading={loading}
|
||||
rows={items}
|
||||
wide
|
||||
columns={[
|
||||
{ key: "stake_coin", label: "档位", render: (row) => formatCoin(row.stake_coin) },
|
||||
{ key: "type", label: "对象", render: (row) => participantLabel(row.participant_type) },
|
||||
{ key: "play", label: "手势 / 点数", render: (row) => playLabel(row) },
|
||||
{ key: "result", label: "结果", render: (row) => resultLabel(row.result) },
|
||||
{ key: "count", label: "人次", render: (row) => formatNumber(row.participant_count) },
|
||||
{ key: "net_win_coin", label: "净输赢", render: (row) => formatCoin(row.net_win_coin) },
|
||||
{ key: "win_rate", label: "胜率", render: (row) => formatPercent(row.win_rate) }
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function WinLossTable({ loading, summary }) {
|
||||
const rows = [
|
||||
{ metric: "真实用户赢家数", value: summary.winnerCount, note: summary.countNote },
|
||||
{ metric: "真实用户输家数", value: summary.loserCount, note: summary.countNote },
|
||||
{ metric: "真实用户平局数", value: summary.drawCount, note: summary.countNote },
|
||||
{ metric: "真实用户下注", value: summary.userStakeCoin, note: "match_totals.user_stake_coin", type: "coin" },
|
||||
{ metric: "真实用户派奖", value: summary.payoutCoin, note: "match_totals.payout_coin", type: "coin" },
|
||||
{ metric: "真实用户退款", value: summary.refundCoin, note: "match_totals.refund_coin", type: "coin" },
|
||||
{ metric: "真实用户净输赢", value: summary.userNetWin, note: "派奖 + 退款 - 真实用户下注", type: "coin" }
|
||||
];
|
||||
return (
|
||||
<Panel className="panel--self-game" title="用户输赢">
|
||||
<DataTable
|
||||
emptyText="暂无输赢数据"
|
||||
loading={loading}
|
||||
rows={rows}
|
||||
columns={[
|
||||
{ key: "metric", label: "指标", render: (row) => row.metric },
|
||||
{ key: "value", label: "数值", render: (row) => (row.type === "coin" ? formatCoin(row.value) : formatNumber(row.value)) },
|
||||
{ key: "note", label: "口径", render: (row) => row.note }
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function RiskTable({ items, loading }) {
|
||||
return (
|
||||
<Panel className="panel--self-game" title="高风险用户">
|
||||
<DataTable
|
||||
emptyText="暂无风险用户"
|
||||
loading={loading}
|
||||
rows={items}
|
||||
columns={[
|
||||
{ key: "user_id", label: "用户", render: (row) => <RiskUserCell row={row} /> },
|
||||
{ key: "match_count", label: "局数", render: (row) => formatNumber(row.match_count) },
|
||||
{ key: "win_rate", label: "胜率", render: (row) => formatPercent(row.win_rate) },
|
||||
{ key: "net_win_coin", label: "净赢", render: (row) => formatCoin(row.net_win_coin) },
|
||||
{ key: "cancel_rate", label: "取消率", render: (row) => formatPercent(row.cancel_rate) }
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function RiskUserCell({ row }) {
|
||||
const nickname = row.nickname || row.username || "";
|
||||
const shortID = row.short_id || row.display_user_id || "";
|
||||
const userID = row.user_id ? String(row.user_id) : "";
|
||||
const avatar = row.avatar || "";
|
||||
const primary = nickname || shortID || userID || "--";
|
||||
const secondary = shortID ? `ID ${shortID}` : userID ? `UID ${userID}` : "";
|
||||
return (
|
||||
<div className="risk-user-cell">
|
||||
{avatar ? <img alt="" className="risk-user-avatar" src={avatar} /> : <span className="risk-user-avatar risk-user-avatar--empty">{primary.slice(0, 1).toUpperCase()}</span>}
|
||||
<span className="risk-user-copy">
|
||||
<strong>{primary}</strong>
|
||||
{secondary ? <small>{secondary}</small> : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricDefinitionList({ items, loading }) {
|
||||
return (
|
||||
<Panel className="panel--self-game" title="口径">
|
||||
{loading ? <div className="panel-empty">加载中...</div> : null}
|
||||
{!loading && !items.length ? <div className="panel-empty">暂无口径说明</div> : null}
|
||||
{!loading && items.length ? (
|
||||
<div className="definition-list">
|
||||
{items.map((item) => (
|
||||
<div className="definition-row" key={item.metric}>
|
||||
<strong>{item.metric}</strong>
|
||||
<span>{item.definition}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function DataTable({ columns, emptyText, loading, rows, wide = false }) {
|
||||
if (loading) {
|
||||
return <div className="panel-empty">加载中...</div>;
|
||||
}
|
||||
if (!rows.length) {
|
||||
return <div className="panel-empty">{emptyText}</div>;
|
||||
}
|
||||
return (
|
||||
<div className="self-game-table-wrap">
|
||||
<table className={["self-game-table", wide ? "self-game-table--wide" : ""].filter(Boolean).join(" ")}>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th key={column.key}>{column.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, rowIndex) => (
|
||||
<tr key={rowKey(row, rowIndex)}>
|
||||
{columns.map((column) => (
|
||||
<td key={column.key}>{column.render(row)}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function rowKey(row, rowIndex) {
|
||||
return [row.game_id, row.user_id, row.metric, row.stake_coin, row.event_name, row.reason, row.participant_type, row.result, row.rps_gesture, row.dice_point, rowIndex]
|
||||
.filter((item) => item !== undefined && item !== null && item !== "")
|
||||
.join(":");
|
||||
}
|
||||
|
||||
function realUserResultCounts(items, totals) {
|
||||
const result = { countNote: "按 participant_distribution 中真实用户结果统计", draw: 0, lose: 0, win: 0 };
|
||||
let userRows = 0;
|
||||
items.forEach((item) => {
|
||||
if (!isRealUserParticipant(item.participant_type)) {
|
||||
return;
|
||||
}
|
||||
userRows += 1;
|
||||
const count = numberValue(item.participant_count);
|
||||
if (item.result === "win") {
|
||||
result.win += count;
|
||||
} else if (item.result === "lose") {
|
||||
result.lose += count;
|
||||
} else if (item.result === "draw") {
|
||||
result.draw += count;
|
||||
}
|
||||
});
|
||||
if (userRows > 0) {
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
countNote: "缺少真实用户分布时回退 match_totals 参与人结果",
|
||||
draw: numberValue(totals.draw_count),
|
||||
lose: numberValue(totals.loser_count),
|
||||
win: numberValue(totals.winner_count)
|
||||
};
|
||||
}
|
||||
|
||||
function isRealUserParticipant(value) {
|
||||
return value === "user" || value === "human";
|
||||
}
|
||||
|
||||
function latestPoolBalance(items) {
|
||||
if (!items.length) {
|
||||
return 0;
|
||||
}
|
||||
return items.reduce((balance, item) => {
|
||||
const next = numberValue(item.balance_after_coin);
|
||||
return next !== 0 ? next : balance;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function directionLabel(value) {
|
||||
if (value === "in") {
|
||||
return "入池";
|
||||
}
|
||||
if (value === "out") {
|
||||
return "出池";
|
||||
}
|
||||
return value || "--";
|
||||
}
|
||||
|
||||
function participantLabel(value) {
|
||||
if (value === "robot") {
|
||||
return "机器人";
|
||||
}
|
||||
if (isRealUserParticipant(value)) {
|
||||
return "真人";
|
||||
}
|
||||
return value || "--";
|
||||
}
|
||||
|
||||
function resultLabel(value) {
|
||||
if (value === "win") {
|
||||
return "胜";
|
||||
}
|
||||
if (value === "lose") {
|
||||
return "负";
|
||||
}
|
||||
if (value === "draw") {
|
||||
return "平";
|
||||
}
|
||||
return value || "--";
|
||||
}
|
||||
|
||||
function playLabel(row) {
|
||||
if (row.rps_gesture) {
|
||||
return row.rps_gesture;
|
||||
}
|
||||
if (numberValue(row.dice_point) > 0) {
|
||||
return `${row.dice_point} 点`;
|
||||
}
|
||||
return "--";
|
||||
}
|
||||
46
databi/src/data/countryMeta.js
Normal file
46
databi/src/data/countryMeta.js
Normal file
@ -0,0 +1,46 @@
|
||||
const countryMeta = [
|
||||
{ code: "BR", label: "巴西", names: ["Brazil", "巴西"], point: [-51.93, -14.24] },
|
||||
{ code: "EG", label: "埃及", names: ["Egypt", "埃及"], point: [30.8, 26.82] },
|
||||
{ code: "ID", label: "印度尼西亚", names: ["Indonesia", "印度尼西亚"], point: [113.92, -0.79] },
|
||||
{ code: "IN", label: "印度", names: ["India", "印度"], point: [78.96, 20.59] },
|
||||
{ code: "TH", label: "泰国", names: ["Thailand", "泰国"], point: [100.99, 15.87] },
|
||||
{ code: "TR", label: "土耳其", names: ["Turkey", "土耳其"], point: [35.24, 38.96] },
|
||||
{ code: "US", label: "美国", names: ["United States", "USA", "United States of America", "美国"], point: [-95.71, 37.09] },
|
||||
{ code: "VN", label: "越南", names: ["Vietnam", "Viet Nam", "越南"], point: [108.28, 14.06] }
|
||||
];
|
||||
|
||||
const metaByName = new Map();
|
||||
const metaByCode = new Map();
|
||||
|
||||
for (const item of countryMeta) {
|
||||
metaByCode.set(item.code, item);
|
||||
for (const name of item.names) {
|
||||
metaByName.set(normalizeKey(name), item);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCountryMeta({ code, name }) {
|
||||
const fromCode = code ? metaByCode.get(String(code).trim().toUpperCase()) : null;
|
||||
if (fromCode) {
|
||||
return fromCode;
|
||||
}
|
||||
return metaByName.get(normalizeKey(name)) || null;
|
||||
}
|
||||
|
||||
export function countryFlag(code) {
|
||||
const normalized = String(code || "").trim().toUpperCase();
|
||||
if (!/^[A-Z]{2}$/.test(normalized)) {
|
||||
return "";
|
||||
}
|
||||
return Array.from(normalized)
|
||||
.map((char) => String.fromCodePoint(127397 + char.charCodeAt(0)))
|
||||
.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();
|
||||
}
|
||||
820
databi/src/data/createDashboardModel.js
Normal file
820
databi/src/data/createDashboardModel.js
Normal file
@ -0,0 +1,820 @@
|
||||
import { CoinIcon, ActiveUsersIcon, CrownIcon, StarUserIcon, TrendIcon, UserPlusIcon } from "../components/MetricIcons.jsx";
|
||||
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, sameRange, todayRange } from "../utils/time.js";
|
||||
|
||||
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", "registrations", "registered_users", "registeredUsers");
|
||||
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 realRoomRobotNormalGiftCoin = readNumber(source, "real_room_robot_normal_gift_coin", "realRoomRobotNormalGiftCoin");
|
||||
const realRoomRobotLuckyGiftCoin = readNumber(source, "real_room_robot_lucky_gift_coin", "realRoomRobotLuckyGiftCoin");
|
||||
const realRoomRobotSuperGiftCoin = readNumber(source, "real_room_robot_super_gift_coin", "realRoomRobotSuperGiftCoin");
|
||||
const realRoomRobotTotalGiftCoin =
|
||||
readNumber(source, "real_room_robot_total_gift_coin", "realRoomRobotTotalGiftCoin") || realRoomRobotNormalGiftCoin + realRoomRobotLuckyGiftCoin + realRoomRobotSuperGiftCoin;
|
||||
const realRoomRobotGiftRoomCount = readNumber(source, "real_room_robot_gift_room_count", "realRoomRobotGiftRoomCount");
|
||||
const realRoomRobotAvgRoomGiftCoin =
|
||||
readNumber(source, "real_room_robot_avg_room_gift_coin", "realRoomRobotAvgRoomGiftCoin") || Math.round(ratio(realRoomRobotTotalGiftCoin, realRoomRobotGiftRoomCount));
|
||||
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 dailyCountryBreakdown = normalizeDailyCountryBreakdown(source);
|
||||
const dailySeries = normalizeDailySeries(source);
|
||||
const revenueSeries = dailySeries.length ? dailySeries : normalizeRevenueSeries(source);
|
||||
const payoutDistribution = normalizeDistribution(source);
|
||||
const gameRanking = normalizeGameRanking(source);
|
||||
const reportCountryRows = buildReportCountryRows({
|
||||
countryBreakdown,
|
||||
dailyCountryBreakdown,
|
||||
dailySeries,
|
||||
range,
|
||||
source,
|
||||
});
|
||||
|
||||
return {
|
||||
appCode,
|
||||
businessKpis: [
|
||||
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,
|
||||
dailyCountryBreakdown,
|
||||
funnel: [
|
||||
{ name: "活跃用户", rate: 1, value: activeUsers },
|
||||
{ name: "付费用户", rate: ratio(paidUsers, activeUsers), value: paidUsers },
|
||||
{ name: "充值用户", rate: ratio(rechargeUsers, activeUsers), value: rechargeUsers },
|
||||
],
|
||||
gameMetrics: [
|
||||
{
|
||||
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: 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: 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,
|
||||
reportCountryRows,
|
||||
reportMetricSources: Array.isArray(source.report_metric_sources) ? source.report_metric_sources : [],
|
||||
revenueSeries,
|
||||
robotGiftKpis: [
|
||||
coinMetric("真人房机器人普通礼物", realRoomRobotNormalGiftCoin, "", "当前筛选", {
|
||||
trend: metricTrend(dailySeries, "real_room_robot_normal_gift_coin", "真人房机器人普通礼物", "coin"),
|
||||
}),
|
||||
coinMetric("真人房机器人幸运礼物", realRoomRobotLuckyGiftCoin, "", "当前筛选", {
|
||||
trend: metricTrend(dailySeries, "real_room_robot_lucky_gift_coin", "真人房机器人幸运礼物", "coin"),
|
||||
}),
|
||||
coinMetric("真人房机器人超级幸运礼物", realRoomRobotSuperGiftCoin, "", "当前筛选", {
|
||||
trend: metricTrend(dailySeries, "real_room_robot_super_gift_coin", "真人房机器人超级幸运礼物", "coin"),
|
||||
}),
|
||||
coinMetric("真人房机器人房均礼物", realRoomRobotAvgRoomGiftCoin, "", `${formatNumber(realRoomRobotGiftRoomCount)} 个房间`, {
|
||||
trend: metricTrend(dailySeries, "real_room_robot_avg_room_gift_coin", "真人房机器人房均礼物", "coin"),
|
||||
}),
|
||||
],
|
||||
roomMetrics: [
|
||||
{
|
||||
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: 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, activeUsers)),
|
||||
},
|
||||
{
|
||||
caption: captionWithDelta(comparisonCaption, readDelta(source, "arppu_delta_rate", "arppuDeltaRate")),
|
||||
label: "ARPPU",
|
||||
value: formatMoney(arppu),
|
||||
},
|
||||
],
|
||||
topCountries: countryBreakdown.slice(0, 4),
|
||||
updatedAt: formatDateTime(source.updated_at_ms || source.updatedAtMs || Date.now(), timeZone),
|
||||
};
|
||||
}
|
||||
|
||||
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 readOptionalNumber(source, ...keys) {
|
||||
for (const key of keys) {
|
||||
if (source?.[key] !== undefined && source[key] !== null) {
|
||||
return numberValue(source[key]);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasAnyKey(source, ...keys) {
|
||||
return keys.some((key) => source?.[key] !== undefined);
|
||||
}
|
||||
|
||||
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: coinSeller,
|
||||
google,
|
||||
label: "当前",
|
||||
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 ||
|
||||
(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),
|
||||
}))
|
||||
.sort((left, right) => right.recharge_usd_minor - left.recharge_usd_minor);
|
||||
}
|
||||
|
||||
function normalizeDailyCountryBreakdown(source) {
|
||||
const rows = firstArray(source?.daily_country_breakdown, source?.dailyCountryBreakdown);
|
||||
if (!rows.length) {
|
||||
return [];
|
||||
}
|
||||
const totalRecharge = rows.reduce((sum, item) => sum + effectiveRechargeUSDMinor(item), 0) || 1;
|
||||
return rows.map((item, index) => ({
|
||||
...normalizeCountryRow(item, index, totalRecharge),
|
||||
date_label: item.label || item.stat_day || item.statDay || item.day || "",
|
||||
stat_day: item.stat_day || item.statDay || item.day || "",
|
||||
}));
|
||||
}
|
||||
|
||||
function buildReportCountryRows({ countryBreakdown, dailyCountryBreakdown, dailySeries, range, source }) {
|
||||
const totalRow = createReportRow(normalizeCountryRow({ ...source, country: "总计" }, 0, effectiveRechargeUSDMinor(source) || 1), {
|
||||
country: "总计",
|
||||
date_label: "全部",
|
||||
row_type: "total",
|
||||
});
|
||||
if (!isMultiDayRange(range)) {
|
||||
return [totalRow, ...countryBreakdown.map((row) => createReportRow(row, { date_label: range?.start || row.stat_day || "", row_type: "country" }))];
|
||||
}
|
||||
|
||||
const dailyRowsByDay = groupRowsByDay(dailyCountryBreakdown);
|
||||
const dailySeriesByDay = new Map(dailySeries.map((item) => [item.stat_day || item.label, item]));
|
||||
const dayKeys = Array.from(new Set([...dailySeries.map((item) => item.stat_day || item.label).filter(Boolean), ...dailyCountryBreakdown.map((item) => item.stat_day).filter(Boolean)])).sort();
|
||||
|
||||
if (!dayKeys.length) {
|
||||
return [totalRow, ...countryBreakdown.map((row) => createReportRow(row, { date_label: "汇总", row_type: "country" }))];
|
||||
}
|
||||
|
||||
const rows = [totalRow];
|
||||
for (const day of dayKeys) {
|
||||
const dayCountries = dailyRowsByDay.get(day) || [];
|
||||
const dayTotalSource = { ...summarizeReportRows(dayCountries), ...(dailySeriesByDay.get(day) || {}) };
|
||||
const childRows = dayCountries.map((row) =>
|
||||
createReportRow(row, {
|
||||
date_label: day,
|
||||
row_type: "country",
|
||||
stat_day: day,
|
||||
}),
|
||||
);
|
||||
rows.push(
|
||||
createReportRow(normalizeCountryRow({ ...dayTotalSource, country: "总计" }, rows.length, effectiveRechargeUSDMinor(dayTotalSource) || 1), {
|
||||
children: childRows,
|
||||
country: "总计",
|
||||
date_label: day,
|
||||
row_type: "date_total",
|
||||
stat_day: day,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function createReportRow(row, extra) {
|
||||
return {
|
||||
...row,
|
||||
...extra,
|
||||
flag: extra.row_type === "country" ? row.flag : "",
|
||||
row_id: [extra.row_type, extra.stat_day || extra.date_label || "", row.country_id || row.country || "", row.region_id || ""].join(":"),
|
||||
};
|
||||
}
|
||||
|
||||
function groupRowsByDay(rows) {
|
||||
return rows.reduce((groups, row) => {
|
||||
const day = row.stat_day || row.date_label || "";
|
||||
if (!day) {
|
||||
return groups;
|
||||
}
|
||||
if (!groups.has(day)) {
|
||||
groups.set(day, []);
|
||||
}
|
||||
groups.get(day).push(row);
|
||||
return groups;
|
||||
}, new Map());
|
||||
}
|
||||
|
||||
function summarizeReportRows(rows) {
|
||||
const fields = [
|
||||
"active_users",
|
||||
"coin_seller_recharge_usd_minor",
|
||||
"coin_seller_stock_coin",
|
||||
"coin_seller_transfer_coin",
|
||||
"coin_total",
|
||||
"consumed_coin",
|
||||
"output_coin",
|
||||
"game_payout",
|
||||
"game_refund",
|
||||
"game_turnover",
|
||||
"gift_coin_spent",
|
||||
"google_recharge_usd_minor",
|
||||
"lucky_gift_payout",
|
||||
"lucky_gift_turnover",
|
||||
"mifapay_recharge_usd_minor",
|
||||
"mic_online_ms",
|
||||
"mic_online_users",
|
||||
"new_user_recharge_usd_minor",
|
||||
"paid_users",
|
||||
"platform_grant_coin",
|
||||
"recharge_usd_minor",
|
||||
"recharge_users",
|
||||
"registrations",
|
||||
"manual_grant_coin",
|
||||
"salary_usd_minor",
|
||||
"salary_transfer_coin",
|
||||
"super_lucky_gift_payout",
|
||||
"super_lucky_gift_turnover",
|
||||
];
|
||||
const summary = fields.reduce((summary, key) => {
|
||||
summary[key] = sumOptionalField(rows, key);
|
||||
return summary;
|
||||
}, {});
|
||||
// 日期父行是多个国家行的合计,平均麦上时间必须按合计后的总时长/人数重算;如果下游 App 没有麦上时长字段,保留 null 让报表展示缺失而不是 0。
|
||||
summary.avg_mic_online_ms = summary.mic_online_ms === null || summary.mic_online_users === null ? null : Math.round(ratio(summary.mic_online_ms, summary.mic_online_users));
|
||||
return summary;
|
||||
}
|
||||
|
||||
function sumOptionalField(rows, key) {
|
||||
let hasValue = false;
|
||||
const total = rows.reduce((sum, row) => {
|
||||
const value = row?.[key];
|
||||
if (value === undefined || value === null || Number.isNaN(Number(value))) {
|
||||
return sum;
|
||||
}
|
||||
hasValue = true;
|
||||
return sum + numberValue(value);
|
||||
}, 0);
|
||||
return hasValue ? total : null;
|
||||
}
|
||||
|
||||
function isMultiDayRange(range) {
|
||||
return Boolean(range?.start && range?.end && range.start !== range.end);
|
||||
}
|
||||
|
||||
function normalizeCountryRow(item, index, totalRecharge) {
|
||||
const code = item.country_code || item.countryCode || item.iso_code || item.isoCode;
|
||||
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 = effectiveRechargeUSDMinor(item);
|
||||
const activeUsers = readOptionalNumber(item, "active_users", "activeUsers");
|
||||
const paidUsers = readOptionalNumber(item, "paid_users", "paidUsers");
|
||||
const rechargeUsers = readOptionalNumber(item, "recharge_users", "rechargeUsers", "paid_users", "paidUsers");
|
||||
const gameTurnover = readOptionalNumber(item, "game_turnover", "gameTurnover");
|
||||
const gamePayout = readOptionalNumber(item, "game_payout", "gamePayout");
|
||||
const gameRefund = readOptionalNumber(item, "game_refund", "gameRefund") ?? 0;
|
||||
const gameProfit = readOptionalNumber(item, "game_profit", "gameProfit") ?? (gameTurnover === null || gamePayout === null ? null : gameTurnover - gamePayout - gameRefund);
|
||||
const luckyGiftTurnover = readOptionalNumber(item, "lucky_gift_turnover", "luckyGiftTurnover", "room_lucky_gift_turnover", "roomLuckyGiftTurnover");
|
||||
const luckyGiftPayout = readOptionalNumber(item, "lucky_gift_payout", "luckyGiftPayout");
|
||||
const luckyGiftProfit = readOptionalNumber(item, "lucky_gift_profit", "luckyGiftProfit") ?? (luckyGiftTurnover === null || luckyGiftPayout === null ? null : luckyGiftTurnover - luckyGiftPayout);
|
||||
const superLuckyGiftTurnover = readOptionalNumber(item, "super_lucky_gift_turnover", "superLuckyGiftTurnover");
|
||||
const superLuckyGiftPayout = readOptionalNumber(item, "super_lucky_gift_payout", "superLuckyGiftPayout");
|
||||
const superLuckyGiftProfit =
|
||||
readOptionalNumber(item, "super_lucky_gift_profit", "superLuckyGiftProfit") ??
|
||||
(superLuckyGiftTurnover === null || superLuckyGiftPayout === null ? null : superLuckyGiftTurnover - superLuckyGiftPayout);
|
||||
const micOnlineMS = readOptionalNumber(item, "mic_online_ms", "micOnlineMs");
|
||||
const micOnlineUsers = readOptionalNumber(item, "mic_online_users", "micOnlineUsers");
|
||||
const avgMicOnlineMS = readOptionalNumber(item, "avg_mic_online_ms", "avgMicOnlineMs") ?? (micOnlineMS === null || micOnlineUsers === null ? null : Math.round(ratio(micOnlineMS, micOnlineUsers)));
|
||||
const consumedCoin = readOptionalNumber(item, "consumed_coin", "consumedCoin");
|
||||
const outputCoin = readOptionalNumber(item, "output_coin", "outputCoin");
|
||||
const consumeOutputRatio = readOptionalNumber(item, "consume_output_ratio", "consumeOutputRatio") ?? (consumedCoin === null || outputCoin === null ? null : ratio(outputCoin, consumedCoin));
|
||||
const consumeOutputDelta = readOptionalNumber(item, "consume_output_delta", "consumeOutputDelta") ?? (consumedCoin === null || outputCoin === null ? null : consumedCoin - outputCoin);
|
||||
return {
|
||||
active_users: activeUsers,
|
||||
arpu_usd_minor: readOptionalNumber(item, "arpu_usd_minor", "arpuUsdMinor"),
|
||||
arppu_usd_minor: readOptionalNumber(item, "arppu_usd_minor", "arppuUsdMinor"),
|
||||
avg_recharge_usd_minor: readOptionalNumber(item, "avg_recharge_usd_minor", "avgRechargeUsdMinor"),
|
||||
avg_mic_online_ms: avgMicOnlineMS,
|
||||
coin_seller_recharge_usd_minor: readOptionalNumber(item, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor"),
|
||||
coin_seller_stock_coin: readOptionalNumber(item, "coin_seller_stock_coin", "coinSellerStockCoin"),
|
||||
coin_seller_transfer_coin: readOptionalNumber(item, "coin_seller_transfer_coin", "coinSellerTransferCoin"),
|
||||
coin_total: readOptionalNumber(item, "coin_total", "coinTotal"),
|
||||
consumed_coin: consumedCoin,
|
||||
consume_output_delta: consumeOutputDelta,
|
||||
consume_output_ratio: consumeOutputRatio,
|
||||
country: meta?.label || country,
|
||||
country_id: countryId,
|
||||
country_code: code || meta?.code || "",
|
||||
flag: countryFlag(code || meta?.code),
|
||||
game_payout: gamePayout,
|
||||
game_profit: gameProfit,
|
||||
game_profit_rate: readOptionalNumber(item, "game_profit_rate", "gameProfitRate") ?? (gameProfit === null || gameTurnover === null ? null : ratio(gameProfit, gameTurnover)),
|
||||
game_refund: gameRefund,
|
||||
game_turnover: gameTurnover,
|
||||
geo_point: Array.isArray(item.geo_point) ? item.geo_point : meta?.point,
|
||||
gift_coin_spent: readOptionalNumber(item, "gift_coin_spent", "giftCoinSpent"),
|
||||
google_recharge_usd_minor: readOptionalNumber(item, "google_recharge_usd_minor", "googleRechargeUsdMinor"),
|
||||
lucky_gift_payout: luckyGiftPayout,
|
||||
lucky_gift_profit: luckyGiftProfit,
|
||||
lucky_gift_profit_rate:
|
||||
readOptionalNumber(item, "lucky_gift_profit_rate", "luckyGiftProfitRate") ?? (luckyGiftProfit === null || luckyGiftTurnover === null ? null : ratio(luckyGiftProfit, luckyGiftTurnover)),
|
||||
lucky_gift_turnover: luckyGiftTurnover,
|
||||
manual_grant_coin: readOptionalNumber(item, "manual_grant_coin", "manualGrantCoin"),
|
||||
mic_online_ms: micOnlineMS,
|
||||
mic_online_users: micOnlineUsers,
|
||||
mifapay_recharge_usd_minor: readOptionalNumber(item, "mifapay_recharge_usd_minor", "mifapayRechargeUsdMinor", "mifa_pay_recharge_usd_minor", "mifaPayRechargeUsdMinor"),
|
||||
new_user_recharge_usd_minor: readOptionalNumber(item, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor"),
|
||||
paid_users: paidUsers,
|
||||
payer_rate:
|
||||
readOptionalNumber(item, "payer_rate", "payerRate", "paid_conversion_rate", "paidConversionRate") ?? (paidUsers === null || activeUsers === null ? null : ratio(paidUsers, activeUsers)),
|
||||
output_coin: outputCoin,
|
||||
platform_grant_coin: readOptionalNumber(item, "platform_grant_coin", "platformGrantCoin"),
|
||||
recharge_usd_minor: recharge,
|
||||
recharge_conversion_rate: hasAnyKey(item, "recharge_conversion_rate", "rechargeConversionRate")
|
||||
? readOptionalNumber(item, "recharge_conversion_rate", "rechargeConversionRate")
|
||||
: rechargeUsers === null || activeUsers === null
|
||||
? null
|
||||
: ratio(rechargeUsers, activeUsers),
|
||||
recharge_users: rechargeUsers,
|
||||
region_id: numberValue(item.region_id ?? item.regionId),
|
||||
salary_usd_minor: readOptionalNumber(item, "salary_usd_minor", "salaryUsdMinor"),
|
||||
salary_transfer_coin: readOptionalNumber(item, "salary_transfer_coin", "salaryTransferCoin"),
|
||||
share: recharge / totalRecharge,
|
||||
registrations: readOptionalNumber(item, "new_users", "newUsers", "registrations", "registered_users", "registeredUsers"),
|
||||
super_lucky_gift_payout: superLuckyGiftPayout,
|
||||
super_lucky_gift_profit: superLuckyGiftProfit,
|
||||
super_lucky_gift_profit_rate:
|
||||
readOptionalNumber(item, "super_lucky_gift_profit_rate", "superLuckyGiftProfitRate") ??
|
||||
(superLuckyGiftProfit === null || superLuckyGiftTurnover === null ? null : ratio(superLuckyGiftProfit, superLuckyGiftTurnover)),
|
||||
super_lucky_gift_turnover: superLuckyGiftTurnover,
|
||||
trend_rate: numberValue(item.trend_rate ?? item.trendRate),
|
||||
};
|
||||
}
|
||||
|
||||
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 googleMetric = readOptionalNumber(item, "google_recharge_usd_minor", "googleRechargeUsdMinor", "google");
|
||||
const mifapayMetric = readOptionalNumber(item, "mifapay_recharge_usd_minor", "mifapayRechargeUsdMinor", "mifa_pay_recharge_usd_minor", "mifaPayRechargeUsdMinor", "mifapay");
|
||||
const coinSellerMetric = readOptionalNumber(item, "coin_seller_recharge_usd_minor", "coinSellerRechargeUsdMinor", "coin_seller");
|
||||
const google = googleMetric ?? 0;
|
||||
const mifapay = mifapayMetric ?? 0;
|
||||
const coinSeller = coinSellerMetric ?? 0;
|
||||
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 = readOptionalNumber(item, "game_turnover", "gameTurnover");
|
||||
const gamePayout = readOptionalNumber(item, "game_payout", "gamePayout");
|
||||
const gameProfit = readOptionalNumber(item, "game_profit", "gameProfit");
|
||||
const luckyGiftTurnover = readOptionalNumber(item, "lucky_gift_turnover", "luckyGiftTurnover", "room_lucky_gift_turnover", "roomLuckyGiftTurnover");
|
||||
const luckyGiftPayout = readOptionalNumber(item, "lucky_gift_payout", "luckyGiftPayout");
|
||||
const luckyGiftProfit = readOptionalNumber(item, "lucky_gift_profit", "luckyGiftProfit") ?? (luckyGiftTurnover === null || luckyGiftPayout === null ? null : luckyGiftTurnover - luckyGiftPayout);
|
||||
const micOnlineMS = readOptionalNumber(item, "mic_online_ms", "micOnlineMs");
|
||||
const micOnlineUsers = readOptionalNumber(item, "mic_online_users", "micOnlineUsers");
|
||||
const avgMicOnlineMS = readOptionalNumber(item, "avg_mic_online_ms", "avgMicOnlineMs") ?? (micOnlineMS === null || micOnlineUsers === null ? null : Math.round(ratio(micOnlineMS, micOnlineUsers)));
|
||||
const consumedCoin = readOptionalNumber(item, "consumed_coin", "consumedCoin");
|
||||
const outputCoin = readOptionalNumber(item, "output_coin", "outputCoin");
|
||||
const consumeOutputRatio = readOptionalNumber(item, "consume_output_ratio", "consumeOutputRatio") ?? (consumedCoin === null || outputCoin === null ? null : ratio(outputCoin, consumedCoin));
|
||||
const consumeOutputDelta = readOptionalNumber(item, "consume_output_delta", "consumeOutputDelta") ?? (consumedCoin === null || outputCoin === null ? null : consumedCoin - outputCoin);
|
||||
return {
|
||||
active_users: readOptionalNumber(item, "active_users", "activeUsers"),
|
||||
arppu_usd_minor: readOptionalNumber(item, "arppu_usd_minor", "arppuUsdMinor"),
|
||||
arpu_usd_minor: readOptionalNumber(item, "arpu_usd_minor", "arpuUsdMinor"),
|
||||
avg_mic_online_ms: avgMicOnlineMS,
|
||||
coin_seller: coinSeller,
|
||||
coin_seller_recharge_usd_minor: coinSellerMetric,
|
||||
coin_seller_stock_coin: readOptionalNumber(item, "coin_seller_stock_coin", "coinSellerStockCoin"),
|
||||
coin_seller_transfer_coin: readOptionalNumber(item, "coin_seller_transfer_coin", "coinSellerTransferCoin"),
|
||||
coin_total: readOptionalNumber(item, "coin_total", "coinTotal"),
|
||||
consumed_coin: consumedCoin,
|
||||
consume_output_delta: consumeOutputDelta,
|
||||
consume_output_ratio: consumeOutputRatio,
|
||||
game_payout: gamePayout,
|
||||
game_profit: gameProfit,
|
||||
game_turnover: gameTurnover,
|
||||
gift_coin_spent: readOptionalNumber(item, "gift_coin_spent", "giftCoinSpent"),
|
||||
google,
|
||||
google_recharge_usd_minor: googleMetric,
|
||||
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: luckyGiftProfit,
|
||||
lucky_gift_turnover: luckyGiftTurnover,
|
||||
manual_grant_coin: readOptionalNumber(item, "manual_grant_coin", "manualGrantCoin"),
|
||||
mic_online_ms: micOnlineMS,
|
||||
mic_online_users: micOnlineUsers,
|
||||
mifapay,
|
||||
mifapay_recharge_usd_minor: mifapayMetric,
|
||||
new_paid_users: readOptionalNumber(item, "new_paid_users", "newPaidUsers"),
|
||||
new_user_recharge_usd_minor: readOptionalNumber(item, "new_user_recharge_usd_minor", "newUserRechargeUsdMinor"),
|
||||
new_users: readOptionalNumber(item, "new_users", "newUsers"),
|
||||
output_coin: outputCoin,
|
||||
paid_users: readOptionalNumber(item, "paid_users", "paidUsers"),
|
||||
platform_grant_coin: readOptionalNumber(item, "platform_grant_coin", "platformGrantCoin"),
|
||||
recharge_usd_minor: recharge,
|
||||
recharge_users: readOptionalNumber(item, "recharge_users", "rechargeUsers", "paid_users", "paidUsers"),
|
||||
salary_usd_minor: readOptionalNumber(item, "salary_usd_minor", "salaryUsdMinor"),
|
||||
salary_transfer_coin: readOptionalNumber(item, "salary_transfer_coin", "salaryTransferCoin"),
|
||||
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 || "-",
|
||||
trend_rate: numberValue(item.trend_rate ?? item.trendRate),
|
||||
value: numberValue(item.value ?? item.amount ?? item.coin),
|
||||
}));
|
||||
}
|
||||
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 [];
|
||||
}
|
||||
|
||||
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) {
|
||||
const match = String(label).match(/^May\s+(\d{1,2})$/i);
|
||||
if (match) {
|
||||
return `5月${match[1]}日`;
|
||||
}
|
||||
return label;
|
||||
}
|
||||
313
databi/src/data/createDashboardModel.test.js
Normal file
313
databi/src/data/createDashboardModel.test.js
Normal file
@ -0,0 +1,313 @@
|
||||
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_stock_coin: 400_000,
|
||||
coin_seller_transfer_coin: 160_000,
|
||||
salary_transfer_coin: 88_000,
|
||||
country_breakdown: [
|
||||
{
|
||||
coin_seller_recharge_usd_minor: 200,
|
||||
coin_seller_stock_coin: 400_000,
|
||||
coin_seller_transfer_coin: 160_000,
|
||||
country: "阿富汗",
|
||||
google_recharge_usd_minor: 150,
|
||||
recharge_usd_minor: 150,
|
||||
salary_transfer_coin: 88_000,
|
||||
},
|
||||
],
|
||||
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_stock_coin: 400_000,
|
||||
coin_seller_transfer_coin: 160_000,
|
||||
recharge_usd_minor: 350,
|
||||
salary_transfer_coin: 88_000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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(["活跃用户", "付费用户", "充值用户"]);
|
||||
});
|
||||
|
||||
test("adds real-room robot gift metrics to a separate card row", () => {
|
||||
const model = createDashboardModel(
|
||||
{
|
||||
real_room_robot_gift_room_count: 2,
|
||||
real_room_robot_lucky_gift_coin: 200,
|
||||
real_room_robot_normal_gift_coin: 100,
|
||||
real_room_robot_super_gift_coin: 300,
|
||||
},
|
||||
{ appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } },
|
||||
);
|
||||
|
||||
expect(metricValue(model, "真人房机器人普通礼物")).toBe("100");
|
||||
expect(metricValue(model, "真人房机器人幸运礼物")).toBe("200");
|
||||
expect(metricValue(model, "真人房机器人超级幸运礼物")).toBe("300");
|
||||
expect(metricValue(model, "真人房机器人房均礼物")).toBe("300");
|
||||
expect(model.businessKpis.map((item) => item.label)).not.toContain("真人房机器人房均礼物");
|
||||
expect(model.robotGiftKpis.find((item) => item.label === "真人房机器人房均礼物")?.caption).toBe("2 个房间");
|
||||
});
|
||||
|
||||
test("uses active users as payer and recharge conversion denominator", () => {
|
||||
const model = createDashboardModel(
|
||||
{
|
||||
active_users: 80,
|
||||
country_breakdown: [
|
||||
{
|
||||
active_users: 20,
|
||||
country: "巴西",
|
||||
paid_users: 8,
|
||||
recharge_users: 6,
|
||||
recharge_usd_minor: 500,
|
||||
},
|
||||
],
|
||||
new_users: 4,
|
||||
paid_users: 8,
|
||||
recharge_users: 6,
|
||||
},
|
||||
{ appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } },
|
||||
);
|
||||
|
||||
expect(model.sideMetrics.find((item) => item.label === "付费转化率")?.value).toBe("10.00%");
|
||||
expect(model.countryBreakdown[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
payer_rate: 0.4,
|
||||
recharge_conversion_rate: 0.3,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("uses registration count instead of legacy visitors in country breakdown", () => {
|
||||
const model = createDashboardModel(
|
||||
{
|
||||
country_breakdown: [
|
||||
{
|
||||
country: "菲律宾",
|
||||
new_users: 12,
|
||||
visitors: 99,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } },
|
||||
);
|
||||
|
||||
expect(model.countryBreakdown[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
registrations: 12,
|
||||
}),
|
||||
);
|
||||
expect(model.countryBreakdown[0]).not.toHaveProperty("visitors");
|
||||
});
|
||||
|
||||
test("builds report rows with range total, daily totals, and daily country rows", () => {
|
||||
const model = createDashboardModel(
|
||||
{
|
||||
country_breakdown: [{ country: "巴西", country_id: 86, active_users: 30, recharge_usd_minor: 300 }],
|
||||
daily_country_breakdown: [
|
||||
{
|
||||
active_users: 10,
|
||||
coin_seller_stock_coin: 800,
|
||||
coin_seller_transfer_coin: 500,
|
||||
coin_total: 1_200,
|
||||
consumed_coin: 300,
|
||||
country: "巴西",
|
||||
country_id: 86,
|
||||
game_turnover: 300,
|
||||
lucky_gift_turnover: 200,
|
||||
manual_grant_coin: 20,
|
||||
mic_online_ms: 6_000,
|
||||
mic_online_users: 3,
|
||||
output_coin: 120,
|
||||
platform_grant_coin: 150,
|
||||
recharge_usd_minor: 100,
|
||||
salary_usd_minor: 900,
|
||||
salary_transfer_coin: 700,
|
||||
stat_day: "2026-06-05",
|
||||
super_lucky_gift_turnover: 80,
|
||||
super_lucky_gift_payout: 20,
|
||||
},
|
||||
{
|
||||
active_users: 20,
|
||||
country: "巴西",
|
||||
country_id: 86,
|
||||
recharge_usd_minor: 200,
|
||||
stat_day: "2026-06-06",
|
||||
},
|
||||
],
|
||||
daily_series: [
|
||||
{
|
||||
active_users: 10,
|
||||
coin_total: 1_200,
|
||||
consumed_coin: 300,
|
||||
label: "2026-06-05",
|
||||
manual_grant_coin: 20,
|
||||
mic_online_ms: 6_000,
|
||||
mic_online_users: 3,
|
||||
output_coin: 120,
|
||||
platform_grant_coin: 150,
|
||||
recharge_usd_minor: 100,
|
||||
salary_usd_minor: 900,
|
||||
stat_day: "2026-06-05",
|
||||
},
|
||||
{ active_users: 20, label: "2026-06-06", recharge_usd_minor: 200, stat_day: "2026-06-06" },
|
||||
],
|
||||
recharge_usd_minor: 300,
|
||||
},
|
||||
{ appCode: "lalu", countryId: 0, range: { end: "2026-06-06", start: "2026-06-05" } },
|
||||
);
|
||||
|
||||
expect(model.reportCountryRows.map((row) => row.row_type)).toEqual(["total", "date_total", "date_total"]);
|
||||
expect(model.reportCountryRows[0]).toEqual(expect.objectContaining({ country: "总计", date_label: "全部" }));
|
||||
expect(model.reportCountryRows[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
avg_mic_online_ms: 2_000,
|
||||
coin_total: 1_200,
|
||||
consumed_coin: 300,
|
||||
consume_output_delta: 180,
|
||||
consume_output_ratio: 0.4,
|
||||
country: "总计",
|
||||
date_label: "2026-06-05",
|
||||
manual_grant_coin: 20,
|
||||
mic_online_ms: 6_000,
|
||||
output_coin: 120,
|
||||
platform_grant_coin: 150,
|
||||
salary_usd_minor: 900,
|
||||
}),
|
||||
);
|
||||
expect(model.reportCountryRows[1].children).toHaveLength(1);
|
||||
expect(model.reportCountryRows[1].children[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
avg_mic_online_ms: 2_000,
|
||||
coin_seller_stock_coin: 800,
|
||||
coin_seller_transfer_coin: 500,
|
||||
coin_total: 1_200,
|
||||
consumed_coin: 300,
|
||||
consume_output_delta: 180,
|
||||
consume_output_ratio: 0.4,
|
||||
country: "巴西",
|
||||
game_turnover: 300,
|
||||
lucky_gift_turnover: 200,
|
||||
manual_grant_coin: 20,
|
||||
mic_online_ms: 6_000,
|
||||
output_coin: 120,
|
||||
platform_grant_coin: 150,
|
||||
salary_usd_minor: 900,
|
||||
salary_transfer_coin: 700,
|
||||
super_lucky_gift_profit: 60,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps unavailable external report metrics as missing values", () => {
|
||||
const model = createDashboardModel(
|
||||
{
|
||||
country_breakdown: [
|
||||
{
|
||||
active_users: 12,
|
||||
arppu_usd_minor: null,
|
||||
coin_seller_recharge_usd_minor: null,
|
||||
coin_seller_transfer_coin: null,
|
||||
country: "Saudi Arabia",
|
||||
game_profit_rate: null,
|
||||
game_turnover: null,
|
||||
gift_coin_spent: null,
|
||||
lucky_gift_profit_rate: null,
|
||||
lucky_gift_turnover: null,
|
||||
paid_users: null,
|
||||
recharge_conversion_rate: null,
|
||||
recharge_users: null,
|
||||
recharge_usd_minor: 10_025,
|
||||
},
|
||||
],
|
||||
recharge_usd_minor: 10_025,
|
||||
},
|
||||
{ appCode: "aslan", countryId: 0, range: { end: "2026-06-30", start: "2026-06-30" } },
|
||||
);
|
||||
|
||||
expect(model.countryBreakdown[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
arppu_usd_minor: null,
|
||||
coin_seller_recharge_usd_minor: null,
|
||||
coin_seller_transfer_coin: null,
|
||||
game_profit_rate: null,
|
||||
game_turnover: null,
|
||||
gift_coin_spent: null,
|
||||
lucky_gift_profit_rate: null,
|
||||
lucky_gift_turnover: null,
|
||||
paid_users: null,
|
||||
payer_rate: null,
|
||||
recharge_conversion_rate: null,
|
||||
recharge_users: null,
|
||||
}),
|
||||
);
|
||||
expect(model.reportCountryRows[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
arppu_usd_minor: null,
|
||||
game_turnover: null,
|
||||
paid_users: null,
|
||||
recharge_users: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
function metricValue(model, label) {
|
||||
return [...model.kpis, ...model.businessKpis, ...model.robotGiftKpis].find((item) => item.label === label)?.value;
|
||||
}
|
||||
1
databi/src/data/world.json
Normal file
1
databi/src/data/world.json
Normal file
File diff suppressed because one or more lines are too long
12
databi/src/main.jsx
Normal file
12
databi/src/main.jsx
Normal file
@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { DatabiApp } from "./DatabiApp.jsx";
|
||||
// 全局设计 token(@/styles/tokens.css)供共享组件(如 TimeRangeFilter)取色,先于本地样式引入。
|
||||
import "@/styles/tokens.css";
|
||||
import "./styles/index.css";
|
||||
|
||||
createRoot(document.getElementById("databi-root")).render(
|
||||
<React.StrictMode>
|
||||
<DatabiApp />
|
||||
</React.StrictMode>
|
||||
);
|
||||
338
databi/src/social/SocialBiApp.jsx
Normal file
338
databi/src/social/SocialBiApp.jsx
Normal file
@ -0,0 +1,338 @@
|
||||
// 社交 BI v2 Shell:深色侧栏(视图导航)+ 顶栏全局筛选(日期/App/区域/粒度),
|
||||
// 视图组件通过 useSocialBi() 消费统一的筛选状态与数据派生结果。
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import AccountTreeOutlined from "@mui/icons-material/AccountTreeOutlined";
|
||||
import GroupsOutlined from "@mui/icons-material/GroupsOutlined";
|
||||
import InsightsOutlined from "@mui/icons-material/InsightsOutlined";
|
||||
import PublicOutlined from "@mui/icons-material/PublicOutlined";
|
||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||
import RepeatOutlined from "@mui/icons-material/RepeatOutlined";
|
||||
import TableChartOutlined from "@mui/icons-material/TableChartOutlined";
|
||||
import {
|
||||
ALL,
|
||||
DATE_PRESETS,
|
||||
GRANULARITIES,
|
||||
createDefaultFilters,
|
||||
rangeLabel,
|
||||
readStateFromURL,
|
||||
toggleMultiValue,
|
||||
writeStateToURL
|
||||
} from "./state.js";
|
||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||
import { appColor } from "./metrics.js";
|
||||
import { SOCIAL_BI_FUNNEL_APP_CODES, useSocialBiData } from "./useSocialBiData.js";
|
||||
import { OverviewView } from "./views/OverviewView.jsx";
|
||||
import { RegionsView } from "./views/RegionsView.jsx";
|
||||
import { TeamKpiView } from "./views/TeamKpiView.jsx";
|
||||
import { RetentionView } from "./views/RetentionView.jsx";
|
||||
import { DataTableView } from "./views/DataTableView.jsx";
|
||||
import { FunnelView } from "./views/FunnelView.jsx";
|
||||
import "../styles/social-v2.css";
|
||||
|
||||
const VIEWS = [
|
||||
{ component: OverviewView, icon: InsightsOutlined, key: "overview", label: "经营概览" },
|
||||
{ component: RegionsView, icon: PublicOutlined, key: "regions", label: "地区洞察" },
|
||||
{ component: TeamKpiView, icon: GroupsOutlined, key: "team", label: "运营中心" },
|
||||
{ component: RetentionView, icon: RepeatOutlined, key: "retention", label: "留存质量" },
|
||||
{ component: DataTableView, icon: TableChartOutlined, key: "table", label: "数据明细" },
|
||||
{ component: FunnelView, icon: AccountTreeOutlined, key: "funnel", label: "埋点漏斗" }
|
||||
];
|
||||
|
||||
const SocialBiContext = createContext(null);
|
||||
|
||||
export function useSocialBi() {
|
||||
const context = useContext(SocialBiContext);
|
||||
if (!context) {
|
||||
throw new Error("useSocialBi must be used inside SocialBiApp");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function SocialBiApp() {
|
||||
const initial = useMemo(() => readStateFromURL(), []);
|
||||
const [filters, setFilters] = useState(initial.filters);
|
||||
const [viewKey, setViewKey] = useState(() => (VIEWS.some((view) => view.key === initial.view) ? initial.view : "overview"));
|
||||
const data = useSocialBiData(filters, { includeFunnel: viewKey === "funnel" });
|
||||
|
||||
useEffect(() => {
|
||||
writeStateToURL(filters, viewKey);
|
||||
}, [filters, viewKey]);
|
||||
|
||||
const updateFilter = useCallback((key, value) => {
|
||||
setFilters((current) => ({ ...current, [key]: value }));
|
||||
}, []);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setFilters(createDefaultFilters());
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ ...data, filters, resetFilters, setViewKey, updateFilter, viewKey }),
|
||||
[data, filters, resetFilters, updateFilter, viewKey]
|
||||
);
|
||||
|
||||
const ActiveView = (VIEWS.find((view) => view.key === viewKey) || VIEWS[0]).component;
|
||||
|
||||
return (
|
||||
<SocialBiContext.Provider value={contextValue}>
|
||||
<div className="sbi-shell">
|
||||
<Sidebar data={data} onViewChange={setViewKey} viewKey={viewKey} />
|
||||
<div className="sbi-main">
|
||||
<TopBar data={data} filters={filters} updateFilter={updateFilter} viewKey={viewKey} />
|
||||
{data.errors.length ? (
|
||||
<div className="sbi-error-banner" role="alert">
|
||||
{data.errors.map((message, index) => (
|
||||
<span key={`${index}-${message}`}>{message}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<main className="sbi-view" aria-label="Social BI 数据中心">
|
||||
<ActiveView />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</SocialBiContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({ data, onViewChange, viewKey }) {
|
||||
const access = data.master?.access;
|
||||
const scopeLabel = !data.master ? "" : access?.all ? "全量数据范围" : `负责 ${access?.scopes?.length || 0} 个 App/区域`;
|
||||
return (
|
||||
<aside className="sbi-sidebar" aria-label="Social BI 导航">
|
||||
<div className="sbi-brand">
|
||||
<span className="sbi-brand-mark">HY</span>
|
||||
<div className="sbi-brand-copy">
|
||||
<strong>运营数据总览</strong>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="sbi-nav" role="tablist" aria-label="数据视图">
|
||||
{VIEWS.map((view) => {
|
||||
const Icon = view.icon;
|
||||
const isActive = viewKey === view.key;
|
||||
return (
|
||||
<button
|
||||
aria-selected={isActive}
|
||||
className={isActive ? "sbi-nav-item is-active" : "sbi-nav-item"}
|
||||
key={view.key}
|
||||
onClick={() => onViewChange(view.key)}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
<Icon fontSize="small" />
|
||||
<span>{view.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{scopeLabel ? (
|
||||
<div className="sbi-scope-badge" title="数据按你的负责范围裁剪,可在用户管理里调整">
|
||||
<span className="sbi-scope-dot" />
|
||||
{scopeLabel}
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function TopBar({ data, filters, updateFilter, viewKey }) {
|
||||
return (
|
||||
<header className="sbi-topbar">
|
||||
<div className="sbi-topbar-row">
|
||||
<DatePresetControl filters={filters} updateFilter={updateFilter} />
|
||||
<GranularityControl filters={filters} updateFilter={updateFilter} />
|
||||
<button className="sbi-refresh" onClick={data.refresh} title="刷新数据" type="button">
|
||||
<RefreshOutlined fontSize="small" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="sbi-topbar-row">
|
||||
<AppChips data={data} filters={filters} updateFilter={updateFilter} viewKey={viewKey} />
|
||||
<RegionSelect data={data} filters={filters} updateFilter={updateFilter} />
|
||||
<span className="sbi-range-label">{rangeLabel(data.range)}</span>
|
||||
{data.isLoading ? <span className="sbi-loading-dot" aria-label="加载中" /> : null}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function DatePresetControl({ filters, updateFilter }) {
|
||||
return (
|
||||
<div className="sbi-date-presets" role="radiogroup" aria-label="日期区间">
|
||||
{DATE_PRESETS.map((preset) => (
|
||||
<button
|
||||
aria-checked={filters.preset === preset.key}
|
||||
className={filters.preset === preset.key ? "is-active" : ""}
|
||||
key={preset.key}
|
||||
onClick={() => updateFilter("preset", preset.key)}
|
||||
role="radio"
|
||||
type="button"
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
{filters.preset === "custom" ? (
|
||||
<TimeRangeFilter
|
||||
className="sbi-custom-range"
|
||||
label="自定义区间"
|
||||
value={{ endMs: dayTextToEndMs(filters.customEnd), startMs: dayTextToStartMs(filters.customStart) }}
|
||||
withTime={false}
|
||||
onChange={(range) => {
|
||||
updateFilter("customStart", msToDayText(range.startMs));
|
||||
updateFilter("customEnd", msToDayText(range.endMs));
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 自定义区间在筛选状态里以天字符串(YYYY-MM-DD)存储,选择器以本地时区毫秒交互,这里做双向换算。
|
||||
function parseDayText(value) {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || ""));
|
||||
return match ? { day: Number(match[3]), month: Number(match[2]), year: Number(match[1]) } : null;
|
||||
}
|
||||
|
||||
function dayTextToStartMs(dayText) {
|
||||
const parts = parseDayText(dayText);
|
||||
return parts ? new Date(parts.year, parts.month - 1, parts.day, 0, 0, 0, 0).getTime() : "";
|
||||
}
|
||||
|
||||
function dayTextToEndMs(dayText) {
|
||||
const parts = parseDayText(dayText);
|
||||
return parts ? new Date(parts.year, parts.month - 1, parts.day, 23, 59, 59, 999).getTime() : "";
|
||||
}
|
||||
|
||||
function msToDayText(ms) {
|
||||
if (!ms) {
|
||||
return "";
|
||||
}
|
||||
const date = new Date(Number(ms));
|
||||
const pad = (value) => String(value).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
function GranularityControl({ filters, updateFilter }) {
|
||||
return (
|
||||
<div className="sbi-granularity" role="radiogroup" aria-label="趋势粒度">
|
||||
{GRANULARITIES.map((item) => (
|
||||
<button
|
||||
aria-checked={filters.granularity === item.key}
|
||||
className={filters.granularity === item.key ? "is-active" : ""}
|
||||
key={item.key}
|
||||
onClick={() => updateFilter("granularity", item.key)}
|
||||
role="radio"
|
||||
type="button"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppChips({ data, filters, updateFilter, viewKey }) {
|
||||
const apps = viewKey === "funnel"
|
||||
? (data.master?.apps || []).filter((app) => SOCIAL_BI_FUNNEL_APP_CODES.includes(app.app_code))
|
||||
: data.master?.apps || [];
|
||||
const allCodes = apps.map((app) => app.app_code);
|
||||
const hasSelectedVisibleApp = filters.apps.some((appCode) => allCodes.includes(appCode));
|
||||
const isAll = filters.apps.includes(ALL) || (viewKey === "funnel" && !hasSelectedVisibleApp);
|
||||
return (
|
||||
<div className="sbi-app-chips" role="group" aria-label="App 筛选">
|
||||
<button
|
||||
aria-pressed={isAll}
|
||||
className={isAll ? "sbi-chip is-active" : "sbi-chip"}
|
||||
onClick={() => updateFilter("apps", [ALL])}
|
||||
type="button"
|
||||
>
|
||||
{viewKey === "funnel" ? "全部漏斗 App" : "全部 App"}
|
||||
</button>
|
||||
{apps.map((app) => {
|
||||
const selected = !isAll && filters.apps.includes(app.app_code);
|
||||
return (
|
||||
<button
|
||||
aria-pressed={selected}
|
||||
className={selected ? "sbi-chip is-active" : "sbi-chip"}
|
||||
key={app.app_code}
|
||||
onClick={() => updateFilter("apps", toggleMultiValue(filters.apps, app.app_code, allCodes))}
|
||||
type="button"
|
||||
>
|
||||
<span className="sbi-chip-dot" style={{ background: appColor(app.app_code, allCodes) }} />
|
||||
{app.app_name || app.app_code}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionSelect({ data, filters, updateFilter }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef(null);
|
||||
const apps = data.master?.apps || [];
|
||||
const regions = data.master?.regions || [];
|
||||
const groups = apps
|
||||
.map((app) => ({
|
||||
app,
|
||||
regions: regions.filter((region) => region.app_code === app.app_code)
|
||||
}))
|
||||
.filter((group) => group.regions.length);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerDown = (event) => {
|
||||
if (!containerRef.current?.contains(event.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
||||
}, []);
|
||||
|
||||
const selectedCount = filters.regions.includes(ALL) ? 0 : filters.regions.length;
|
||||
return (
|
||||
<div className="sbi-region-select" ref={containerRef}>
|
||||
<button aria-expanded={isOpen} className="sbi-region-trigger" onClick={() => setIsOpen((open) => !open)} type="button">
|
||||
地区:{selectedCount ? `已选 ${selectedCount} 项` : "全部"}
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div className="sbi-region-menu" role="listbox" aria-label="地区筛选">
|
||||
<button
|
||||
className={filters.regions.includes(ALL) ? "sbi-region-option is-active" : "sbi-region-option"}
|
||||
onClick={() => updateFilter("regions", [ALL])}
|
||||
type="button"
|
||||
>
|
||||
全部地区
|
||||
</button>
|
||||
{groups.map((group) => (
|
||||
<div className="sbi-region-group" key={group.app.app_code}>
|
||||
<button
|
||||
className={
|
||||
filters.regions.includes(`app:${group.app.app_code}`) ? "sbi-region-option is-group is-active" : "sbi-region-option is-group"
|
||||
}
|
||||
onClick={() => updateFilter("regions", toggleMultiValue(filters.regions, `app:${group.app.app_code}`))}
|
||||
type="button"
|
||||
>
|
||||
{group.app.app_name || group.app.app_code} · 全部区域
|
||||
</button>
|
||||
{group.regions.map((region) => {
|
||||
const value = `${region.app_code}:${region.region_id}`;
|
||||
return (
|
||||
<button
|
||||
className={filters.regions.includes(value) ? "sbi-region-option is-active" : "sbi-region-option"}
|
||||
key={value}
|
||||
onClick={() => updateFilter("regions", toggleMultiValue(filters.regions, value))}
|
||||
type="button"
|
||||
>
|
||||
{region.region_name || region.region_code}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{!groups.length ? <p className="sbi-region-empty">暂无区域目录</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
databi/src/social/format.js
Normal file
121
databi/src/social/format.js
Normal file
@ -0,0 +1,121 @@
|
||||
// 社交 BI v2 的展示格式化层:所有视图共用,保证同一指标在任何视图里的写法一致。
|
||||
// 后端金额一律是 USD 分(*_usd_minor),比例一律是 0-1 小数,这里统一换算。
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
|
||||
const decimalFormatter = new Intl.NumberFormat("en-US", { maximumFractionDigits: 1, minimumFractionDigits: 1 });
|
||||
|
||||
export function isBlank(value) {
|
||||
return value === null || value === undefined || value === "" || (typeof value === "number" && !Number.isFinite(value));
|
||||
}
|
||||
|
||||
export function formatMoneyMinor(value) {
|
||||
if (isBlank(value)) {
|
||||
return "--";
|
||||
}
|
||||
return formatMoney(Number(value) / 100);
|
||||
}
|
||||
|
||||
export function formatMoney(value) {
|
||||
if (isBlank(value)) {
|
||||
return "--";
|
||||
}
|
||||
const numeric = Number(value);
|
||||
const digits = Math.abs(numeric) < 1000 && !Number.isInteger(numeric) ? 1 : 0;
|
||||
return `$${numeric.toLocaleString("en-US", { maximumFractionDigits: digits, minimumFractionDigits: digits })}`;
|
||||
}
|
||||
|
||||
export function formatCount(value) {
|
||||
if (isBlank(value)) {
|
||||
return "--";
|
||||
}
|
||||
return numberFormatter.format(Number(value));
|
||||
}
|
||||
|
||||
// 金币等大数值在卡片/图表标签里用紧凑写法(1.2亿 视觉负担太大,用 en 缩写与后台其余模块一致)。
|
||||
export function formatCompact(value) {
|
||||
if (isBlank(value)) {
|
||||
return "--";
|
||||
}
|
||||
const numeric = Number(value);
|
||||
const abs = Math.abs(numeric);
|
||||
if (abs >= 1_000_000_000) {
|
||||
return `${(numeric / 1_000_000_000).toFixed(1)}B`;
|
||||
}
|
||||
if (abs >= 1_000_000) {
|
||||
return `${(numeric / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
if (abs >= 10_000) {
|
||||
return `${(numeric / 1_000).toFixed(1)}K`;
|
||||
}
|
||||
return numberFormatter.format(numeric);
|
||||
}
|
||||
|
||||
export function formatRatioPercent(value) {
|
||||
if (isBlank(value)) {
|
||||
return "--";
|
||||
}
|
||||
return `${decimalFormatter.format(Number(value) * 100)}%`;
|
||||
}
|
||||
|
||||
export function formatPercentValue(value) {
|
||||
if (isBlank(value)) {
|
||||
return "--";
|
||||
}
|
||||
return `${decimalFormatter.format(Number(value))}%`;
|
||||
}
|
||||
|
||||
export function formatDurationMs(value) {
|
||||
if (isBlank(value)) {
|
||||
return "--";
|
||||
}
|
||||
const totalSeconds = Math.max(0, Math.round(Number(value) / 1000));
|
||||
if (totalSeconds < 60) {
|
||||
return `${totalSeconds}秒`;
|
||||
}
|
||||
const totalMinutes = Math.round(totalSeconds / 60);
|
||||
if (totalMinutes < 60) {
|
||||
return `${totalMinutes}分`;
|
||||
}
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
return minutes ? `${hours}小时${minutes}分` : `${hours}小时`;
|
||||
}
|
||||
|
||||
// 环比:后端 delta 字段是 0-1 增幅(0.12 = +12%)。
|
||||
export function formatDeltaRate(value) {
|
||||
if (isBlank(value)) {
|
||||
return "";
|
||||
}
|
||||
const numeric = Number(value) * 100;
|
||||
const sign = numeric >= 0 ? "+" : "";
|
||||
return `${sign}${decimalFormatter.format(numeric)}%`;
|
||||
}
|
||||
|
||||
export function deltaDirection(value) {
|
||||
if (isBlank(value) || Number(value) === 0) {
|
||||
return "flat";
|
||||
}
|
||||
return Number(value) > 0 ? "up" : "down";
|
||||
}
|
||||
|
||||
export function csvEscape(value) {
|
||||
const text = String(value ?? "");
|
||||
if (/[",\n]/.test(text)) {
|
||||
return `"${text.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function downloadCsv(filename, headerCells, rows) {
|
||||
const lines = [headerCells.map(csvEscape).join(",")];
|
||||
rows.forEach((cells) => {
|
||||
lines.push(cells.map(csvEscape).join(","));
|
||||
});
|
||||
const blob = new Blob(["\uFEFF", lines.join("\n")], { type: "text/csv;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
285
databi/src/social/metrics.js
Normal file
285
databi/src/social/metrics.js
Normal file
@ -0,0 +1,285 @@
|
||||
// 社交 BI v2 的指标口径中心:所有视图通过 METRIC_DEFS 取 label/格式化/口径说明,
|
||||
// 行数据保持后端原始 snake_case 字段(金额 USD 分、比例 0-1),渲染时才格式化。
|
||||
|
||||
import { formatCompact, formatCount, formatDurationMs, formatMoneyMinor, formatRatioPercent, isBlank } from "./format.js";
|
||||
|
||||
export const METRIC_DEFS = {
|
||||
new_users: { label: "新增", tooltip: "当日新注册用户数(legacy 按 user_run_profile 创建时间)", type: "count" },
|
||||
active_users: { label: "日活 (DAU)", tooltip: "当日活跃去重用户;跨天合计为人天", type: "count" },
|
||||
paid_users: { label: "付费用户", tooltip: "发生成功充值的去重用户", type: "count" },
|
||||
recharge_users: { label: "充值用户", tooltip: "与付费用户同口径", type: "count" },
|
||||
recharge_usd_minor: { label: "充值 ($)", tooltip: "全部渠道充值合计(USD)", type: "money" },
|
||||
new_user_recharge_usd_minor: { label: "新用户充值", tooltip: "当日注册用户当日的充值金额", type: "money" },
|
||||
google_recharge_usd_minor: { label: "谷歌充值", tooltip: "Google Play 渠道充值", type: "money" },
|
||||
mifapay_recharge_usd_minor: { label: "三方充值", tooltip: "非谷歌的三方渠道充值合计", type: "money" },
|
||||
coin_seller_recharge_usd_minor: { label: "币商充值", tooltip: "币商进货充值金额", type: "money" },
|
||||
coin_seller_stock_coin: { label: "币商充值金币", tooltip: "币商进货获得的金币", type: "coin" },
|
||||
coin_seller_transfer_coin: { label: "币商出货金币", tooltip: "币商转给用户的金币(legacy 按 SELLER_AGENT 流水)", type: "coin" },
|
||||
game_turnover: { label: "游戏流水", tooltip: "游戏下注金币流水", type: "coin" },
|
||||
game_profit_rate: { label: "游戏利润率", tooltip: "(流水-返奖)/流水", type: "ratio" },
|
||||
lucky_gift_turnover: { label: "幸运礼物流水", tooltip: "幸运礼物赠送金币流水", type: "coin" },
|
||||
lucky_gift_payout: { label: "幸运礼物返奖", tooltip: "幸运礼物中奖返还金币", type: "coin" },
|
||||
lucky_gift_profit_rate: { label: "幸运礼物利润率", tooltip: "(流水-返奖)/流水", type: "ratio" },
|
||||
super_lucky_gift_turnover: { label: "超级幸运礼物流水", tooltip: "超级幸运礼物赠送金币流水(legacy 平台无此玩法)", type: "coin" },
|
||||
super_lucky_gift_profit_rate: { label: "超级幸运礼物利润率", tooltip: "(流水-返奖)/流水", type: "ratio" },
|
||||
gift_coin_spent: { label: "礼物流水", tooltip: "全部礼物赠送金币流水(含幸运礼物)", type: "coin" },
|
||||
coin_total: { label: "金币数量", tooltip: "用户金币余额快照", type: "coin" },
|
||||
consumed_coin: { label: "消耗金币", tooltip: "金币消耗流水合计(legacy 仅全 App 口径)", type: "coin" },
|
||||
output_coin: { label: "产出金币", tooltip: "金币产出流水合计(legacy 仅全 App 口径)", type: "coin" },
|
||||
consume_output_ratio: { label: "消耗产出比", tooltip: "消耗金币 / 产出金币", type: "ratio" },
|
||||
platform_grant_coin: { label: "平台发放金币", tooltip: "平台活动发放金币", type: "coin" },
|
||||
manual_grant_coin: { label: "人工发放金币", tooltip: "后台人工发放金币", type: "coin" },
|
||||
salary_usd_minor: { label: "存量工资 ($)", tooltip: "主播工资余额快照(USD)", type: "money" },
|
||||
salary_transfer_coin: { label: "工资兑换金币", tooltip: "工资兑换成金币的数量", type: "coin" },
|
||||
avg_mic_online_ms: { label: "平均麦上时间", tooltip: "人均麦上时长", type: "duration" },
|
||||
mic_online_ms: { label: "麦上总时长", tooltip: "全部用户麦上时长合计", type: "duration" },
|
||||
arpu_usd_minor: { label: "ARPU ($)", tooltip: "充值 / 日活", type: "money" },
|
||||
arppu_usd_minor: { label: "ARPPU ($)", tooltip: "充值 / 付费用户", type: "money" },
|
||||
paid_conversion_rate: { label: "付费转化率", tooltip: "付费用户 / 日活", type: "ratio" },
|
||||
recharge_conversion_rate: { label: "充值转化率", tooltip: "充值用户 / 日活", type: "ratio" },
|
||||
d1_retention_rate: { label: "次日留存", tooltip: "注册次日仍活跃的比例", type: "ratio" },
|
||||
d7_retention_rate: { label: "7日留存", tooltip: "注册第 7 日仍活跃的比例", type: "ratio" },
|
||||
d30_retention_rate: { label: "30日留存", tooltip: "注册第 30 日仍活跃的比例", type: "ratio" }
|
||||
};
|
||||
|
||||
// 数据明细宽表的列组:与运营的心智分组一致,支持整组折叠。
|
||||
export const METRIC_GROUPS = [
|
||||
{ key: "users", label: "用户", metrics: ["new_users", "active_users", "paid_users", "recharge_users"] },
|
||||
{
|
||||
key: "recharge",
|
||||
label: "充值",
|
||||
metrics: [
|
||||
"recharge_usd_minor",
|
||||
"new_user_recharge_usd_minor",
|
||||
"google_recharge_usd_minor",
|
||||
"mifapay_recharge_usd_minor",
|
||||
"coin_seller_recharge_usd_minor"
|
||||
]
|
||||
},
|
||||
{ key: "coinSeller", label: "币商", metrics: ["coin_seller_stock_coin", "coin_seller_transfer_coin"] },
|
||||
{
|
||||
key: "play",
|
||||
label: "玩法流水",
|
||||
metrics: [
|
||||
"game_turnover",
|
||||
"game_profit_rate",
|
||||
"lucky_gift_turnover",
|
||||
"lucky_gift_profit_rate",
|
||||
"super_lucky_gift_turnover",
|
||||
"super_lucky_gift_profit_rate",
|
||||
"gift_coin_spent"
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "coins",
|
||||
label: "金币",
|
||||
metrics: ["coin_total", "consumed_coin", "output_coin", "consume_output_ratio", "platform_grant_coin", "manual_grant_coin"]
|
||||
},
|
||||
{ key: "salary", label: "工资", metrics: ["salary_usd_minor", "salary_transfer_coin"] },
|
||||
{ key: "engagement", label: "时长", metrics: ["avg_mic_online_ms", "mic_online_ms"] },
|
||||
{
|
||||
key: "quality",
|
||||
label: "质量",
|
||||
metrics: [
|
||||
"arpu_usd_minor",
|
||||
"arppu_usd_minor",
|
||||
"paid_conversion_rate",
|
||||
"recharge_conversion_rate",
|
||||
"d1_retention_rate",
|
||||
"d7_retention_rate",
|
||||
"d30_retention_rate"
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export function metricLabel(key) {
|
||||
return METRIC_DEFS[key]?.label || key;
|
||||
}
|
||||
|
||||
export function metricTooltip(key) {
|
||||
return METRIC_DEFS[key]?.tooltip || "";
|
||||
}
|
||||
|
||||
export function formatMetric(key, value) {
|
||||
const type = METRIC_DEFS[key]?.type;
|
||||
if (type === "money") {
|
||||
return formatMoneyMinor(value);
|
||||
}
|
||||
if (type === "ratio") {
|
||||
return formatRatioPercent(value);
|
||||
}
|
||||
if (type === "coin") {
|
||||
return formatCount(value);
|
||||
}
|
||||
if (type === "duration") {
|
||||
return formatDurationMs(value);
|
||||
}
|
||||
return formatCount(value);
|
||||
}
|
||||
|
||||
export function formatMetricCompact(key, value) {
|
||||
const type = METRIC_DEFS[key]?.type;
|
||||
if (type === "money") {
|
||||
return isBlank(value) ? "--" : `$${formatCompact(Number(value) / 100)}`;
|
||||
}
|
||||
if (type === "ratio") {
|
||||
return formatRatioPercent(value);
|
||||
}
|
||||
if (type === "duration") {
|
||||
return formatDurationMs(value);
|
||||
}
|
||||
return formatCompact(value);
|
||||
}
|
||||
|
||||
// 图表取数值:金额换算成美元,比例换算成百分数,其余原样;缺失返回 null 让 ECharts 断线。
|
||||
export function metricChartValue(key, value) {
|
||||
if (isBlank(value)) {
|
||||
return null;
|
||||
}
|
||||
const type = METRIC_DEFS[key]?.type;
|
||||
if (type === "money") {
|
||||
return Number(value) / 100;
|
||||
}
|
||||
if (type === "ratio") {
|
||||
return Number(value) * 100;
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
// 可线性相加的字段;与后端 databi/metrics.go 的 additiveMetricKeys 对齐,前端聚合只做同一口径。
|
||||
const ADDITIVE_METRIC_KEYS = [
|
||||
"new_users",
|
||||
"active_users",
|
||||
"paid_users",
|
||||
"recharge_users",
|
||||
"recharge_usd_minor",
|
||||
"new_user_recharge_usd_minor",
|
||||
"google_recharge_usd_minor",
|
||||
"mifapay_recharge_usd_minor",
|
||||
"coin_seller_recharge_usd_minor",
|
||||
"coin_seller_stock_coin",
|
||||
"coin_seller_transfer_coin",
|
||||
"game_turnover",
|
||||
"game_payout",
|
||||
"lucky_gift_turnover",
|
||||
"lucky_gift_payout",
|
||||
"super_lucky_gift_turnover",
|
||||
"super_lucky_gift_payout",
|
||||
"gift_coin_spent",
|
||||
"coin_total",
|
||||
"consumed_coin",
|
||||
"output_coin",
|
||||
"platform_grant_coin",
|
||||
"manual_grant_coin",
|
||||
"salary_usd_minor",
|
||||
"salary_transfer_coin",
|
||||
"mic_online_ms",
|
||||
"mic_online_users",
|
||||
"d1_retention_users",
|
||||
"d1_retention_base_users",
|
||||
"d7_retention_users",
|
||||
"d7_retention_base_users",
|
||||
"d30_retention_users",
|
||||
"d30_retention_base_users"
|
||||
];
|
||||
|
||||
function readNumeric(row, key) {
|
||||
const value = row?.[key];
|
||||
if (isBlank(value)) {
|
||||
return null;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
// 余额快照类指标只在"空间"维度可加(跨国家/区域/App),跨天求和没有业务含义;
|
||||
// 跨天聚合时取最后一天的快照(与后端整段口径一致)。
|
||||
const SNAPSHOT_METRIC_KEYS = new Set(["coin_total", "salary_usd_minor"]);
|
||||
|
||||
// aggregateMetricMaps 把若干行(App 合计、区域行、日行)合并成一行:
|
||||
// 可加字段求和(全缺失保持 null → 显示 "--"),比例/均值按分子分母重算,避免"平均百分比"这类错误口径。
|
||||
// 行集跨多天时传 { acrossTime: true },快照字段只累计最后一天的行。
|
||||
export function aggregateMetricMaps(rows, { acrossTime = false } = {}) {
|
||||
const out = {};
|
||||
let latestDay = "";
|
||||
if (acrossTime) {
|
||||
rows.forEach((row) => {
|
||||
const day = String(row?.stat_day || "");
|
||||
if (day > latestDay) {
|
||||
latestDay = day;
|
||||
}
|
||||
});
|
||||
}
|
||||
ADDITIVE_METRIC_KEYS.forEach((key) => {
|
||||
const snapshotOnly = acrossTime && SNAPSHOT_METRIC_KEYS.has(key);
|
||||
let sum = null;
|
||||
rows.forEach((row) => {
|
||||
if (snapshotOnly && String(row?.stat_day || "") !== latestDay) {
|
||||
return;
|
||||
}
|
||||
const value = readNumeric(row, key);
|
||||
if (value !== null) {
|
||||
sum = (sum ?? 0) + value;
|
||||
}
|
||||
});
|
||||
out[key] = sum;
|
||||
});
|
||||
|
||||
const recharge = out.recharge_usd_minor;
|
||||
const active = out.active_users;
|
||||
const paid = out.paid_users;
|
||||
out.arpu_usd_minor = recharge !== null && active ? Math.round(recharge / active) : null;
|
||||
out.arppu_usd_minor = recharge !== null && paid ? Math.round(recharge / paid) : null;
|
||||
out.paid_conversion_rate = paid !== null && active ? paid / active : null;
|
||||
out.recharge_conversion_rate = out.recharge_users !== null && active ? out.recharge_users / active : null;
|
||||
out.game_profit_rate = out.game_turnover ? (out.game_turnover - (out.game_payout ?? 0)) / out.game_turnover : null;
|
||||
out.lucky_gift_profit_rate = out.lucky_gift_turnover
|
||||
? (out.lucky_gift_turnover - (out.lucky_gift_payout ?? 0)) / out.lucky_gift_turnover
|
||||
: null;
|
||||
out.super_lucky_gift_profit_rate = out.super_lucky_gift_turnover
|
||||
? (out.super_lucky_gift_turnover - (out.super_lucky_gift_payout ?? 0)) / out.super_lucky_gift_turnover
|
||||
: null;
|
||||
out.consume_output_ratio = out.consumed_coin !== null && out.output_coin ? out.consumed_coin / out.output_coin : null;
|
||||
out.avg_mic_online_ms = out.mic_online_ms !== null && out.mic_online_users ? Math.round(out.mic_online_ms / out.mic_online_users) : null;
|
||||
["d1", "d7", "d30"].forEach((day) => {
|
||||
const users = out[`${day}_retention_users`];
|
||||
const base = out[`${day}_retention_base_users`];
|
||||
out[`${day}_retention_rate`] = users !== null && base ? users / base : null;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// averageMetricMaps:跨天"平均值"行。可加字段取日均(总量/覆盖天数),余额快照保持末日快照,
|
||||
// 比例/均值类分子分母同除天数后不变,直接沿用汇总行的派生值。
|
||||
export function averageMetricMaps(rows, dayCount) {
|
||||
if (!rows.length || !dayCount || dayCount <= 0) {
|
||||
return null;
|
||||
}
|
||||
const out = aggregateMetricMaps(rows, { acrossTime: true });
|
||||
ADDITIVE_METRIC_KEYS.forEach((key) => {
|
||||
if (SNAPSHOT_METRIC_KEYS.has(key)) {
|
||||
return;
|
||||
}
|
||||
if (out[key] !== null && out[key] !== undefined) {
|
||||
out[key] = out[key] / dayCount;
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// App 品牌色:chips、图表系列、行标识统一取色,保证同一 App 全站同色。
|
||||
// 已知 App 用固定映射(与筛选状态无关);未知 App 按 code 哈希取色,避免"选中子集后颜色漂移"。
|
||||
const APP_COLOR_PALETTE = ["#2563eb", "#0e9488", "#d97706", "#7c3aed", "#db2777", "#059669", "#dc2626", "#4f46e5"];
|
||||
const APP_COLOR_FIXED = { aslan: 2, fami: 3, huwaa: 4, lalu: 0, yumi: 1 };
|
||||
|
||||
export function appColor(appCode) {
|
||||
const code = String(appCode || "").toLowerCase();
|
||||
if (code in APP_COLOR_FIXED) {
|
||||
return APP_COLOR_PALETTE[APP_COLOR_FIXED[code]];
|
||||
}
|
||||
let hash = 0;
|
||||
for (let index = 0; index < code.length; index += 1) {
|
||||
hash = (hash * 31 + code.charCodeAt(index)) >>> 0;
|
||||
}
|
||||
return APP_COLOR_PALETTE[hash % APP_COLOR_PALETTE.length];
|
||||
}
|
||||
206
databi/src/social/state.js
Normal file
206
databi/src/social/state.js
Normal file
@ -0,0 +1,206 @@
|
||||
// 社交 BI v2 的全局筛选状态:日期预设/自定义区间、App 多选、区域多选、趋势粒度,
|
||||
// 并同步到 URL query,保证筛选状态可以直接作为链接分享。
|
||||
|
||||
import { lastDaysRange, thisMonthRange, todayRange } from "../utils/time.js";
|
||||
|
||||
export const ALL = "all";
|
||||
|
||||
export const DATE_PRESETS = [
|
||||
{ key: "today", label: "今日" },
|
||||
{ key: "yesterday", label: "昨日" },
|
||||
{ key: "last7", label: "近 7 日" },
|
||||
{ key: "last30", label: "近 30 日" },
|
||||
{ key: "month", label: "本月" },
|
||||
{ key: "prevMonth", label: "上月" },
|
||||
{ key: "custom", label: "自定义" }
|
||||
];
|
||||
|
||||
export const GRANULARITIES = [
|
||||
{ key: "day", label: "日" },
|
||||
{ key: "week", label: "周" },
|
||||
{ key: "month", label: "月" }
|
||||
];
|
||||
|
||||
export function createDefaultFilters() {
|
||||
return {
|
||||
apps: [ALL],
|
||||
customEnd: "",
|
||||
customStart: "",
|
||||
granularity: "day",
|
||||
preset: "yesterday",
|
||||
regions: [ALL]
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveDateRange(filters, timeZone) {
|
||||
const today = todayRange(timeZone).start;
|
||||
switch (filters.preset) {
|
||||
case "today":
|
||||
return { end: today, start: today };
|
||||
case "yesterday": {
|
||||
const yesterday = shiftDate(today, -1);
|
||||
return { end: yesterday, start: yesterday };
|
||||
}
|
||||
case "last7": {
|
||||
const range = lastDaysRange(7, timeZone);
|
||||
return { end: range.end, start: range.start };
|
||||
}
|
||||
case "last30": {
|
||||
const range = lastDaysRange(30, timeZone);
|
||||
return { end: range.end, start: range.start };
|
||||
}
|
||||
case "month": {
|
||||
const range = thisMonthRange(timeZone);
|
||||
return { end: range.end, start: range.start };
|
||||
}
|
||||
case "prevMonth": {
|
||||
const monthStart = thisMonthRange(timeZone).start;
|
||||
const prevEnd = shiftDate(monthStart, -1);
|
||||
return { end: prevEnd, start: `${prevEnd.slice(0, 7)}-01` };
|
||||
}
|
||||
case "custom": {
|
||||
const start = normalizeDateText(filters.customStart) || shiftDate(today, -7);
|
||||
const end = normalizeDateText(filters.customEnd) || today;
|
||||
return end < start ? { end: start, start: end } : { end, start };
|
||||
}
|
||||
default: {
|
||||
const yesterday = shiftDate(today, -1);
|
||||
return { end: yesterday, start: yesterday };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function rangeDayCount(range) {
|
||||
const start = Date.parse(`${range.start}T00:00:00Z`);
|
||||
const end = Date.parse(`${range.end}T00:00:00Z`);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) {
|
||||
return 1;
|
||||
}
|
||||
return Math.round((end - start) / 86400000) + 1;
|
||||
}
|
||||
|
||||
export function rangeLabel(range) {
|
||||
return range.start === range.end ? range.start : `${range.start} ~ ${range.end}`;
|
||||
}
|
||||
|
||||
export function shiftDate(dateText, days) {
|
||||
const [year, month, day] = String(dateText).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 normalizeDateText(value) {
|
||||
const text = String(value || "").trim();
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : "";
|
||||
}
|
||||
|
||||
function pad2(value) {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
|
||||
// stat_day → 周/月粒度的分桶 key;周取周一为锚点,与运营周报口径一致。
|
||||
export function bucketDay(statDay, granularity) {
|
||||
if (granularity === "month") {
|
||||
return String(statDay).slice(0, 7);
|
||||
}
|
||||
if (granularity === "week") {
|
||||
const time = Date.parse(`${statDay}T00:00:00Z`);
|
||||
if (!Number.isFinite(time)) {
|
||||
return statDay;
|
||||
}
|
||||
const date = new Date(time);
|
||||
const weekday = (date.getUTCDay() + 6) % 7;
|
||||
date.setUTCDate(date.getUTCDate() - weekday);
|
||||
return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())}`;
|
||||
}
|
||||
return statDay;
|
||||
}
|
||||
|
||||
const URL_KEYS = ["preset", "start", "end", "apps", "regions", "view", "granularity"];
|
||||
|
||||
export function readStateFromURL(location = window.location) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const filters = createDefaultFilters();
|
||||
let view = "";
|
||||
if (params.get("preset") && DATE_PRESETS.some((item) => item.key === params.get("preset"))) {
|
||||
filters.preset = params.get("preset");
|
||||
}
|
||||
filters.customStart = normalizeDateText(params.get("start"));
|
||||
filters.customEnd = normalizeDateText(params.get("end"));
|
||||
if (params.get("apps")) {
|
||||
filters.apps = params.get("apps").split(",").map((item) => item.trim().toLowerCase()).filter(Boolean);
|
||||
}
|
||||
if (params.get("regions")) {
|
||||
filters.regions = params.get("regions").split(",").map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
if (params.get("granularity") && GRANULARITIES.some((item) => item.key === params.get("granularity"))) {
|
||||
filters.granularity = params.get("granularity");
|
||||
}
|
||||
if (params.get("view")) {
|
||||
view = params.get("view");
|
||||
}
|
||||
if (!filters.apps.length) {
|
||||
filters.apps = [ALL];
|
||||
}
|
||||
if (!filters.regions.length) {
|
||||
filters.regions = [ALL];
|
||||
}
|
||||
return { filters, view };
|
||||
}
|
||||
|
||||
export function writeStateToURL(filters, view, history = window.history, location = window.location) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
URL_KEYS.forEach((key) => params.delete(key));
|
||||
if (filters.preset !== "yesterday") {
|
||||
params.set("preset", filters.preset);
|
||||
}
|
||||
if (filters.preset === "custom") {
|
||||
if (filters.customStart) {
|
||||
params.set("start", filters.customStart);
|
||||
}
|
||||
if (filters.customEnd) {
|
||||
params.set("end", filters.customEnd);
|
||||
}
|
||||
}
|
||||
if (filters.apps.length && !filters.apps.includes(ALL)) {
|
||||
params.set("apps", filters.apps.join(","));
|
||||
}
|
||||
if (filters.regions.length && !filters.regions.includes(ALL)) {
|
||||
params.set("regions", filters.regions.join(","));
|
||||
}
|
||||
if (filters.granularity !== "day") {
|
||||
params.set("granularity", filters.granularity);
|
||||
}
|
||||
if (view && view !== "overview") {
|
||||
params.set("view", view);
|
||||
}
|
||||
const query = params.toString();
|
||||
history.replaceState(null, "", `${location.pathname}${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
// 区域选择值格式:整 App = `app:<appCode>`,单区域 = `<appCode>:<regionId>`。
|
||||
export function regionSelectionMatches(selection, appCode, regionId) {
|
||||
if (!selection?.length || selection.includes(ALL)) {
|
||||
return true;
|
||||
}
|
||||
return selection.includes(`app:${appCode}`) || selection.includes(`${appCode}:${regionId}`);
|
||||
}
|
||||
|
||||
export function appSelectionMatches(selection, appCode) {
|
||||
if (!selection?.length || selection.includes(ALL)) {
|
||||
return true;
|
||||
}
|
||||
return selection.includes(String(appCode || "").toLowerCase());
|
||||
}
|
||||
|
||||
export function toggleMultiValue(current, value, allValues) {
|
||||
if (value === ALL) {
|
||||
return [ALL];
|
||||
}
|
||||
const withoutAll = current.filter((item) => item !== ALL);
|
||||
const next = withoutAll.includes(value) ? withoutAll.filter((item) => item !== value) : [...withoutAll, value];
|
||||
if (!next.length || (allValues?.length && allValues.every((item) => next.includes(item)))) {
|
||||
return [ALL];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
364
databi/src/social/useSocialBiData.js
Normal file
364
databi/src/social/useSocialBiData.js
Normal file
@ -0,0 +1,364 @@
|
||||
// 社交 BI v2 的数据层:负责 master/overview/funnel/kpi 接口的拉取与派生行,
|
||||
// 视图不直接调 API,只消费这里的产物(原始 snake_case 指标行 + 维度字段)。
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { fetchSocialBiFunnel, fetchSocialBiKpi, fetchSocialBiMaster, fetchSocialBiOverview, fetchSocialBiRequirements } from "../api.js";
|
||||
import { DEFAULT_TIME_ZONE, rangeEndMs, rangeStartMs } from "../utils/time.js";
|
||||
import { ALL, appSelectionMatches, regionSelectionMatches, resolveDateRange } from "./state.js";
|
||||
|
||||
export const SOCIAL_BI_TZ = DEFAULT_TIME_ZONE;
|
||||
export const SOCIAL_BI_FUNNEL_APP_CODES = ["lalu", "huwaa", "fami"];
|
||||
|
||||
export function useSocialBiData(filters, { includeFunnel = false } = {}) {
|
||||
const [master, setMaster] = useState(null);
|
||||
// 数据请求等 master 结果落地后再发:否则会先用兜底 App 列表打一轮,master 到达后立刻重打一轮(整轮浪费)。
|
||||
const [isMasterSettled, setIsMasterSettled] = useState(false);
|
||||
const [overview, setOverview] = useState(null);
|
||||
const [funnel, setFunnel] = useState(null);
|
||||
const [kpi, setKpi] = useState(null);
|
||||
const [requirements, setRequirements] = useState(null);
|
||||
const [requirementsError, setRequirementsError] = useState("");
|
||||
const [isRequirementsLoading, setIsRequirementsLoading] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errors, setErrors] = useState([]);
|
||||
const [refreshToken, setRefreshToken] = useState(0);
|
||||
const requestSeq = useRef(0);
|
||||
const requirementsSeq = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
fetchSocialBiMaster()
|
||||
.then((data) => {
|
||||
if (!ignore && data) {
|
||||
setMaster(data);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!ignore) {
|
||||
setMaster(null);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ignore) {
|
||||
setIsMasterSettled(true);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// range 只依赖日期相关筛选:切换粒度/区域/App 多选不应触发接口重拉(那些是纯前端派生)。
|
||||
const { customEnd, customStart, preset } = filters;
|
||||
const range = useMemo(
|
||||
() => resolveDateRange({ customEnd, customStart, preset }, SOCIAL_BI_TZ),
|
||||
[customEnd, customStart, preset]
|
||||
);
|
||||
const appCodes = useMemo(() => {
|
||||
const available = (master?.apps || []).map((app) => app.app_code);
|
||||
if (!filters.apps.length || filters.apps.includes(ALL)) {
|
||||
return available;
|
||||
}
|
||||
return filters.apps.filter((appCode) => !available.length || available.includes(appCode));
|
||||
}, [filters.apps, master]);
|
||||
const funnelAppCodes = useMemo(() => {
|
||||
const available = (master?.apps || []).map((app) => app.app_code).filter((appCode) => SOCIAL_BI_FUNNEL_APP_CODES.includes(appCode));
|
||||
if (!filters.apps.length || filters.apps.includes(ALL)) {
|
||||
return available;
|
||||
}
|
||||
const selected = filters.apps.filter((appCode) => available.includes(appCode));
|
||||
return selected.length ? selected : available;
|
||||
}, [filters.apps, master]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMasterSettled) {
|
||||
return;
|
||||
}
|
||||
const sequence = ++requestSeq.current;
|
||||
const startMs = rangeStartMs(range, SOCIAL_BI_TZ);
|
||||
const endMs = rangeEndMs(range, SOCIAL_BI_TZ);
|
||||
setIsLoading(true);
|
||||
// 漏斗会执行 cohort 聚合,只在用户进入漏斗视图时请求;其他视图不能为不可见数据承担查询成本或错误提示。
|
||||
const funnelRequest = includeFunnel
|
||||
? fetchSocialBiFunnel({ appCodes: funnelAppCodes, endMs, startMs, statTz: SOCIAL_BI_TZ })
|
||||
: Promise.resolve(null);
|
||||
Promise.allSettled([
|
||||
fetchSocialBiOverview({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }),
|
||||
funnelRequest,
|
||||
fetchSocialBiKpi({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ })
|
||||
]).then(([overviewResult, funnelResult, kpiResult]) => {
|
||||
if (sequence !== requestSeq.current) {
|
||||
return;
|
||||
}
|
||||
const nextErrors = [];
|
||||
if (overviewResult.status === "fulfilled") {
|
||||
setOverview(overviewResult.value);
|
||||
(overviewResult.value?.apps || []).forEach((app) => {
|
||||
if (app.error) {
|
||||
nextErrors.push(`${app.app_name || app.app_code}: ${app.error}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setOverview(null);
|
||||
nextErrors.push(`统计数据加载失败: ${overviewResult.reason?.message || "未知错误"}`);
|
||||
}
|
||||
if (!includeFunnel) {
|
||||
setFunnel(null);
|
||||
} else if (funnelResult.status === "fulfilled") {
|
||||
setFunnel(funnelResult.value);
|
||||
(funnelResult.value?.apps || []).forEach((app) => {
|
||||
if (app.error) {
|
||||
nextErrors.push(`${app.app_name || app.app_code}: ${app.error}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setFunnel(null);
|
||||
nextErrors.push(`埋点漏斗加载失败: ${funnelResult.reason?.message || "未知错误"}`);
|
||||
}
|
||||
if (kpiResult.status === "fulfilled") {
|
||||
setKpi(kpiResult.value);
|
||||
Object.entries(kpiResult.value?.app_errors || {}).forEach(([appCode, message]) => {
|
||||
nextErrors.push(`${appCode}: ${message}`);
|
||||
});
|
||||
} else {
|
||||
setKpi(null);
|
||||
nextErrors.push(`运营充值数据加载失败: ${kpiResult.reason?.message || "未知错误"}`);
|
||||
}
|
||||
setErrors(nextErrors);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [appCodes, funnelAppCodes, includeFunnel, isMasterSettled, range, refreshToken]);
|
||||
|
||||
const loadRequirements = useCallback(
|
||||
({ newUserType, payerType, section, userRole } = {}) => {
|
||||
if (!isMasterSettled) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
const sequence = ++requirementsSeq.current;
|
||||
const startMs = rangeStartMs(range, SOCIAL_BI_TZ);
|
||||
const endMs = rangeEndMs(range, SOCIAL_BI_TZ);
|
||||
const requestScopes = buildRequirementRequestScopes(appCodes, filters.regions);
|
||||
setIsRequirementsLoading(true);
|
||||
setRequirementsError("");
|
||||
setRequirements(null);
|
||||
if (!requestScopes.length) {
|
||||
const emptyResult = { access: overview?.access || null, apps: [] };
|
||||
setRequirements(emptyResult);
|
||||
setIsRequirementsLoading(false);
|
||||
return Promise.resolve(emptyResult);
|
||||
}
|
||||
// 数据需求按表格页局部 section/角色/付费筛选请求;全局 App/日期仍复用 Social BI 顶栏口径。
|
||||
return Promise.all(requestScopes.map((scope) => fetchSocialBiRequirements({
|
||||
...scope,
|
||||
endMs,
|
||||
newUserType,
|
||||
payerType,
|
||||
section,
|
||||
startMs,
|
||||
statTz: SOCIAL_BI_TZ,
|
||||
userRole
|
||||
})))
|
||||
.then((results) => {
|
||||
if (sequence !== requirementsSeq.current) {
|
||||
return null;
|
||||
}
|
||||
const data = mergeRequirementResponses(results);
|
||||
setRequirements(data);
|
||||
return data;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (sequence === requirementsSeq.current) {
|
||||
setRequirements(null);
|
||||
setRequirementsError(error?.message || "数据需求加载失败");
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
if (sequence === requirementsSeq.current) {
|
||||
setIsRequirementsLoading(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
[appCodes, filters.regions, isMasterSettled, overview?.access, range]
|
||||
);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setRefreshToken((current) => current + 1);
|
||||
}, []);
|
||||
|
||||
const derived = useMemo(() => deriveRows(overview, filters, master), [filters, master, overview]);
|
||||
|
||||
return {
|
||||
appCodes,
|
||||
derived,
|
||||
errors,
|
||||
funnel,
|
||||
funnelAppCodes,
|
||||
isLoading,
|
||||
isRequirementsLoading,
|
||||
kpi,
|
||||
loadRequirements,
|
||||
master,
|
||||
overview,
|
||||
range,
|
||||
refreshToken,
|
||||
requirements,
|
||||
requirementsError,
|
||||
refresh
|
||||
};
|
||||
}
|
||||
|
||||
// deriveRows 输出四类行,全部带维度字段 + 原始指标字段:
|
||||
// appTotals/appDaily(App 粒度)、regionTotals/regionDaily(大区粒度,已按顶栏区域选择过滤)、
|
||||
// countryTotals(国家粒度,已按国家→区域目录归一 region_id 并跟随区域筛选)。restricted=true 表示该 App 已被数据范围裁剪。
|
||||
function deriveRows(overview, filters, master) {
|
||||
const appTotals = [];
|
||||
const appDaily = [];
|
||||
const regionTotals = [];
|
||||
const regionDaily = [];
|
||||
const countryTotals = [];
|
||||
const appErrors = [];
|
||||
const regionByCountry = buildRegionByCountryIndex(master);
|
||||
|
||||
(overview?.apps || []).forEach((app) => {
|
||||
if (!appSelectionMatches(filters.apps, app.app_code)) {
|
||||
return;
|
||||
}
|
||||
const context = {
|
||||
app_code: app.app_code,
|
||||
app_name: app.app_name || app.app_code,
|
||||
kind: app.kind,
|
||||
restricted: Boolean(app.restricted)
|
||||
};
|
||||
if (app.error) {
|
||||
appErrors.push({ ...context, error: app.error });
|
||||
return;
|
||||
}
|
||||
appTotals.push({ ...context, ...(app.total || {}) });
|
||||
(app.daily_series || []).forEach((row) => {
|
||||
appDaily.push({ ...context, ...row, stat_day: row.stat_day || row.label });
|
||||
});
|
||||
(app.region_breakdown || []).forEach((row) => {
|
||||
if (!regionSelectionMatches(filters.regions, app.app_code, Number(row.region_id ?? 0))) {
|
||||
return;
|
||||
}
|
||||
regionTotals.push({
|
||||
...context,
|
||||
...row,
|
||||
region_id: Number(row.region_id ?? 0),
|
||||
region_name: row.region_name || row.region_code || `区域 ${row.region_id}`
|
||||
});
|
||||
});
|
||||
(app.daily_region_breakdown || []).forEach((row) => {
|
||||
if (!regionSelectionMatches(filters.regions, app.app_code, Number(row.region_id ?? 0))) {
|
||||
return;
|
||||
}
|
||||
regionDaily.push({
|
||||
...context,
|
||||
...row,
|
||||
region_id: Number(row.region_id ?? 0),
|
||||
region_name: row.region_name || row.region_code || `区域 ${row.region_id}`,
|
||||
stat_day: row.stat_day || row.label
|
||||
});
|
||||
});
|
||||
(app.country_breakdown || []).forEach((row) => {
|
||||
const countryCode = row.country_code || String(row.country_id || "");
|
||||
// legacy 国家行不带 region_id,用区域目录(countries 列表)归一,保证下钻与区域筛选口径一致。
|
||||
let regionId = Number(row.region_id ?? 0);
|
||||
let regionName = row.region_name || "";
|
||||
if (!regionId) {
|
||||
const region = regionByCountry.get(`${app.app_code}:${String(countryCode).toUpperCase()}`);
|
||||
if (region) {
|
||||
regionId = region.region_id;
|
||||
regionName = region.region_name || region.region_code || regionName;
|
||||
}
|
||||
}
|
||||
if (!regionSelectionMatches(filters.regions, app.app_code, regionId)) {
|
||||
return;
|
||||
}
|
||||
countryTotals.push({
|
||||
...context,
|
||||
...row,
|
||||
country_code: countryCode,
|
||||
country_name: row.country_name || row.country || row.country_code || `国家 ${row.country_id}`,
|
||||
region_id: regionId,
|
||||
region_name: regionName
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 顶栏选了区域时,App 合计/日行切换成所选区域行的聚合视角由各视图用 regionScoped 判断。
|
||||
const regionScoped = Boolean(filters.regions.length && !filters.regions.includes(ALL));
|
||||
return { appDaily, appErrors, appTotals, countryTotals, regionDaily, regionScoped, regionTotals };
|
||||
}
|
||||
|
||||
function buildRegionByCountryIndex(master) {
|
||||
const index = new Map();
|
||||
(master?.regions || []).forEach((region) => {
|
||||
(region.countries || []).forEach((countryCode) => {
|
||||
const key = `${region.app_code}:${String(countryCode).toUpperCase()}`;
|
||||
if (!index.has(key)) {
|
||||
index.set(key, region);
|
||||
}
|
||||
});
|
||||
});
|
||||
return index;
|
||||
}
|
||||
|
||||
function buildRequirementRequestScopes(appCodes, regions) {
|
||||
const selectedApps = new Set((appCodes || []).filter(Boolean));
|
||||
if (!regions?.length || regions.includes(ALL)) {
|
||||
return [{ appCodes: [...selectedApps] }];
|
||||
}
|
||||
const byApp = new Map();
|
||||
regions.forEach((selection) => {
|
||||
const value = String(selection || "").trim();
|
||||
if (!value || value === ALL) {
|
||||
return;
|
||||
}
|
||||
if (value.startsWith("app:")) {
|
||||
const appCode = value.slice(4).trim().toLowerCase();
|
||||
if (appCode) {
|
||||
byApp.set(appCode, null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const [rawAppCode, rawRegionID] = value.split(":");
|
||||
const appCode = String(rawAppCode || "").trim().toLowerCase();
|
||||
const regionID = Number(rawRegionID);
|
||||
if (!appCode || !Number.isFinite(regionID) || regionID <= 0 || byApp.get(appCode) === null) {
|
||||
return;
|
||||
}
|
||||
if (!byApp.has(appCode)) {
|
||||
byApp.set(appCode, new Set());
|
||||
}
|
||||
byApp.get(appCode).add(regionID);
|
||||
});
|
||||
const scopes = [];
|
||||
byApp.forEach((regionIDs, appCode) => {
|
||||
if (selectedApps.size && !selectedApps.has(appCode)) {
|
||||
return;
|
||||
}
|
||||
if (regionIDs === null) {
|
||||
scopes.push({ appCodes: [appCode] });
|
||||
return;
|
||||
}
|
||||
const ids = [...regionIDs].sort((left, right) => left - right);
|
||||
if (ids.length === 1) {
|
||||
scopes.push({ appCodes: [appCode], regionId: ids[0] });
|
||||
} else if (ids.length > 1) {
|
||||
scopes.push({ appCodes: [appCode], regionIds: ids });
|
||||
}
|
||||
});
|
||||
return scopes;
|
||||
}
|
||||
|
||||
function mergeRequirementResponses(results) {
|
||||
const merged = { access: results.find((item) => item?.access)?.access || null, apps: [] };
|
||||
results.forEach((item) => {
|
||||
if (Array.isArray(item?.apps)) {
|
||||
merged.apps.push(...item.apps);
|
||||
}
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
998
databi/src/social/views/DataTableView.jsx
Normal file
998
databi/src/social/views/DataTableView.jsx
Normal file
@ -0,0 +1,998 @@
|
||||
// DataTableView:数据明细视图 —— Excel 式全字段宽表(维度切换 / 列组开关 / 指标排序 /
|
||||
// 首列冻结 / CSV 导出)。行数据保持后端原始 snake_case 口径,渲染时经 metrics.js 统一格式化。
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Button from "@mui/material/Button";
|
||||
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
|
||||
import { useSocialBi } from "../SocialBiApp.jsx";
|
||||
import {
|
||||
METRIC_GROUPS,
|
||||
aggregateMetricMaps,
|
||||
averageMetricMaps,
|
||||
formatMetric,
|
||||
metricChartValue,
|
||||
metricLabel,
|
||||
metricTooltip
|
||||
} from "../metrics.js";
|
||||
import { downloadCsv, formatCount, formatDurationMs, formatMoneyMinor, isBlank } from "../format.js";
|
||||
import { rangeLabel } from "../state.js";
|
||||
import "./data-table-view.css";
|
||||
|
||||
const GROUPS_STORAGE_KEY = "hyapp-admin.sbi-table-groups";
|
||||
const MAX_RENDER_ROWS = 400;
|
||||
const SKELETON_WIDTHS = ["42%", "100%", "96%", "91%", "86%", "81%"];
|
||||
const EMPTY_ROWS = [];
|
||||
|
||||
// 维度列定义:value 取行上的展示文本,totalText 是合计行占位(日期列"汇总",其余"全部")。
|
||||
const DIM_COLUMNS = {
|
||||
app: { label: "App", totalText: "全部", value: (row) => row.app_name || row.app_code || "--" },
|
||||
country: { label: "国家", totalText: "全部", value: (row) => row.country_name || row.country_code || "--" },
|
||||
day: { label: "日期", totalText: "汇总", value: (row) => row.stat_day || "--" },
|
||||
region: { label: "区域", totalText: "全部", value: (row) => row.region_name || row.region_code || "--" }
|
||||
};
|
||||
|
||||
// 五种明细维度:key 与 derived 的行集字段一一对应。
|
||||
const DIMENSIONS = [
|
||||
{ csvName: "app-daily", dims: ["day", "app"], hasDay: true, key: "appDaily", label: "App×日" },
|
||||
{ csvName: "app-total", dims: ["app"], hasDay: false, key: "appTotals", label: "App 合计" },
|
||||
{ csvName: "region-daily", dims: ["day", "app", "region"], hasDay: true, key: "regionDaily", label: "区域×日" },
|
||||
{ csvName: "region-total", dims: ["app", "region"], hasDay: false, key: "regionTotals", label: "区域合计" },
|
||||
{ csvName: "country", dims: ["app", "country"], hasDay: false, key: "countryTotals", label: "国家" }
|
||||
];
|
||||
|
||||
const TABLE_MODES = [
|
||||
{ key: "wide", label: "运营宽表" },
|
||||
{ key: "requirements", label: "数据需求" }
|
||||
];
|
||||
|
||||
const REQUIREMENT_SECTIONS = [
|
||||
{
|
||||
csvName: "p0-new-users",
|
||||
fallbackColumns: [
|
||||
{ key: "app_first_open_users", label: "下载APP人数", type: "count" },
|
||||
{ key: "new_users", label: "新增注册", type: "count" },
|
||||
{ key: "profile_complete_users", label: "完善资料", type: "count" },
|
||||
{ key: "first_room_join_users", label: "首次进房", type: "count" }
|
||||
],
|
||||
key: "new_users",
|
||||
label: "新用户"
|
||||
},
|
||||
{
|
||||
csvName: "p1-revenue",
|
||||
fallbackColumns: [
|
||||
{ key: "recharge_usd_minor", label: "充值", type: "money_minor" },
|
||||
{ key: "recharge_users", label: "充值用户", type: "count" },
|
||||
{ key: "first_recharge_users", label: "首充用户", type: "count" },
|
||||
{ key: "repeat_recharge_users", label: "复购用户", type: "count" },
|
||||
{ key: "arppu_usd_minor", label: "ARPPU", type: "money_minor" }
|
||||
],
|
||||
key: "revenue",
|
||||
label: "营收",
|
||||
newUserFilter: true,
|
||||
payerFilter: true,
|
||||
roleFilter: true
|
||||
},
|
||||
{
|
||||
csvName: "p2-retention",
|
||||
fallbackColumns: [
|
||||
{ key: "active_d1_active_base_users", label: "活跃用户基数", type: "count" },
|
||||
{ key: "active_d1_active_users", label: "次留人数", type: "count" },
|
||||
{ key: "active_d1_active_rate", label: "用户次留", type: "ratio" },
|
||||
{ key: "active_d7_active_rate", label: "用户7日留", type: "ratio" },
|
||||
{ key: "active_d30_active_rate", label: "用户30日留", type: "ratio" }
|
||||
],
|
||||
key: "retention",
|
||||
label: "留存",
|
||||
newUserFilter: true,
|
||||
payerFilter: true,
|
||||
roleFilter: true
|
||||
},
|
||||
{
|
||||
csvName: "p2-active",
|
||||
fallbackColumns: [
|
||||
{ key: "active_users", label: "活跃用户", type: "count" },
|
||||
{ key: "room_join_users", label: "进房用户", type: "count" },
|
||||
{ key: "mic_users", label: "上麦用户", type: "count" },
|
||||
{ key: "message_users", label: "发言用户", type: "count" },
|
||||
{ key: "avg_session_ms", label: "人均在线", type: "duration_ms" }
|
||||
],
|
||||
key: "active",
|
||||
label: "活跃",
|
||||
newUserFilter: true,
|
||||
payerFilter: true,
|
||||
roleFilter: true
|
||||
},
|
||||
{
|
||||
csvName: "p4-gift",
|
||||
fallbackColumns: [
|
||||
{ key: "gift_panel_open_users", label: "礼物面板用户", type: "count" },
|
||||
{ key: "gift_senders", label: "送礼用户", type: "count" },
|
||||
{ key: "gift_coin_spent", label: "礼物流水", type: "coin" },
|
||||
{ key: "gift_paid_users", label: "付费送礼用户", type: "count" }
|
||||
],
|
||||
key: "gift",
|
||||
label: "送礼",
|
||||
newUserFilter: true,
|
||||
roleFilter: true
|
||||
},
|
||||
{
|
||||
csvName: "p5-game",
|
||||
fallbackColumns: [
|
||||
{ key: "probability_game_panel_open_users", label: "概率游戏面板用户", type: "count" },
|
||||
{ key: "game_players", label: "押注用户", type: "count" },
|
||||
{ key: "game_turnover", label: "游戏流水", type: "coin" },
|
||||
{ key: "game_profit", label: "游戏利润", type: "coin" },
|
||||
{ key: "game_profit_rate", label: "游戏利润率", type: "ratio" }
|
||||
],
|
||||
key: "game",
|
||||
label: "游戏",
|
||||
newUserFilter: true,
|
||||
roleFilter: true
|
||||
}
|
||||
];
|
||||
|
||||
const USER_ROLE_OPTIONS = [
|
||||
{ label: "全部角色", value: "all" },
|
||||
{ label: "主播", value: "host" },
|
||||
{ label: "用户", value: "user" }
|
||||
];
|
||||
|
||||
const PAYER_TYPE_OPTIONS = [
|
||||
{ label: "全部付费", value: "all" },
|
||||
{ label: "已付费", value: "paid" },
|
||||
{ label: "未付费", value: "unpaid" }
|
||||
];
|
||||
|
||||
// 新用户 = 当天注册成功的用户;非新用户 = 当天未注册(历史注册)的用户。
|
||||
const NEW_USER_OPTIONS = [
|
||||
{ label: "全部用户", value: "all" },
|
||||
{ label: "新用户", value: "new" },
|
||||
{ label: "非新用户", value: "old" }
|
||||
];
|
||||
|
||||
// P5 按游戏明细的兜底列(正常由后端 game_columns 下发)。
|
||||
const FALLBACK_GAME_COLUMNS = [
|
||||
{ key: "game_bet_users", label: "押注用户数", type: "count" },
|
||||
{ key: "game_d1_retention_rate", label: "游戏次留", type: "ratio" },
|
||||
{ key: "game_arppu_coin", label: "ARPPU(金币)", type: "coin" },
|
||||
{ key: "game_bet_coin", label: "押注金币", type: "coin" }
|
||||
];
|
||||
|
||||
const GAME_SOURCE_LABELS = { self: "自研", vendor: "厂商" };
|
||||
|
||||
function readClosedGroups() {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(GROUPS_STORAGE_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
if (Array.isArray(parsed)) {
|
||||
const validKeys = new Set(METRIC_GROUPS.map((group) => group.key));
|
||||
return parsed.filter((key) => validKeys.has(key));
|
||||
}
|
||||
} catch {
|
||||
// localStorage 不可用或内容损坏时回退为全部列组展开。
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function writeClosedGroups(keys) {
|
||||
try {
|
||||
window.localStorage.setItem(GROUPS_STORAGE_KEY, JSON.stringify(keys));
|
||||
} catch {
|
||||
// 存储失败只影响持久化,不影响本次会话内的开关状态。
|
||||
}
|
||||
}
|
||||
|
||||
function metricSortValue(row, key) {
|
||||
const value = row?.[key];
|
||||
if (isBlank(value)) {
|
||||
return null;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
// null 视为最小:desc 时缺失口径沉底,asc 时排最前。
|
||||
function compareMetric(a, b, key, dir) {
|
||||
const left = metricSortValue(a, key);
|
||||
const right = metricSortValue(b, key);
|
||||
const leftValue = left === null ? Number.NEGATIVE_INFINITY : left;
|
||||
const rightValue = right === null ? Number.NEGATIVE_INFINITY : right;
|
||||
if (leftValue === rightValue) {
|
||||
return 0;
|
||||
}
|
||||
return dir === "asc" ? leftValue - rightValue : rightValue - leftValue;
|
||||
}
|
||||
|
||||
function rowIdentity(row, dimKey, index) {
|
||||
const parts = [row.app_code, row.region_id, row.country_code, row.stat_day].filter(
|
||||
(part) => part !== undefined && part !== null && part !== ""
|
||||
);
|
||||
return parts.length ? `${dimKey}:${parts.join("|")}` : `${dimKey}:${index}`;
|
||||
}
|
||||
|
||||
function requirementSectionConfig(key) {
|
||||
return REQUIREMENT_SECTIONS.find((section) => section.key === key) || REQUIREMENT_SECTIONS[0];
|
||||
}
|
||||
|
||||
function asArray(value) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function sectionMatches(item, sectionKey) {
|
||||
return String(item?.section ?? item?.key ?? item?.code ?? "") === sectionKey;
|
||||
}
|
||||
|
||||
function normalizeRequirementColumn(column) {
|
||||
const key = String(column?.key ?? column?.field ?? column?.name ?? "").trim();
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
key,
|
||||
label: column.label || column.title || column.name || key,
|
||||
tooltip: column.tooltip || column.description || column.remark || "",
|
||||
type: column.type || column.value_type || column.format || ""
|
||||
};
|
||||
}
|
||||
|
||||
function requirementAppContext(app) {
|
||||
return {
|
||||
app_code: app?.app_code || app?.appCode || "",
|
||||
app_name: app?.app_name || app?.appName || app?.app_code || app?.appCode || "",
|
||||
kind: app?.kind || "",
|
||||
restricted: Boolean(app?.restricted)
|
||||
};
|
||||
}
|
||||
|
||||
function flattenRequirementRow(row, context = {}) {
|
||||
if (!row || typeof row !== "object") {
|
||||
return null;
|
||||
}
|
||||
const metrics = row.metrics && typeof row.metrics === "object" && !Array.isArray(row.metrics) ? row.metrics : {};
|
||||
const flattened = { ...context, ...row, ...metrics };
|
||||
delete flattened.metrics;
|
||||
return flattened;
|
||||
}
|
||||
|
||||
function normalizedRequirementColumns(sectionItem, fallbackColumns) {
|
||||
const columns = asArray(sectionItem?.columns || sectionItem?.fields || sectionItem?.metrics).map(normalizeRequirementColumn).filter(Boolean);
|
||||
return columns.length ? columns : fallbackColumns;
|
||||
}
|
||||
|
||||
// 日行统一按日期倒序展示(最新在最上),不依赖后端返回顺序;无日期的行(如"汇总"占位)保持原相对位置沉底。
|
||||
function sortRequirementDailyRows(rows) {
|
||||
return [...rows].sort((a, b) => {
|
||||
const left = String(a?.stat_day || "");
|
||||
const right = String(b?.stat_day || "");
|
||||
if (left === right) {
|
||||
return 0;
|
||||
}
|
||||
return left < right ? 1 : -1;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRequirementPayload(payload, sectionKey) {
|
||||
const fallbackColumns = requirementSectionConfig(sectionKey).fallbackColumns;
|
||||
const appSections = asArray(payload?.apps).flatMap((app) => {
|
||||
const context = requirementAppContext(app);
|
||||
return asArray(app?.sections)
|
||||
.filter((section) => sectionMatches(section, sectionKey))
|
||||
.map((section) => ({ context, section }));
|
||||
});
|
||||
if (appSections.length) {
|
||||
const firstSection = appSections.find(({ section }) => asArray(section?.columns || section?.fields || section?.metrics).length)?.section
|
||||
|| appSections[0].section;
|
||||
const columns = normalizedRequirementColumns(firstSection, fallbackColumns);
|
||||
if (appSections.length === 1) {
|
||||
const { context, section } = appSections[0];
|
||||
return {
|
||||
average: flattenRequirementRow(section.average, context),
|
||||
columns,
|
||||
rows: sortRequirementDailyRows(
|
||||
asArray(section.daily_rows || section.dailyRows || section.daily_series || section.rows || section.items)
|
||||
.map((row) => flattenRequirementRow(row, context))
|
||||
.filter(Boolean)
|
||||
),
|
||||
total: flattenRequirementRow(section.total || section.totals || section.summary, context)
|
||||
};
|
||||
}
|
||||
const rows = appSections.flatMap(({ context, section }) => {
|
||||
const total = flattenRequirementRow(section.total || section.totals || section.summary, { ...context, stat_day: "汇总" });
|
||||
const average = flattenRequirementRow(section.average, { ...context, stat_day: "平均值" });
|
||||
const dailyRows = sortRequirementDailyRows(
|
||||
asArray(section.daily_rows || section.dailyRows || section.daily_series || section.rows || section.items)
|
||||
.map((row) => flattenRequirementRow(row, context))
|
||||
.filter(Boolean)
|
||||
);
|
||||
return [total, average, ...dailyRows].filter(Boolean);
|
||||
});
|
||||
return { average: null, columns, rows, total: null };
|
||||
}
|
||||
|
||||
const sectionItem = asArray(payload?.sections).find((item) => sectionMatches(item, sectionKey))
|
||||
|| asArray(payload?.items).find((item) => sectionMatches(item, sectionKey) && (item.columns || item.daily_rows || item.dailyRows || item.total || item.totals))
|
||||
|| payload
|
||||
|| {};
|
||||
const columns = normalizedRequirementColumns(sectionItem, fallbackColumns);
|
||||
const rows = sortRequirementDailyRows(
|
||||
asArray(sectionItem.daily_rows || sectionItem.dailyRows || sectionItem.daily_series || sectionItem.rows || sectionItem.items)
|
||||
.map((row) => flattenRequirementRow(row))
|
||||
.filter(Boolean)
|
||||
);
|
||||
const total = flattenRequirementRow(sectionItem.total || sectionItem.totals || sectionItem.summary);
|
||||
return {
|
||||
average: flattenRequirementRow(sectionItem.average),
|
||||
columns,
|
||||
rows,
|
||||
total
|
||||
};
|
||||
}
|
||||
|
||||
// P5 按游戏明细:每个游戏输出 [汇总行 + 日行(倒序)],行带 app/game 上下文;列优先取后端 game_columns。
|
||||
// 该明细不受用户身份/付费身份/是否新用户筛选影响(后端口径限制)。
|
||||
function normalizeRequirementGamePayload(payload) {
|
||||
const appSections = asArray(payload?.apps).flatMap((app) => {
|
||||
const context = requirementAppContext(app);
|
||||
return asArray(app?.sections)
|
||||
.filter((section) => sectionMatches(section, "game"))
|
||||
.map((section) => ({ context, section }));
|
||||
});
|
||||
const sections = appSections.length
|
||||
? appSections
|
||||
: asArray(payload?.sections)
|
||||
.filter((section) => sectionMatches(section, "game"))
|
||||
.map((section) => ({ context: {}, section }));
|
||||
const columns =
|
||||
sections
|
||||
.map(({ section }) => asArray(section.game_columns).map(normalizeRequirementColumn).filter(Boolean))
|
||||
.find((cols) => cols.length) || FALLBACK_GAME_COLUMNS;
|
||||
const rows = sections.flatMap(({ context, section }) =>
|
||||
asArray(section.games).flatMap((game) => {
|
||||
const gameContext = {
|
||||
...context,
|
||||
game_key: game.game_key || "",
|
||||
game_name: game.game_name || game.game_key || "--",
|
||||
game_source: game.game_source || ""
|
||||
};
|
||||
const total = flattenRequirementRow(game.total, { ...gameContext, stat_day: "汇总" });
|
||||
const daily = sortRequirementDailyRows(
|
||||
asArray(game.daily_series)
|
||||
.map((row) => flattenRequirementRow(row, gameContext))
|
||||
.filter(Boolean)
|
||||
);
|
||||
return total ? [total, ...daily] : daily;
|
||||
})
|
||||
);
|
||||
return { columns, rows };
|
||||
}
|
||||
|
||||
function requirementGameLabel(row) {
|
||||
const source = GAME_SOURCE_LABELS[String(row?.game_source || "")] || "";
|
||||
const name = row?.game_name || row?.game_key || "--";
|
||||
return source ? `${source}·${name}` : name;
|
||||
}
|
||||
|
||||
function inferRequirementType(column) {
|
||||
const type = String(column.type || "").toLowerCase();
|
||||
const key = String(column.key || "").toLowerCase();
|
||||
if (type.includes("money") || key.endsWith("_usd_minor")) {
|
||||
return "money_minor";
|
||||
}
|
||||
if (type.includes("ratio") || type.includes("rate") || type.includes("percent") || key.endsWith("_rate")) {
|
||||
return "ratio";
|
||||
}
|
||||
if (type.includes("duration") || key.endsWith("_ms")) {
|
||||
return "duration_ms";
|
||||
}
|
||||
if (type.includes("coin") || key.endsWith("_coin") || key.endsWith("_turnover") || key.endsWith("_profit")) {
|
||||
return "coin";
|
||||
}
|
||||
return type || "number";
|
||||
}
|
||||
|
||||
// 文档展示规则:百分比固定保留 2 位小数(截断补零);整数展示整数;非整数展示 2 位小数(超出截断)。
|
||||
function truncateTwoDecimals(value, { pad = false } = {}) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return "--";
|
||||
}
|
||||
const truncated = Math.trunc(numeric * 100) / 100;
|
||||
return truncated.toLocaleString("en-US", {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: pad ? 2 : 0
|
||||
});
|
||||
}
|
||||
|
||||
function formatRequirementRatio(value) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return "--";
|
||||
}
|
||||
return `${truncateTwoDecimals(numeric * 100, { pad: true })}%`;
|
||||
}
|
||||
|
||||
function formatRequirementValue(column, value) {
|
||||
if (isBlank(value)) {
|
||||
return "--";
|
||||
}
|
||||
const type = inferRequirementType(column);
|
||||
if (type === "money_minor") {
|
||||
const dollars = Number(value) / 100;
|
||||
if (!Number.isFinite(dollars)) {
|
||||
return "--";
|
||||
}
|
||||
return Number.isInteger(dollars) ? formatMoneyMinor(value) : `$${truncateTwoDecimals(dollars, { pad: true })}`;
|
||||
}
|
||||
if (type === "ratio") {
|
||||
return formatRequirementRatio(value);
|
||||
}
|
||||
if (type === "duration_ms") {
|
||||
return formatDurationMs(value);
|
||||
}
|
||||
if (type === "coin" || type === "count" || type === "integer") {
|
||||
const numeric = Number(value);
|
||||
// 平均值行的计数/金币可能出现小数,按"非整数展示2位小数(截断)"处理。
|
||||
if (Number.isFinite(numeric) && !Number.isInteger(numeric)) {
|
||||
return truncateTwoDecimals(numeric, { pad: true });
|
||||
}
|
||||
return formatCount(value);
|
||||
}
|
||||
const numeric = Number(value);
|
||||
if (Number.isFinite(numeric)) {
|
||||
return Number.isInteger(numeric) ? formatCount(numeric) : truncateTwoDecimals(numeric, { pad: true });
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function csvRequirementValue(column, value) {
|
||||
if (isBlank(value)) {
|
||||
return "";
|
||||
}
|
||||
const type = inferRequirementType(column);
|
||||
if (type === "money_minor") {
|
||||
return Number(value) / 100;
|
||||
}
|
||||
if (type === "ratio") {
|
||||
return Number(value) * 100;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requirementRowIdentity(row, sectionKey, index) {
|
||||
const parts = [row.app_code, row.stat_day, row.region_id].filter((part) => part !== undefined && part !== null && part !== "");
|
||||
return parts.length ? `${sectionKey}:${parts.join("|")}` : `${sectionKey}:${index}`;
|
||||
}
|
||||
|
||||
function requirementAppName(row) {
|
||||
return row?.app_name || row?.app_code || "全部";
|
||||
}
|
||||
|
||||
export function DataTableView() {
|
||||
const {
|
||||
derived,
|
||||
isLoading,
|
||||
isRequirementsLoading,
|
||||
loadRequirements,
|
||||
range,
|
||||
refreshToken,
|
||||
requirements,
|
||||
requirementsError
|
||||
} = useSocialBi();
|
||||
const [mode, setMode] = useState("wide");
|
||||
const [dimKey, setDimKey] = useState("appDaily");
|
||||
const [closedGroups, setClosedGroups] = useState(readClosedGroups);
|
||||
const [sort, setSort] = useState(null);
|
||||
const [requirementSection, setRequirementSection] = useState(REQUIREMENT_SECTIONS[0].key);
|
||||
const [requirementRole, setRequirementRole] = useState("all");
|
||||
const [requirementPayer, setRequirementPayer] = useState("all");
|
||||
const [requirementNewUser, setRequirementNewUser] = useState("all");
|
||||
|
||||
const dim = DIMENSIONS.find((item) => item.key === dimKey) || DIMENSIONS[0];
|
||||
const dimColumns = dim.dims.map((name) => DIM_COLUMNS[name]);
|
||||
const hasDay = dim.hasDay;
|
||||
const baseRows = derived?.[dim.key] || EMPTY_ROWS;
|
||||
const activeRequirementSection = requirementSectionConfig(requirementSection);
|
||||
const effectiveRequirementRole = activeRequirementSection.roleFilter ? requirementRole : "all";
|
||||
const effectiveRequirementPayer = activeRequirementSection.payerFilter ? requirementPayer : "all";
|
||||
const effectiveRequirementNewUser = activeRequirementSection.newUserFilter ? requirementNewUser : "all";
|
||||
const requirementData = useMemo(() => normalizeRequirementPayload(requirements, requirementSection), [requirements, requirementSection]);
|
||||
const requirementColumns = requirementData.columns;
|
||||
const requirementRows = requirementData.rows;
|
||||
const requirementTotal = requirementData.total;
|
||||
const requirementAverage = requirementData.average;
|
||||
const requirementGameData = useMemo(
|
||||
() => (requirementSection === "game" ? normalizeRequirementGamePayload(requirements) : { columns: [], rows: [] }),
|
||||
[requirements, requirementSection]
|
||||
);
|
||||
|
||||
const visibleGroups = useMemo(() => METRIC_GROUPS.filter((group) => !closedGroups.includes(group.key)), [closedGroups]);
|
||||
const visibleMetrics = useMemo(() => visibleGroups.flatMap((group) => group.metrics), [visibleGroups]);
|
||||
|
||||
// 排序列被列组开关隐藏时回退到维度默认排序。
|
||||
const activeSort = sort && visibleMetrics.includes(sort.key) ? sort : null;
|
||||
|
||||
// 排序/合计每次渲染重算(行集千行量级可接受);react-hooks 的 preserve-manual-memoization
|
||||
// 规则不允许在这里包 useMemo(依赖来自模块常量推导,编译器无法证明不可变)。
|
||||
const sortedRows = [...baseRows];
|
||||
if (activeSort) {
|
||||
sortedRows.sort((a, b) => compareMetric(a, b, activeSort.key, activeSort.dir));
|
||||
} else if (hasDay) {
|
||||
sortedRows.sort((a, b) => {
|
||||
if (a.stat_day !== b.stat_day) {
|
||||
return String(a.stat_day) < String(b.stat_day) ? 1 : -1;
|
||||
}
|
||||
return compareMetric(a, b, "recharge_usd_minor", "desc");
|
||||
});
|
||||
} else {
|
||||
sortedRows.sort((a, b) => compareMetric(a, b, "recharge_usd_minor", "desc"));
|
||||
}
|
||||
|
||||
// 日维度的汇总跨天:余额快照类指标(金币数量/存量工资)只取最后一天,避免按天数放大。
|
||||
const totalRow = sortedRows.length ? aggregateMetricMaps(sortedRows, { acrossTime: hasDay }) : null;
|
||||
// 平均值行只在带日期的维度有意义:可加指标按覆盖天数取日均。
|
||||
const wideDayCount = hasDay ? new Set(sortedRows.map((row) => String(row.stat_day || "")).filter(Boolean)).size : 0;
|
||||
const averageRow = totalRow && hasDay && wideDayCount > 0 ? averageMetricMaps(sortedRows, wideDayCount) : null;
|
||||
const renderRows = sortedRows.slice(0, MAX_RENDER_ROWS);
|
||||
const isTruncated = sortedRows.length > MAX_RENDER_ROWS;
|
||||
const hasRestricted = baseRows.some((row) => row.restricted);
|
||||
const columnCount = dimColumns.length + visibleMetrics.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "requirements") {
|
||||
return;
|
||||
}
|
||||
loadRequirements({
|
||||
newUserType: effectiveRequirementNewUser,
|
||||
payerType: effectiveRequirementPayer,
|
||||
section: requirementSection,
|
||||
userRole: effectiveRequirementRole
|
||||
});
|
||||
}, [effectiveRequirementNewUser, effectiveRequirementPayer, effectiveRequirementRole, loadRequirements, mode, refreshToken, requirementSection]);
|
||||
|
||||
const handleDimChange = (key) => {
|
||||
setDimKey(key);
|
||||
setSort(null);
|
||||
};
|
||||
|
||||
const handleRequirementSectionChange = (key) => {
|
||||
const nextSection = requirementSectionConfig(key);
|
||||
setRequirementSection(nextSection.key);
|
||||
if (!nextSection.roleFilter) {
|
||||
setRequirementRole("all");
|
||||
}
|
||||
if (!nextSection.payerFilter) {
|
||||
setRequirementPayer("all");
|
||||
}
|
||||
if (!nextSection.newUserFilter) {
|
||||
setRequirementNewUser("all");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleGroup = (key) => {
|
||||
const next = closedGroups.includes(key) ? closedGroups.filter((item) => item !== key) : [...closedGroups, key];
|
||||
setClosedGroups(next);
|
||||
writeClosedGroups(next);
|
||||
};
|
||||
|
||||
const handleSort = (key) => {
|
||||
setSort((current) => (current?.key === key ? { dir: current.dir === "desc" ? "asc" : "desc", key } : { dir: "desc", key }));
|
||||
};
|
||||
|
||||
// 导出当前可见列与排序后的全部行:金额转美元、比例转百分数、缺失导出空串。
|
||||
const handleExport = () => {
|
||||
const header = [...dimColumns.map((column) => column.label), ...visibleMetrics.map((key) => metricLabel(key))];
|
||||
const rows = sortedRows.map((row) => [
|
||||
...dimColumns.map((column) => column.value(row)),
|
||||
...visibleMetrics.map((key) => {
|
||||
const value = metricChartValue(key, row[key]);
|
||||
return value === null ? "" : value;
|
||||
})
|
||||
]);
|
||||
downloadCsv(`social-bi-${dim.csvName}-${rangeLabel(range).replace(/\s+/g, "")}.csv`, header, rows);
|
||||
};
|
||||
|
||||
const handleRequirementsExport = () => {
|
||||
const header = ["日期", "App", ...requirementColumns.map((column) => column.label)];
|
||||
const rows = [
|
||||
...(requirementTotal ? [{ ...requirementTotal, app_name: "全部", stat_day: "汇总" }] : []),
|
||||
...(requirementAverage ? [{ ...requirementAverage, app_name: "全部", stat_day: "平均值" }] : []),
|
||||
...requirementRows
|
||||
].map((row) => [
|
||||
row.stat_day || "--",
|
||||
requirementAppName(row),
|
||||
...requirementColumns.map((column) => csvRequirementValue(column, row[column.key]))
|
||||
]);
|
||||
downloadCsv(`social-bi-requirements-${activeRequirementSection.csvName}-${rangeLabel(range).replace(/\s+/g, "")}.csv`, header, rows);
|
||||
};
|
||||
|
||||
const handleGameBreakdownExport = () => {
|
||||
const header = ["游戏", "日期", "App", ...requirementGameData.columns.map((column) => column.label)];
|
||||
const rows = requirementGameData.rows.map((row) => [
|
||||
requirementGameLabel(row),
|
||||
row.stat_day || "--",
|
||||
requirementAppName(row),
|
||||
...requirementGameData.columns.map((column) => csvRequirementValue(column, row[column.key]))
|
||||
]);
|
||||
downloadCsv(`social-bi-game-breakdown-${rangeLabel(range).replace(/\s+/g, "")}.csv`, header, rows);
|
||||
};
|
||||
|
||||
let wideContent;
|
||||
if (isLoading && !baseRows.length) {
|
||||
wideContent = (
|
||||
<div className="sbi-dt-skeleton" aria-label="数据加载中">
|
||||
{SKELETON_WIDTHS.map((width) => (
|
||||
<span className="sbi-skeleton" key={width} style={{ width }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} else if (!baseRows.length) {
|
||||
wideContent = (
|
||||
<div className="sbi-empty">
|
||||
<strong>当前无数据</strong>
|
||||
</div>
|
||||
);
|
||||
} else if (!visibleMetrics.length) {
|
||||
wideContent = (
|
||||
<div className="sbi-empty">
|
||||
<strong>所有列组已隐藏</strong>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
wideContent = (
|
||||
<div className="sbi-table-scroll sbi-dt-scroll">
|
||||
<table className="sbi-table sbi-dt-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{dimColumns.map((column, index) => (
|
||||
<th className={index === 0 ? "is-left sbi-dt-sticky" : "is-left"} key={column.label} rowSpan={2}>
|
||||
{column.label}
|
||||
</th>
|
||||
))}
|
||||
{visibleGroups.map((group) => (
|
||||
<th className="sbi-dt-group-th" colSpan={group.metrics.length} key={group.key}>
|
||||
{group.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
<tr>
|
||||
{visibleMetrics.map((key) => {
|
||||
const isSorted = activeSort?.key === key;
|
||||
return (
|
||||
<th
|
||||
aria-sort={isSorted ? (activeSort.dir === "desc" ? "descending" : "ascending") : undefined}
|
||||
className={isSorted ? "sbi-dt-metric-th is-sorted" : "sbi-dt-metric-th"}
|
||||
key={key}
|
||||
onClick={() => handleSort(key)}
|
||||
>
|
||||
<span className="sbi-metric-hint" title={metricTooltip(key)}>
|
||||
{metricLabel(key)}
|
||||
</span>
|
||||
{isSorted ? <span className="sbi-dt-sort">{activeSort.dir === "desc" ? "↓" : "↑"}</span> : null}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{totalRow ? (
|
||||
<tr className="is-total">
|
||||
{dimColumns.map((column, index) => (
|
||||
<td className={index === 0 ? "is-left sbi-dt-sticky" : "is-left"} key={column.label}>
|
||||
{column.totalText}
|
||||
</td>
|
||||
))}
|
||||
{visibleMetrics.map((key) => (
|
||||
<td key={key}>{formatMetric(key, totalRow[key])}</td>
|
||||
))}
|
||||
</tr>
|
||||
) : null}
|
||||
{averageRow ? (
|
||||
<tr className="is-total">
|
||||
{dimColumns.map((column, index) => (
|
||||
<td className={index === 0 ? "is-left sbi-dt-sticky" : "is-left"} key={column.label}>
|
||||
{index === 0 ? "平均值" : column.totalText}
|
||||
</td>
|
||||
))}
|
||||
{visibleMetrics.map((key) => (
|
||||
<td key={key}>{formatMetric(key, averageRow[key])}</td>
|
||||
))}
|
||||
</tr>
|
||||
) : null}
|
||||
{renderRows.map((row, rowIndex) => (
|
||||
<tr key={rowIdentity(row, dim.key, rowIndex)}>
|
||||
{dimColumns.map((column, index) => (
|
||||
<td className={index === 0 ? "is-left sbi-dt-sticky" : "is-left"} key={column.label}>
|
||||
{column.value(row)}
|
||||
</td>
|
||||
))}
|
||||
{visibleMetrics.map((key) => (
|
||||
<td key={key}>{formatMetric(key, row[key])}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
{isTruncated ? (
|
||||
<tfoot>
|
||||
<tr className="sbi-dt-note">
|
||||
<td colSpan={columnCount}>已截断,共 {sortedRows.length} 行,可导出 CSV 查看全部</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
) : null}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let requirementsContent;
|
||||
if (isRequirementsLoading && !requirementRows.length && !requirementTotal) {
|
||||
requirementsContent = (
|
||||
<div className="sbi-dt-skeleton" aria-label="数据需求加载中">
|
||||
{SKELETON_WIDTHS.map((width) => (
|
||||
<span className="sbi-skeleton" key={width} style={{ width }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} else if (requirementsError) {
|
||||
requirementsContent = (
|
||||
<div className="sbi-empty" role="alert">
|
||||
<strong>{requirementsError}</strong>
|
||||
</div>
|
||||
);
|
||||
} else if (!requirementRows.length && !requirementTotal) {
|
||||
requirementsContent = (
|
||||
<div className="sbi-empty">
|
||||
<strong>当前无数据</strong>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
requirementsContent = (
|
||||
<>
|
||||
<div className="sbi-table-scroll sbi-dt-scroll">
|
||||
<table className="sbi-table sbi-dt-req-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="is-left sbi-dt-sticky">日期</th>
|
||||
<th className="is-left">App</th>
|
||||
{requirementColumns.map((column) => (
|
||||
<th key={column.key}>
|
||||
<span className={column.tooltip ? "sbi-metric-hint" : ""} title={column.tooltip || undefined}>
|
||||
{column.label}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{requirementTotal ? (
|
||||
<tr className="is-total">
|
||||
<td className="is-left sbi-dt-sticky">汇总</td>
|
||||
<td className="is-left">全部</td>
|
||||
{requirementColumns.map((column) => (
|
||||
<td key={column.key}>{formatRequirementValue(column, requirementTotal[column.key])}</td>
|
||||
))}
|
||||
</tr>
|
||||
) : null}
|
||||
{requirementAverage ? (
|
||||
<tr className="is-total">
|
||||
<td className="is-left sbi-dt-sticky">平均值</td>
|
||||
<td className="is-left">全部</td>
|
||||
{requirementColumns.map((column) => (
|
||||
<td key={column.key}>{formatRequirementValue(column, requirementAverage[column.key])}</td>
|
||||
))}
|
||||
</tr>
|
||||
) : null}
|
||||
{requirementRows.map((row, rowIndex) => (
|
||||
<tr key={requirementRowIdentity(row, activeRequirementSection.key, rowIndex)}>
|
||||
<td className="is-left sbi-dt-sticky">{row.stat_day || "--"}</td>
|
||||
<td className="is-left">{requirementAppName(row)}</td>
|
||||
{requirementColumns.map((column) => (
|
||||
<td key={column.key}>{formatRequirementValue(column, row[column.key])}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{requirementGameData.rows.length ? (
|
||||
<>
|
||||
<div className="sbi-card-header">
|
||||
<strong>各游戏明细</strong>
|
||||
<small>押注用户/同游戏次留/ARPPU · 不受身份与新用户筛选影响</small>
|
||||
</div>
|
||||
<div className="sbi-table-scroll sbi-dt-scroll">
|
||||
<table className="sbi-table sbi-dt-req-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="is-left sbi-dt-sticky">游戏</th>
|
||||
<th className="is-left">日期</th>
|
||||
<th className="is-left">App</th>
|
||||
{requirementGameData.columns.map((column) => (
|
||||
<th key={column.key}>
|
||||
<span className={column.tooltip ? "sbi-metric-hint" : ""} title={column.tooltip || undefined}>
|
||||
{column.label}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{requirementGameData.rows.map((row, rowIndex) => (
|
||||
<tr
|
||||
className={row.stat_day === "汇总" ? "is-total" : undefined}
|
||||
key={`${row.app_code || ""}|${row.game_key || rowIndex}|${row.stat_day || rowIndex}`}
|
||||
>
|
||||
<td className="is-left sbi-dt-sticky">{requirementGameLabel(row)}</td>
|
||||
<td className="is-left">{row.stat_day || "--"}</td>
|
||||
<td className="is-left">{requirementAppName(row)}</td>
|
||||
{requirementGameData.columns.map((column) => (
|
||||
<td key={column.key}>{formatRequirementValue(column, row[column.key])}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const rowCount = mode === "requirements" ? requirementRows.length : sortedRows.length;
|
||||
const content = mode === "requirements" ? requirementsContent : wideContent;
|
||||
const exportDisabled = mode === "requirements"
|
||||
? (!requirementRows.length && !requirementTotal) || isRequirementsLoading
|
||||
: !sortedRows.length || !visibleMetrics.length;
|
||||
|
||||
return (
|
||||
<section className="sbi-card">
|
||||
<div className="sbi-card-header">
|
||||
<strong>数据明细</strong>
|
||||
<small>
|
||||
{rangeLabel(range)} · 共 {rowCount} 行
|
||||
</small>
|
||||
</div>
|
||||
<div className="sbi-dt-tools">
|
||||
<div className="sbi-seg" role="radiogroup" aria-label="数据明细模式">
|
||||
{TABLE_MODES.map((item) => (
|
||||
<button
|
||||
aria-checked={mode === item.key}
|
||||
className={mode === item.key ? "is-active" : ""}
|
||||
key={item.key}
|
||||
onClick={() => setMode(item.key)}
|
||||
role="radio"
|
||||
type="button"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{mode === "wide" ? (
|
||||
<>
|
||||
<div className="sbi-seg" role="radiogroup" aria-label="明细维度">
|
||||
{DIMENSIONS.map((item) => (
|
||||
<button
|
||||
aria-checked={dim.key === item.key}
|
||||
className={dim.key === item.key ? "is-active" : ""}
|
||||
key={item.key}
|
||||
onClick={() => handleDimChange(item.key)}
|
||||
role="radio"
|
||||
type="button"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="sbi-dt-groups" role="group" aria-label="列组开关">
|
||||
<span className="sbi-dt-groups-label">列组</span>
|
||||
{METRIC_GROUPS.map((group) => {
|
||||
const isOpen = !closedGroups.includes(group.key);
|
||||
return (
|
||||
<button
|
||||
aria-pressed={isOpen}
|
||||
className={isOpen ? "sbi-chip is-active" : "sbi-chip"}
|
||||
key={group.key}
|
||||
onClick={() => toggleGroup(group.key)}
|
||||
title={isOpen ? "点击隐藏该组指标列" : "点击显示该组指标列"}
|
||||
type="button"
|
||||
>
|
||||
{group.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hasRestricted ? <span className="sbi-badge">已按数据范围过滤</span> : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="sbi-dt-section-tabs" role="tablist" aria-label="数据需求分组">
|
||||
{REQUIREMENT_SECTIONS.map((section) => (
|
||||
<button
|
||||
aria-selected={activeRequirementSection.key === section.key}
|
||||
className={activeRequirementSection.key === section.key ? "is-active" : ""}
|
||||
key={section.key}
|
||||
onClick={() => handleRequirementSectionChange(section.key)}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
{section.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{activeRequirementSection.roleFilter ? (
|
||||
<RequirementFilterGroup
|
||||
ariaLabel="用户身份"
|
||||
label="用户身份"
|
||||
options={USER_ROLE_OPTIONS}
|
||||
value={effectiveRequirementRole}
|
||||
onChange={setRequirementRole}
|
||||
/>
|
||||
) : null}
|
||||
{activeRequirementSection.payerFilter ? (
|
||||
<RequirementFilterGroup
|
||||
ariaLabel="付费身份"
|
||||
label="付费身份"
|
||||
options={PAYER_TYPE_OPTIONS}
|
||||
value={effectiveRequirementPayer}
|
||||
onChange={setRequirementPayer}
|
||||
/>
|
||||
) : null}
|
||||
{activeRequirementSection.newUserFilter ? (
|
||||
<RequirementFilterGroup
|
||||
ariaLabel="是否新用户"
|
||||
label="是否新用户"
|
||||
options={NEW_USER_OPTIONS}
|
||||
value={effectiveRequirementNewUser}
|
||||
onChange={setRequirementNewUser}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<div className="sbi-dt-export">
|
||||
{mode === "requirements" && requirementGameData.rows.length ? (
|
||||
<Button
|
||||
onClick={handleGameBreakdownExport}
|
||||
size="small"
|
||||
startIcon={<FileDownloadOutlined />}
|
||||
variant="outlined"
|
||||
>
|
||||
导出游戏明细
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
disabled={exportDisabled}
|
||||
onClick={mode === "requirements" ? handleRequirementsExport : handleExport}
|
||||
size="small"
|
||||
startIcon={<FileDownloadOutlined />}
|
||||
variant="outlined"
|
||||
>
|
||||
导出 CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{content}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RequirementFilterGroup({ ariaLabel, label, onChange, options, value }) {
|
||||
return (
|
||||
<div className="sbi-dt-filter-group">
|
||||
<span className="sbi-dt-groups-label">{label}</span>
|
||||
<div className="sbi-seg" role="radiogroup" aria-label={ariaLabel}>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
aria-checked={value === option.value}
|
||||
className={value === option.value ? "is-active" : ""}
|
||||
key={option.value}
|
||||
onClick={() => onChange(option.value)}
|
||||
role="radio"
|
||||
type="button"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
382
databi/src/social/views/FunnelView.jsx
Normal file
382
databi/src/social/views/FunnelView.jsx
Normal file
@ -0,0 +1,382 @@
|
||||
// 埋点漏斗:展示 App 原始 tracking 事件的总体漏斗、失败诊断和 D1 cohort。
|
||||
// 数据源为 statistics-service 的 app_tracking_events 聚合结果,视图只做跨 App 求和和排序。
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { EChart } from "../../charts/EChart.jsx";
|
||||
import { formatCount, formatRatioPercent, isBlank } from "../format.js";
|
||||
import { rangeLabel } from "../state.js";
|
||||
import { useSocialBi } from "../SocialBiApp.jsx";
|
||||
import "./funnel-view.css";
|
||||
|
||||
const COHORT_DIMENSIONS = [
|
||||
{ key: "country", label: "国家" },
|
||||
{ key: "language", label: "语言" },
|
||||
{ key: "channel", label: "渠道" },
|
||||
{ key: "login_method", label: "登录方式" },
|
||||
{ key: "first_room_stay", label: "首房停留时长" }
|
||||
];
|
||||
|
||||
const COHORT_STEP_COLUMNS = [
|
||||
{ key: "login_success", label: "登录成功" },
|
||||
{ key: "profile_complete", label: "资料完成" },
|
||||
{ key: "room_join_success", label: "进房成功" },
|
||||
{ key: "stay_3m", label: "停留 3m" },
|
||||
{ key: "stay_10m", label: "停留 10m" },
|
||||
{ key: "send_message", label: "发消息" }
|
||||
];
|
||||
|
||||
const SUMMARY_CARDS = [
|
||||
{ key: "login_start_users", label: "登录开始", type: "count" },
|
||||
{ key: "login_success_rate", label: "登录成功率", type: "ratio" },
|
||||
{ key: "room_join_success_users", label: "进房成功", type: "count" },
|
||||
{ key: "d1_retention_rate", label: "D1 留存", type: "ratio" },
|
||||
{ key: "room_join_fail_users", label: "进房失败", type: "count" }
|
||||
];
|
||||
|
||||
const SUPPORTED_FUNNEL_APPS = "Lalu / Huwaa / Fami";
|
||||
|
||||
export function FunnelView() {
|
||||
const { funnel, isLoading, range } = useSocialBi();
|
||||
const [dimension, setDimension] = useState("country");
|
||||
const appRows = useMemo(() => (funnel?.apps || []).filter((app) => !app.error), [funnel]);
|
||||
const appErrors = useMemo(() => (funnel?.apps || []).filter((app) => app.error), [funnel]);
|
||||
const steps = useMemo(() => aggregateSteps(appRows), [appRows]);
|
||||
const totals = useMemo(() => aggregateTotals(appRows), [appRows]);
|
||||
const cohorts = useMemo(() => aggregateCohorts(appRows), [appRows]);
|
||||
const selectedCohorts = useMemo(
|
||||
() => (cohorts.get(dimension) || []).slice(0, 50),
|
||||
[cohorts, dimension]
|
||||
);
|
||||
const chartOption = useMemo(() => funnelChartOption(steps), [steps]);
|
||||
|
||||
if (isLoading && !appRows.length) {
|
||||
return (
|
||||
<div className="sbi-funnel" aria-label="埋点漏斗加载中">
|
||||
<div className="sbi-funnel-skeleton">
|
||||
{["70%", "92%", "84%", "76%", "88%"].map((width) => (
|
||||
<span className="sbi-skeleton" key={width} style={{ width }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!appRows.length) {
|
||||
return (
|
||||
<div className="sbi-funnel">
|
||||
<div className="sbi-empty">
|
||||
<strong>当前无埋点漏斗数据</strong>
|
||||
<span>
|
||||
{appErrors.length
|
||||
? "所选 App 暂未接入漏斗或统计服务返回错误"
|
||||
: `埋点漏斗 App 筛选目前仅支持 ${SUPPORTED_FUNNEL_APPS}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sbi-funnel">
|
||||
<div className="sbi-funnel-head">
|
||||
<div>
|
||||
<h1>埋点漏斗</h1>
|
||||
<span>
|
||||
{rangeLabel(range)} · 仅支持 {SUPPORTED_FUNNEL_APPS}
|
||||
</span>
|
||||
</div>
|
||||
<div className="sbi-funnel-apps">
|
||||
{appRows.map((app) => (
|
||||
<span key={app.app_code}>{app.app_name || app.app_code}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{appErrors.length ? (
|
||||
<div className="sbi-funnel-warning">
|
||||
{appErrors.map((app) => (
|
||||
<span key={app.app_code}>
|
||||
{app.app_name || app.app_code}: {app.error}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="sbi-funnel-summary" aria-label="漏斗核心指标">
|
||||
{SUMMARY_CARDS.map((item) => (
|
||||
<article className="sbi-card sbi-funnel-kpi" key={item.key}>
|
||||
<span>{item.label}</span>
|
||||
<strong>{item.type === "ratio" ? formatRatioPercent(totals[item.key]) : formatCount(totals[item.key])}</strong>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="sbi-funnel-grid">
|
||||
<article className="sbi-card sbi-funnel-chart-card">
|
||||
<div className="sbi-card-title">
|
||||
<div>
|
||||
<h2>主路径转化</h2>
|
||||
<span>按去重用户数计算</span>
|
||||
</div>
|
||||
</div>
|
||||
<EChart className="sbi-funnel-chart" option={chartOption} />
|
||||
</article>
|
||||
|
||||
<article className="sbi-card sbi-funnel-table-card">
|
||||
<div className="sbi-card-title">
|
||||
<div>
|
||||
<h2>事件明细</h2>
|
||||
<span>含进房失败和互动动作</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sbi-table-scroll sbi-funnel-step-scroll">
|
||||
<table className="sbi-table sbi-funnel-step-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="is-left">事件</th>
|
||||
<th>用户</th>
|
||||
<th>次数</th>
|
||||
<th>上一步</th>
|
||||
<th>总转化</th>
|
||||
<th>流失</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{steps.map((step) => (
|
||||
<tr className={step.is_failure ? "is-failure" : ""} key={step.event_name}>
|
||||
<td className="is-left">
|
||||
<strong>{step.label || step.event_name}</strong>
|
||||
<span>{step.event_name}</span>
|
||||
</td>
|
||||
<td>{formatCount(step.user_count)}</td>
|
||||
<td>{formatCount(step.event_count)}</td>
|
||||
<td>{formatRatioPercent(step.previous_conversion_rate)}</td>
|
||||
<td>{formatRatioPercent(step.overall_conversion_rate)}</td>
|
||||
<td>{formatCount(step.dropoff_users)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="sbi-card sbi-funnel-cohort-card">
|
||||
<div className="sbi-card-title sbi-funnel-cohort-title">
|
||||
<div>
|
||||
<h2>D1 Cohort</h2>
|
||||
<span>基准用户为登录成功用户</span>
|
||||
</div>
|
||||
<div className="sbi-funnel-tabs" role="tablist" aria-label="D1 cohort 维度">
|
||||
{COHORT_DIMENSIONS.map((item) => (
|
||||
<button
|
||||
aria-selected={dimension === item.key}
|
||||
className={dimension === item.key ? "is-active" : ""}
|
||||
key={item.key}
|
||||
onClick={() => setDimension(item.key)}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sbi-table-scroll sbi-funnel-cohort-scroll">
|
||||
<table className="sbi-table sbi-funnel-cohort-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="is-left">{COHORT_DIMENSIONS.find((item) => item.key === dimension)?.label || "Cohort"}</th>
|
||||
<th>基准用户</th>
|
||||
<th>D1 用户</th>
|
||||
<th>D1 留存</th>
|
||||
{COHORT_STEP_COLUMNS.map((item) => (
|
||||
<th key={item.key}>{item.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedCohorts.length ? (
|
||||
selectedCohorts.map((row) => (
|
||||
<tr key={`${row.dimension}:${row.value}`}>
|
||||
<td className="is-left">
|
||||
<strong>{row.label || row.value || "unknown"}</strong>
|
||||
</td>
|
||||
<td>{formatCount(row.base_users)}</td>
|
||||
<td>{formatCount(row.d1_retention_users)}</td>
|
||||
<td>{formatRatioPercent(row.d1_retention_rate)}</td>
|
||||
{COHORT_STEP_COLUMNS.map((item) => (
|
||||
<td key={item.key}>{formatCount(stepUserCount(row, item.key))}</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td className="sbi-funnel-empty-cell" colSpan={4 + COHORT_STEP_COLUMNS.length}>
|
||||
当前维度暂无 cohort 数据
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function aggregateSteps(appRows) {
|
||||
const order = [];
|
||||
const byEvent = new Map();
|
||||
appRows.forEach((app) => {
|
||||
(app.steps || []).forEach((step) => {
|
||||
const key = step.event_name || step.key;
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
if (!byEvent.has(key)) {
|
||||
order.push(key);
|
||||
byEvent.set(key, {
|
||||
...step,
|
||||
device_count: 0,
|
||||
event_count: 0,
|
||||
user_count: 0
|
||||
});
|
||||
}
|
||||
const current = byEvent.get(key);
|
||||
current.device_count += Number(step.device_count || 0);
|
||||
current.event_count += Number(step.event_count || 0);
|
||||
current.user_count += Number(step.user_count || 0);
|
||||
current.is_failure = Boolean(current.is_failure || step.is_failure);
|
||||
if (!current.label) {
|
||||
current.label = step.label;
|
||||
}
|
||||
});
|
||||
});
|
||||
const baseUsers = Number(byEvent.get("login_start")?.user_count || (order.length ? byEvent.get(order[0])?.user_count || 0 : 0));
|
||||
let previousUsers = 0;
|
||||
return order.map((key) => {
|
||||
const step = byEvent.get(key);
|
||||
const userCount = Number(step.user_count || 0);
|
||||
const out = {
|
||||
...step,
|
||||
dropoff_users: previousUsers > userCount ? previousUsers - userCount : 0,
|
||||
overall_conversion_rate: ratio(userCount, baseUsers),
|
||||
previous_conversion_rate: ratio(userCount, previousUsers)
|
||||
};
|
||||
previousUsers = userCount;
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
function aggregateTotals(appRows) {
|
||||
const totals = {};
|
||||
let d1Base = 0;
|
||||
let d1Users = 0;
|
||||
appRows.forEach((app) => {
|
||||
Object.entries(app.totals || {}).forEach(([key, value]) => {
|
||||
if (key.endsWith("_rate")) {
|
||||
return;
|
||||
}
|
||||
totals[key] = Number(totals[key] || 0) + Number(value || 0);
|
||||
});
|
||||
d1Base += Number(app.totals?.d1_retention_base_users || 0);
|
||||
d1Users += Number(app.totals?.d1_retention_users || 0);
|
||||
});
|
||||
totals.login_success_rate = ratio(totals.login_success_users, totals.login_start_users);
|
||||
totals.d1_retention_rate = ratio(d1Users, d1Base);
|
||||
return totals;
|
||||
}
|
||||
|
||||
function aggregateCohorts(appRows) {
|
||||
const grouped = new Map();
|
||||
appRows.forEach((app) => {
|
||||
(app.cohorts || []).forEach((cohort) => {
|
||||
const dimension = cohort.dimension || "unknown";
|
||||
const value = cohort.value || cohort.label || "unknown";
|
||||
if (!grouped.has(dimension)) {
|
||||
grouped.set(dimension, new Map());
|
||||
}
|
||||
const bucket = grouped.get(dimension);
|
||||
if (!bucket.has(value)) {
|
||||
bucket.set(value, {
|
||||
dimension,
|
||||
label: cohort.label || value,
|
||||
value,
|
||||
base_users: 0,
|
||||
d1_retention_users: 0,
|
||||
d1_retention_rate: 0,
|
||||
steps: new Map()
|
||||
});
|
||||
}
|
||||
const row = bucket.get(value);
|
||||
row.base_users += Number(cohort.base_users || 0);
|
||||
row.d1_retention_users += Number(cohort.d1_retention_users || 0);
|
||||
(cohort.steps || []).forEach((step) => {
|
||||
const eventName = step.event_name;
|
||||
if (!eventName) {
|
||||
return;
|
||||
}
|
||||
row.steps.set(eventName, Number(row.steps.get(eventName) || 0) + Number(step.user_count || 0));
|
||||
});
|
||||
});
|
||||
});
|
||||
const out = new Map();
|
||||
grouped.forEach((bucket, dimension) => {
|
||||
const rows = [...bucket.values()].map((row) => ({
|
||||
...row,
|
||||
d1_retention_rate: ratio(row.d1_retention_users, row.base_users),
|
||||
steps: [...row.steps.entries()].map(([event_name, user_count]) => ({ event_name, user_count }))
|
||||
}));
|
||||
rows.sort((left, right) => Number(right.base_users || 0) - Number(left.base_users || 0));
|
||||
out.set(dimension, rows);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function stepUserCount(row, eventName) {
|
||||
const step = (row.steps || []).find((item) => item.event_name === eventName);
|
||||
return step?.user_count;
|
||||
}
|
||||
|
||||
function funnelChartOption(steps) {
|
||||
const data = steps
|
||||
.filter((step) => !step.is_failure && Number(step.user_count || 0) > 0)
|
||||
.map((step) => ({
|
||||
name: step.label || step.event_name,
|
||||
value: Number(step.user_count || 0)
|
||||
}));
|
||||
return {
|
||||
color: ["#3056d3", "#3b82f6", "#14b8a6", "#22c55e", "#f59e0b", "#ef4444"],
|
||||
series: [
|
||||
{
|
||||
bottom: 18,
|
||||
data,
|
||||
gap: 4,
|
||||
label: {
|
||||
color: "#1f2a44",
|
||||
formatter: ({ name, value }) => `${name}\n${formatCount(value)}`
|
||||
},
|
||||
left: 18,
|
||||
minSize: "8%",
|
||||
right: 18,
|
||||
sort: "none",
|
||||
top: 10,
|
||||
type: "funnel"
|
||||
}
|
||||
],
|
||||
tooltip: {
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "#e3eaf3",
|
||||
textStyle: { color: "#263246" },
|
||||
valueFormatter: (value) => formatCount(value)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function ratio(numerator, denominator) {
|
||||
if (isBlank(denominator) || Number(denominator) <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Number(numerator || 0) / Number(denominator);
|
||||
}
|
||||
489
databi/src/social/views/OverviewView.jsx
Normal file
489
databi/src/social/views/OverviewView.jsx
Normal file
@ -0,0 +1,489 @@
|
||||
// OverviewView:经营概览首屏 —— Hero KPI、充值趋势、渠道构成、App 对比、区域 Top5。
|
||||
// 数据契约见 SocialBiApp.jsx 的 useSocialBi();本视图只做展示与聚合,不直接调 API。
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { EChart } from "../../charts/EChart.jsx";
|
||||
import { deltaDirection, formatCount, formatDeltaRate, formatMoney, isBlank } from "../format.js";
|
||||
import { aggregateMetricMaps, appColor, formatMetric, metricChartValue, metricLabel, metricTooltip } from "../metrics.js";
|
||||
import { useSocialBi } from "../SocialBiApp.jsx";
|
||||
import { bucketDay } from "../state.js";
|
||||
import "./overview-view.css";
|
||||
|
||||
const HERO_METRICS = [
|
||||
{ deltaKey: "recharge_delta_rate", key: "recharge_usd_minor" },
|
||||
{ deltaKey: "new_users_delta_rate", key: "new_users" },
|
||||
{ deltaKey: "active_users_delta_rate", key: "active_users" },
|
||||
{ deltaKey: "paid_users_delta_rate", key: "paid_users" },
|
||||
{ deltaKey: "arppu_delta_rate", key: "arppu_usd_minor" },
|
||||
{ deltaKey: "", key: "paid_conversion_rate" }
|
||||
];
|
||||
|
||||
const TREND_METRICS = [
|
||||
{ key: "recharge_usd_minor", label: "充值" },
|
||||
{ key: "new_users", label: "新增" },
|
||||
{ key: "active_users", label: "日活" },
|
||||
{ key: "gift_coin_spent", label: "礼物流水" }
|
||||
];
|
||||
|
||||
const COMPARE_METRICS = [
|
||||
{ key: "recharge_usd_minor", label: "充值" },
|
||||
{ key: "active_users", label: "日活" },
|
||||
{ key: "new_users", label: "新增" }
|
||||
];
|
||||
|
||||
const CHANNEL_METRICS = ["google_recharge_usd_minor", "mifapay_recharge_usd_minor", "coin_seller_recharge_usd_minor"];
|
||||
const CHANNEL_COLORS = ["#2557d6", "#0e7490", "#d97706"];
|
||||
|
||||
const MONEY_METRIC_KEYS = new Set([
|
||||
"recharge_usd_minor",
|
||||
"google_recharge_usd_minor",
|
||||
"mifapay_recharge_usd_minor",
|
||||
"coin_seller_recharge_usd_minor"
|
||||
]);
|
||||
|
||||
// 图表值已由 metricChartValue 换算(金额→美元),tooltip 里按指标类型补格式。
|
||||
function formatChartAmount(metricKey, value) {
|
||||
if (isBlank(value)) {
|
||||
return "--";
|
||||
}
|
||||
return MONEY_METRIC_KEYS.has(metricKey) ? formatMoney(value) : formatCount(value);
|
||||
}
|
||||
|
||||
// 日行按 bucketDay 分桶,桶 key 升序;每桶保留原始行,由调用方再聚合。
|
||||
function buildBuckets(rows, granularity) {
|
||||
const map = new Map();
|
||||
rows.forEach((row) => {
|
||||
const key = bucketDay(row.stat_day, granularity);
|
||||
if (!map.has(key)) {
|
||||
map.set(key, []);
|
||||
}
|
||||
map.get(key).push(row);
|
||||
});
|
||||
return [...map.keys()].sort().map((key) => ({ key, rows: map.get(key) }));
|
||||
}
|
||||
|
||||
function buildStackedBarOption({ bucketKeys, series, valueMetric }) {
|
||||
return {
|
||||
grid: { bottom: 4, containLabel: true, left: 8, right: 8, top: 28 },
|
||||
legend: { itemHeight: 10, itemWidth: 10, left: 0, textStyle: { color: "#5b7089", fontSize: 12 }, top: 0 },
|
||||
series,
|
||||
tooltip: {
|
||||
axisPointer: { type: "shadow" },
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "#e3eaf3",
|
||||
formatter: (params) => {
|
||||
const list = Array.isArray(params) ? params : [params];
|
||||
let total = null;
|
||||
const lines = list.map((item) => {
|
||||
const numeric = isBlank(item.value) || !Number.isFinite(Number(item.value)) ? null : Number(item.value);
|
||||
if (numeric !== null) {
|
||||
total = (total ?? 0) + numeric;
|
||||
}
|
||||
return `${item.marker}${item.seriesName} <b>${formatChartAmount(valueMetric, numeric)}</b>`;
|
||||
});
|
||||
const header = list[0]?.axisValueLabel || list[0]?.name || "";
|
||||
if (list.length > 1) {
|
||||
lines.push(`合计 <b>${formatChartAmount(valueMetric, total)}</b>`);
|
||||
}
|
||||
return [header, ...lines].filter(Boolean).join("<br/>");
|
||||
},
|
||||
textStyle: { color: "#14263c", fontSize: 12 },
|
||||
trigger: "axis"
|
||||
},
|
||||
xAxis: {
|
||||
axisLabel: { color: "#5b7089", fontSize: 11 },
|
||||
axisLine: { lineStyle: { color: "#eef3f9" } },
|
||||
axisTick: { show: false },
|
||||
data: bucketKeys,
|
||||
type: "category"
|
||||
},
|
||||
yAxis: {
|
||||
axisLabel: { color: "#5b7089", fontSize: 11 },
|
||||
splitLine: { lineStyle: { color: "#eef3f9" } },
|
||||
type: "value"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildSparkOption(spark) {
|
||||
return {
|
||||
grid: { bottom: 2, left: 0, right: 0, top: 2 },
|
||||
series: [
|
||||
{
|
||||
areaStyle: { color: "rgba(37, 87, 214, 0.12)" },
|
||||
data: spark.map((point) => point.value),
|
||||
lineStyle: { color: "#2557d6", width: 2 },
|
||||
showSymbol: false,
|
||||
smooth: true,
|
||||
symbol: "none",
|
||||
type: "line"
|
||||
}
|
||||
],
|
||||
xAxis: { boundaryGap: false, data: spark.map((point) => point.key), show: false, type: "category" },
|
||||
yAxis: { show: false, type: "value" }
|
||||
};
|
||||
}
|
||||
|
||||
function HeroCard({ deltaValue, metricKey, spark, value }) {
|
||||
const sparkOption = useMemo(() => buildSparkOption(spark), [spark]);
|
||||
const hasSpark = spark.length > 1 && spark.some((point) => point.value !== null);
|
||||
return (
|
||||
<article className="sbi-card sbi-ov-hero-card">
|
||||
<header className="sbi-ov-hero-top">
|
||||
<span className="sbi-metric-hint sbi-ov-hero-label" title={metricTooltip(metricKey)}>
|
||||
{metricLabel(metricKey)}
|
||||
</span>
|
||||
{!isBlank(deltaValue) ? (
|
||||
<span className={`sbi-delta is-${deltaDirection(deltaValue)}`}>{formatDeltaRate(deltaValue)}</span>
|
||||
) : null}
|
||||
</header>
|
||||
<strong className="sbi-ov-hero-value">{formatMetric(metricKey, value)}</strong>
|
||||
<div className="sbi-ov-hero-spark">{hasSpark ? <EChart className="sbi-ov-spark-chart" option={sparkOption} /> : null}</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewSkeleton() {
|
||||
return (
|
||||
<section className="sbi-ov">
|
||||
<div className="sbi-ov-hero">
|
||||
{HERO_METRICS.map((metric) => (
|
||||
<article className="sbi-card sbi-ov-hero-card" key={metric.key}>
|
||||
<span className="sbi-skeleton" style={{ height: 14, width: 72 }} />
|
||||
<span className="sbi-skeleton" style={{ height: 26, width: 110 }} />
|
||||
<span className="sbi-skeleton" style={{ height: 38, width: "100%" }} />
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<section className="sbi-card sbi-ov-skeleton-card">
|
||||
<span className="sbi-skeleton" style={{ height: 300, width: "100%" }} />
|
||||
</section>
|
||||
<div className="sbi-ov-duo">
|
||||
<section className="sbi-card sbi-ov-skeleton-card">
|
||||
<span className="sbi-skeleton" style={{ height: 220, width: "100%" }} />
|
||||
</section>
|
||||
<section className="sbi-card sbi-ov-skeleton-card">
|
||||
<span className="sbi-skeleton" style={{ height: 220, width: "100%" }} />
|
||||
</section>
|
||||
</div>
|
||||
<section className="sbi-card sbi-ov-skeleton-card">
|
||||
<span className="sbi-skeleton" style={{ height: 180, width: "100%" }} />
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewView() {
|
||||
const { appCodes, derived, filters, isLoading, setViewKey, updateFilter } = useSocialBi();
|
||||
const [trendMetric, setTrendMetric] = useState("recharge_usd_minor");
|
||||
const [compareMetric, setCompareMetric] = useState("recharge_usd_minor");
|
||||
|
||||
// regionScoped 时 App 级视角统一切到区域行聚合,保证与顶栏区域选择一致。
|
||||
const dailyRows = derived.regionScoped ? derived.regionDaily : derived.appDaily;
|
||||
|
||||
const totals = useMemo(
|
||||
() => aggregateMetricMaps(derived.regionScoped ? derived.regionTotals : derived.appTotals),
|
||||
[derived.appTotals, derived.regionScoped, derived.regionTotals]
|
||||
);
|
||||
|
||||
const buckets = useMemo(() => buildBuckets(dailyRows, filters.granularity), [dailyRows, filters.granularity]);
|
||||
|
||||
const bucketAggregates = useMemo(
|
||||
() => buckets.map((bucket) => ({ key: bucket.key, metrics: aggregateMetricMaps(bucket.rows) })),
|
||||
[buckets]
|
||||
);
|
||||
|
||||
const heroSparks = useMemo(() => {
|
||||
const map = {};
|
||||
HERO_METRICS.forEach((metric) => {
|
||||
map[metric.key] = bucketAggregates.map((bucket) => ({
|
||||
key: bucket.key,
|
||||
value: metricChartValue(metric.key, bucket.metrics[metric.key])
|
||||
}));
|
||||
});
|
||||
return map;
|
||||
}, [bucketAggregates]);
|
||||
|
||||
// 每 App × 每桶的聚合单元,供主趋势堆叠柱使用;App 顺序跟随顶栏 appCodes。
|
||||
const appBuckets = useMemo(() => {
|
||||
const keys = buckets.map((bucket) => bucket.key);
|
||||
const appIndex = new Map();
|
||||
buckets.forEach((bucket, bucketIdx) => {
|
||||
const byApp = new Map();
|
||||
bucket.rows.forEach((row) => {
|
||||
if (!byApp.has(row.app_code)) {
|
||||
byApp.set(row.app_code, []);
|
||||
}
|
||||
byApp.get(row.app_code).push(row);
|
||||
});
|
||||
byApp.forEach((rows, code) => {
|
||||
if (!appIndex.has(code)) {
|
||||
appIndex.set(code, { app_code: code, app_name: rows[0].app_name, cells: new Array(buckets.length).fill(null) });
|
||||
}
|
||||
appIndex.get(code).cells[bucketIdx] = aggregateMetricMaps(rows);
|
||||
});
|
||||
});
|
||||
const apps = [...appIndex.values()].sort((a, b) => appCodes.indexOf(a.app_code) - appCodes.indexOf(b.app_code));
|
||||
return { apps, keys };
|
||||
}, [appCodes, buckets]);
|
||||
|
||||
const trendOption = useMemo(() => {
|
||||
const series = appBuckets.apps.map((app) => ({
|
||||
barMaxWidth: 26,
|
||||
data: app.cells.map((cell) => (cell ? metricChartValue(trendMetric, cell[trendMetric]) : null)),
|
||||
itemStyle: { color: appColor(app.app_code, appCodes) },
|
||||
name: app.app_name,
|
||||
stack: "total",
|
||||
type: "bar"
|
||||
}));
|
||||
return buildStackedBarOption({ bucketKeys: appBuckets.keys, series, valueMetric: trendMetric });
|
||||
}, [appBuckets, appCodes, trendMetric]);
|
||||
|
||||
const channelOption = useMemo(() => {
|
||||
const series = CHANNEL_METRICS.map((key, index) => ({
|
||||
barMaxWidth: 26,
|
||||
data: bucketAggregates.map((bucket) => metricChartValue(key, bucket.metrics[key])),
|
||||
itemStyle: { color: CHANNEL_COLORS[index] },
|
||||
name: metricLabel(key),
|
||||
stack: "channel",
|
||||
type: "bar"
|
||||
}));
|
||||
return buildStackedBarOption({ bucketKeys: bucketAggregates.map((bucket) => bucket.key), series, valueMetric: CHANNEL_METRICS[0] });
|
||||
}, [bucketAggregates]);
|
||||
|
||||
const hasChannelData = useMemo(
|
||||
() => bucketAggregates.some((bucket) => CHANNEL_METRICS.some((key) => Number(bucket.metrics[key] ?? 0) > 0)),
|
||||
[bucketAggregates]
|
||||
);
|
||||
|
||||
const compareRows = useMemo(() => {
|
||||
let rows;
|
||||
if (derived.regionScoped) {
|
||||
const byApp = new Map();
|
||||
derived.regionTotals.forEach((row) => {
|
||||
if (!byApp.has(row.app_code)) {
|
||||
byApp.set(row.app_code, { app_code: row.app_code, app_name: row.app_name, rows: [] });
|
||||
}
|
||||
byApp.get(row.app_code).rows.push(row);
|
||||
});
|
||||
rows = [...byApp.values()].map((entry) => ({
|
||||
app_code: entry.app_code,
|
||||
app_name: entry.app_name,
|
||||
metrics: aggregateMetricMaps(entry.rows)
|
||||
}));
|
||||
} else {
|
||||
rows = derived.appTotals.map((row) => ({ app_code: row.app_code, app_name: row.app_name, metrics: row }));
|
||||
}
|
||||
return rows.sort((a, b) => {
|
||||
const left = Number(a.metrics[compareMetric]);
|
||||
const right = Number(b.metrics[compareMetric]);
|
||||
return (Number.isFinite(right) ? right : 0) - (Number.isFinite(left) ? left : 0);
|
||||
});
|
||||
}, [compareMetric, derived.appTotals, derived.regionScoped, derived.regionTotals]);
|
||||
|
||||
const compareTotal = compareRows.reduce((sum, row) => {
|
||||
const value = Number(row.metrics[compareMetric]);
|
||||
return Number.isFinite(value) && value > 0 ? sum + value : sum;
|
||||
}, 0);
|
||||
|
||||
const topRegions = useMemo(() => {
|
||||
return [...derived.regionTotals]
|
||||
.sort((a, b) => (Number(b.recharge_usd_minor) || 0) - (Number(a.recharge_usd_minor) || 0))
|
||||
.slice(0, 5);
|
||||
}, [derived.regionTotals]);
|
||||
const topRegionMax = topRegions.reduce((max, row) => Math.max(max, Number(row.recharge_usd_minor) || 0), 0);
|
||||
|
||||
const soloApp = !derived.regionScoped && derived.appTotals.length === 1 ? derived.appTotals[0] : null;
|
||||
const hasRestricted = derived.appTotals.some((row) => row.restricted) || derived.appErrors.some((row) => row.restricted);
|
||||
const hasTrend = appBuckets.keys.length > 0 && appBuckets.apps.length > 0;
|
||||
const trendLabel = TREND_METRICS.find((item) => item.key === trendMetric)?.label || "";
|
||||
|
||||
if (isLoading && !derived.appTotals.length && !derived.regionTotals.length) {
|
||||
return <OverviewSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="sbi-ov">
|
||||
{derived.appErrors.length || hasRestricted ? (
|
||||
<div className="sbi-ov-info">
|
||||
{derived.appErrors.map((item) => (
|
||||
<span className="sbi-badge is-warn" key={item.app_code}>
|
||||
{item.app_name}: {item.error}
|
||||
</span>
|
||||
))}
|
||||
{hasRestricted ? <span className="sbi-badge">已按你的数据范围过滤</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="sbi-ov-hero">
|
||||
{HERO_METRICS.map((metric) => (
|
||||
<HeroCard
|
||||
deltaValue={soloApp && metric.deltaKey ? soloApp[metric.deltaKey] : null}
|
||||
key={metric.key}
|
||||
metricKey={metric.key}
|
||||
spark={heroSparks[metric.key]}
|
||||
value={totals[metric.key]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="sbi-card">
|
||||
<header className="sbi-card-header">
|
||||
<strong>{trendLabel}趋势</strong>
|
||||
<small>按 App 堆叠</small>
|
||||
<div className="sbi-card-toolbar">
|
||||
<div className="sbi-seg" role="radiogroup" aria-label="趋势指标">
|
||||
{TREND_METRICS.map((item) => (
|
||||
<button
|
||||
aria-checked={trendMetric === item.key}
|
||||
className={trendMetric === item.key ? "is-active" : ""}
|
||||
key={item.key}
|
||||
onClick={() => setTrendMetric(item.key)}
|
||||
role="radio"
|
||||
title={metricTooltip(item.key)}
|
||||
type="button"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="sbi-ov-card-body">
|
||||
{hasTrend ? (
|
||||
<EChart className="sbi-ov-trend-chart" option={trendOption} />
|
||||
) : (
|
||||
<div className="sbi-empty">
|
||||
<strong>暂无趋势数据</strong>
|
||||
<span>当前筛选范围内没有日级数据,试试放宽日期或 App 筛选</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="sbi-ov-duo">
|
||||
<section className="sbi-card">
|
||||
<header className="sbi-card-header">
|
||||
<strong>渠道构成</strong>
|
||||
<small>谷歌 / 三方 / 币商充值</small>
|
||||
</header>
|
||||
<div className="sbi-ov-card-body">
|
||||
{hasChannelData ? (
|
||||
<EChart className="sbi-ov-channel-chart" option={channelOption} />
|
||||
) : (
|
||||
<div className="sbi-empty">
|
||||
<strong>暂无渠道拆分数据</strong>
|
||||
<span>所选范围内各渠道充值均为空或 0</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="sbi-card">
|
||||
<header className="sbi-card-header">
|
||||
<strong>App 对比</strong>
|
||||
<small title={metricTooltip(compareMetric)}>按{COMPARE_METRICS.find((item) => item.key === compareMetric)?.label}占比</small>
|
||||
<div className="sbi-card-toolbar">
|
||||
<div className="sbi-seg" role="radiogroup" aria-label="对比指标">
|
||||
{COMPARE_METRICS.map((item) => (
|
||||
<button
|
||||
aria-checked={compareMetric === item.key}
|
||||
className={compareMetric === item.key ? "is-active" : ""}
|
||||
key={item.key}
|
||||
onClick={() => setCompareMetric(item.key)}
|
||||
role="radio"
|
||||
title={metricTooltip(item.key)}
|
||||
type="button"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="sbi-ov-card-body">
|
||||
{compareRows.length ? (
|
||||
<div className="sbi-ov-compare">
|
||||
{compareRows.map((row) => {
|
||||
const raw = Number(row.metrics[compareMetric]);
|
||||
const share = compareTotal > 0 && Number.isFinite(raw) && raw > 0 ? (raw / compareTotal) * 100 : 0;
|
||||
const color = appColor(row.app_code, appCodes);
|
||||
return (
|
||||
<div className="sbi-ov-compare-row" key={row.app_code}>
|
||||
<span className="sbi-ov-compare-name">
|
||||
<span className="sbi-ov-dot" style={{ background: color }} />
|
||||
{row.app_name}
|
||||
</span>
|
||||
<div className="sbi-ov-bar-track">
|
||||
<div className="sbi-ov-bar-fill" style={{ background: color, width: `${share}%` }} />
|
||||
</div>
|
||||
<span className="sbi-ov-num">{formatMetric(compareMetric, row.metrics[compareMetric])}</span>
|
||||
<button
|
||||
className="sbi-ov-link"
|
||||
onClick={() => {
|
||||
updateFilter("apps", [row.app_code]);
|
||||
setViewKey("regions");
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
地区 →
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="sbi-empty">
|
||||
<strong>暂无 App 数据</strong>
|
||||
<span>调整顶部 App / 日期筛选后重试</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="sbi-card">
|
||||
<header className="sbi-card-header">
|
||||
<strong>区域 Top5</strong>
|
||||
<small title={metricTooltip("recharge_usd_minor")}>按充值降序</small>
|
||||
<div className="sbi-card-toolbar">
|
||||
<button className="sbi-ov-link" onClick={() => setViewKey("regions")} type="button">
|
||||
查看全部 →
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="sbi-ov-card-body">
|
||||
{topRegions.length ? (
|
||||
<div className="sbi-ov-top">
|
||||
{topRegions.map((row) => {
|
||||
const raw = Number(row.recharge_usd_minor);
|
||||
const share = topRegionMax > 0 && Number.isFinite(raw) && raw > 0 ? (raw / topRegionMax) * 100 : 0;
|
||||
const color = appColor(row.app_code, appCodes);
|
||||
return (
|
||||
<div className="sbi-ov-top-row" key={`${row.app_code}:${row.region_id}`}>
|
||||
<span className="sbi-ov-top-name">
|
||||
<strong>{row.region_name}</strong>
|
||||
<span className="sbi-badge">
|
||||
<span className="sbi-ov-dot" style={{ background: color }} />
|
||||
{row.app_name}
|
||||
</span>
|
||||
</span>
|
||||
<div className="sbi-ov-bar-track">
|
||||
<div className="sbi-ov-bar-fill" style={{ background: color, width: `${share}%` }} />
|
||||
</div>
|
||||
<span className="sbi-ov-num">{formatMetric("recharge_usd_minor", row.recharge_usd_minor)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="sbi-empty">
|
||||
<strong>暂无区域数据</strong>
|
||||
<span>该范围未配置区域目录,或你没有对应区域的数据权限</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
465
databi/src/social/views/RegionsView.jsx
Normal file
465
databi/src/social/views/RegionsView.jsx
Normal file
@ -0,0 +1,465 @@
|
||||
// RegionsView(地区洞察):概要条 + 世界地图(国家粒度)+ 区域卡片网格 + 区域→国家下钻表。
|
||||
// 数据契约见 SocialBiApp.jsx 的 useSocialBi();金额为 USD 分、比例为 0-1,渲染时统一走 metrics/format 层。
|
||||
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { VisualMapComponent } from "echarts/components";
|
||||
import * as echarts from "echarts/core";
|
||||
import { EChart } from "../../charts/EChart.jsx";
|
||||
import { resolveCountryMeta } from "../../data/countryMeta.js";
|
||||
import worldMap from "../../data/world.json";
|
||||
import { formatCount, formatMoneyMinor, formatRatioPercent, isBlank } from "../format.js";
|
||||
import { aggregateMetricMaps, appColor, formatMetric, metricChartValue, metricLabel, metricTooltip } from "../metrics.js";
|
||||
import { useSocialBi } from "../SocialBiApp.jsx";
|
||||
import "./regions-view.css";
|
||||
|
||||
// EChart.jsx 未注册 visualMap 组件,这里按需补注册(echarts.use 幂等,不影响其它图表)。
|
||||
echarts.use([VisualMapComponent]);
|
||||
|
||||
const MAP_METRICS = [
|
||||
{ key: "recharge_usd_minor", label: "充值" },
|
||||
{ key: "new_users", label: "新增" },
|
||||
{ key: "active_users", label: "日活" }
|
||||
];
|
||||
|
||||
const CARD_METRICS = ["new_users", "active_users", "arppu_usd_minor"];
|
||||
|
||||
const TABLE_METRICS = [
|
||||
"recharge_usd_minor",
|
||||
"new_users",
|
||||
"active_users",
|
||||
"paid_users",
|
||||
"arppu_usd_minor",
|
||||
"d1_retention_rate",
|
||||
"gift_coin_spent"
|
||||
];
|
||||
|
||||
// databi-world 的合法地名索引(小写 → 原始 feature name),用于把 ISO alpha-2 / 后端国家名归一成地图地名。
|
||||
const MAP_NAME_INDEX = new Map();
|
||||
(worldMap.features || []).forEach((feature) => {
|
||||
const name = feature?.properties?.name;
|
||||
if (name) {
|
||||
MAP_NAME_INDEX.set(String(name).trim().toLowerCase(), name);
|
||||
}
|
||||
});
|
||||
|
||||
// 复用 countryMeta 的 ISO alpha-2 → 备选英文/中文名列表,再校验哪一个是 databi-world 的 feature name。
|
||||
function resolveMapName(countryCode, countryName) {
|
||||
const meta = resolveCountryMeta({ code: countryCode, name: countryName });
|
||||
const candidates = [...(meta?.names || []), countryName];
|
||||
for (const candidate of candidates) {
|
||||
const hit = MAP_NAME_INDEX.get(String(candidate || "").trim().toLowerCase());
|
||||
if (hit) {
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function countryDisplayName(countryCode, countryName) {
|
||||
const meta = resolveCountryMeta({ code: countryCode, name: countryName });
|
||||
return meta?.label || countryName || countryCode || "未知国家";
|
||||
}
|
||||
|
||||
function rechargeValue(row) {
|
||||
return isBlank(row?.recharge_usd_minor) ? -1 : Number(row.recharge_usd_minor);
|
||||
}
|
||||
|
||||
function byRechargeDesc(a, b) {
|
||||
return rechargeValue(b) - rechargeValue(a);
|
||||
}
|
||||
|
||||
export function RegionsView() {
|
||||
const { appCodes, derived, isLoading } = useSocialBi();
|
||||
const { appTotals, countryTotals, regionTotals } = derived;
|
||||
const [mapMetric, setMapMetric] = useState("recharge_usd_minor");
|
||||
const [expandedRegions, setExpandedRegions] = useState(() => new Set());
|
||||
|
||||
const regionRows = useMemo(() => [...regionTotals].sort(byRechargeDesc), [regionTotals]);
|
||||
|
||||
const regionSummary = useMemo(() => aggregateMetricMaps(regionTotals), [regionTotals]);
|
||||
|
||||
const regionCount = useMemo(
|
||||
() => new Set(regionTotals.map((row) => `${row.app_code}:${row.region_id}`)).size,
|
||||
[regionTotals]
|
||||
);
|
||||
|
||||
const appCount = useMemo(() => new Set(regionTotals.map((row) => row.app_code)).size, [regionTotals]);
|
||||
|
||||
const countryCount = useMemo(() => {
|
||||
const codes = new Set();
|
||||
countryTotals.forEach((row) => {
|
||||
const key = String(row.country_code || row.country_name || "").trim();
|
||||
if (key) {
|
||||
codes.add(key);
|
||||
}
|
||||
});
|
||||
return codes.size;
|
||||
}, [countryTotals]);
|
||||
|
||||
// 每个 App 的总充值(占比条分母):优先 appTotals 的整 App 口径,缺失时退化为可见区域行求和。
|
||||
const appRechargeTotals = useMemo(() => {
|
||||
const totals = new Map();
|
||||
appTotals.forEach((row) => {
|
||||
if (!isBlank(row.recharge_usd_minor)) {
|
||||
totals.set(row.app_code, Number(row.recharge_usd_minor));
|
||||
}
|
||||
});
|
||||
const fallback = new Map();
|
||||
regionTotals.forEach((row) => {
|
||||
if (!isBlank(row.recharge_usd_minor)) {
|
||||
fallback.set(row.app_code, (fallback.get(row.app_code) || 0) + Number(row.recharge_usd_minor));
|
||||
}
|
||||
});
|
||||
fallback.forEach((value, code) => {
|
||||
if (!totals.has(code)) {
|
||||
totals.set(code, value);
|
||||
}
|
||||
});
|
||||
return totals;
|
||||
}, [appTotals, regionTotals]);
|
||||
|
||||
// 下钻子行:countryTotals 按 app_code + region_id 分组(region_id 缺失已在数据层归一成 0,对应"未划分区域")。
|
||||
const countriesByRegion = useMemo(() => {
|
||||
const groups = new Map();
|
||||
countryTotals.forEach((row) => {
|
||||
const key = `${row.app_code}:${Number(row.region_id ?? 0)}`;
|
||||
const bucket = groups.get(key) || [];
|
||||
bucket.push(row);
|
||||
groups.set(key, bucket);
|
||||
});
|
||||
groups.forEach((rows) => rows.sort(byRechargeDesc));
|
||||
return groups;
|
||||
}, [countryTotals]);
|
||||
|
||||
// 地图数据:跨 App 同国家先按地图地名合桶,再用 aggregateMetricMaps 合并(比例按分子分母重算)。
|
||||
const mapIndex = useMemo(() => {
|
||||
const buckets = new Map();
|
||||
const unmatched = new Set();
|
||||
countryTotals.forEach((row) => {
|
||||
const mapName = resolveMapName(row.country_code, row.country_name);
|
||||
if (!mapName) {
|
||||
unmatched.add(String(row.country_code || row.country_name || "?"));
|
||||
return;
|
||||
}
|
||||
const bucket = buckets.get(mapName) || { label: countryDisplayName(row.country_code, row.country_name), rows: [] };
|
||||
bucket.rows.push(row);
|
||||
buckets.set(mapName, bucket);
|
||||
});
|
||||
const entries = new Map();
|
||||
buckets.forEach((bucket, mapName) => {
|
||||
entries.set(mapName, { label: bucket.label, metrics: aggregateMetricMaps(bucket.rows) });
|
||||
});
|
||||
return { entries, unmatchedCount: unmatched.size };
|
||||
}, [countryTotals]);
|
||||
|
||||
const mapOption = useMemo(() => {
|
||||
const data = [];
|
||||
mapIndex.entries.forEach((entry, mapName) => {
|
||||
const value = metricChartValue(mapMetric, entry.metrics[mapMetric]);
|
||||
if (value !== null) {
|
||||
data.push({ name: mapName, value });
|
||||
}
|
||||
});
|
||||
const max = data.reduce((acc, item) => Math.max(acc, item.value), 0) || 1;
|
||||
return {
|
||||
series: [
|
||||
{
|
||||
data,
|
||||
emphasis: { itemStyle: { areaColor: "#93b6f2" }, label: { show: false } },
|
||||
itemStyle: { areaColor: "#e9eef5", borderColor: "#d5dfeb", borderWidth: 0.6 },
|
||||
label: { show: false },
|
||||
map: "databi-world",
|
||||
name: metricLabel(mapMetric),
|
||||
roam: false,
|
||||
select: { disabled: true },
|
||||
type: "map"
|
||||
}
|
||||
],
|
||||
tooltip: {
|
||||
backgroundColor: "#ffffff",
|
||||
borderColor: "#e3eaf3",
|
||||
extraCssText: "box-shadow: 0 4px 16px rgba(16, 34, 56, 0.12);",
|
||||
formatter: (params) => {
|
||||
const entry = mapIndex.entries.get(params.name);
|
||||
if (!entry) {
|
||||
return `${params.name}<br/>暂无数据`;
|
||||
}
|
||||
return `${entry.label}<br/>${metricLabel(mapMetric)}:${formatMetric(mapMetric, entry.metrics[mapMetric])}`;
|
||||
},
|
||||
textStyle: { color: "#14263c", fontSize: 12 }
|
||||
},
|
||||
visualMap: {
|
||||
bottom: 10,
|
||||
calculable: false,
|
||||
inRange: { color: ["#dbeafe", "#2557d6"] },
|
||||
itemHeight: 110,
|
||||
itemWidth: 10,
|
||||
left: 12,
|
||||
max,
|
||||
min: 0,
|
||||
orient: "horizontal",
|
||||
text: ["高", "低"],
|
||||
textStyle: { color: "#5b7089", fontSize: 11 }
|
||||
}
|
||||
};
|
||||
}, [mapIndex, mapMetric]);
|
||||
|
||||
const toggleRegion = (key) => {
|
||||
setExpandedRegions((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading && !regionTotals.length && !countryTotals.length) {
|
||||
return <RegionsSkeleton />;
|
||||
}
|
||||
|
||||
if (!regionTotals.length) {
|
||||
return (
|
||||
<section className="sbi-card">
|
||||
<div className="sbi-empty">
|
||||
<strong>暂无区域数据</strong>
|
||||
<span>检查顶栏区域筛选,或该 App 尚未配置区域目录(legacy App 在其 Mongo sys_region_config,hyapp 在区域管理)</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sbi-rg-summary">
|
||||
<section className="sbi-card sbi-rg-summary-card">
|
||||
<span className="sbi-rg-summary-label">覆盖区域数</span>
|
||||
<span className="sbi-rg-summary-value">{formatCount(regionCount)}</span>
|
||||
<span className="sbi-rg-summary-sub">按 App × 区域去重 · 覆盖 {formatCount(appCount)} 个 App</span>
|
||||
</section>
|
||||
<section className="sbi-card sbi-rg-summary-card">
|
||||
<span className="sbi-rg-summary-label">覆盖国家数</span>
|
||||
<span className="sbi-rg-summary-value">{formatCount(countryCount)}</span>
|
||||
<span className="sbi-rg-summary-sub">countryTotals 按国家去重(跨 App 合并)</span>
|
||||
</section>
|
||||
<section className="sbi-card sbi-rg-summary-card">
|
||||
<span className="sbi-rg-summary-label" title={metricTooltip("recharge_usd_minor")}>
|
||||
区域充值合计
|
||||
</span>
|
||||
<span className="sbi-rg-summary-value">{formatMoneyMinor(regionSummary.recharge_usd_minor)}</span>
|
||||
<span className="sbi-rg-summary-sub">当前区间 · 可见区域行合计</span>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="sbi-card sbi-rg-map-card">
|
||||
<div className="sbi-card-header">
|
||||
<strong>全球分布</strong>
|
||||
<small>
|
||||
{countryCount ? `${formatCount(countryCount)} 个国家 · 跨 App 合并` : "国家粒度"}
|
||||
{mapIndex.unmatchedCount ? ` · ${mapIndex.unmatchedCount} 个国家未匹配到地图` : ""}
|
||||
</small>
|
||||
<div className="sbi-card-toolbar">
|
||||
<div className="sbi-seg" role="radiogroup" aria-label="地图指标">
|
||||
{MAP_METRICS.map((item) => (
|
||||
<button
|
||||
aria-checked={mapMetric === item.key}
|
||||
className={mapMetric === item.key ? "is-active" : ""}
|
||||
key={item.key}
|
||||
onClick={() => setMapMetric(item.key)}
|
||||
role="radio"
|
||||
title={metricTooltip(item.key)}
|
||||
type="button"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sbi-rg-map-body">
|
||||
{mapIndex.entries.size ? (
|
||||
<EChart className="sbi-rg-map-chart" option={mapOption} />
|
||||
) : (
|
||||
<div className="sbi-empty">
|
||||
<strong>暂无国家数据</strong>
|
||||
<span>当前区间没有国家粒度数据,或国家编码未能匹配到世界地图</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="sbi-rg-grid">
|
||||
{regionRows.map((row) => {
|
||||
const key = `${row.app_code}:${row.region_id}`;
|
||||
const appTotal = appRechargeTotals.get(row.app_code);
|
||||
const share = appTotal && !isBlank(row.recharge_usd_minor) ? Number(row.recharge_usd_minor) / appTotal : null;
|
||||
return (
|
||||
<section className="sbi-card sbi-rg-region-card" key={key}>
|
||||
<div className="sbi-rg-region-head">
|
||||
<span className="sbi-rg-region-name" title={row.region_name}>
|
||||
{row.region_name}
|
||||
</span>
|
||||
{row.restricted ? (
|
||||
<span className="sbi-badge" title="数据按你的负责范围裁剪">
|
||||
范围裁剪
|
||||
</span>
|
||||
) : null}
|
||||
<span className="sbi-rg-app-tag">
|
||||
<span className="sbi-rg-app-dot" style={{ background: appColor(row.app_code, appCodes) }} />
|
||||
{row.app_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="sbi-rg-region-money">
|
||||
<small className="sbi-metric-hint" title={metricTooltip("recharge_usd_minor")}>
|
||||
{metricLabel("recharge_usd_minor")}
|
||||
</small>
|
||||
<strong>{formatMoneyMinor(row.recharge_usd_minor)}</strong>
|
||||
</div>
|
||||
<div className="sbi-rg-region-metrics">
|
||||
{CARD_METRICS.map((metric) => (
|
||||
<div className="sbi-rg-region-metric" key={metric}>
|
||||
<small className="sbi-metric-hint" title={metricTooltip(metric)}>
|
||||
{metricLabel(metric)}
|
||||
</small>
|
||||
<span>{formatMetric(metric, row[metric])}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="sbi-rg-share">
|
||||
<span className="sbi-rg-share-head">
|
||||
<span>占 {row.app_name} 总充值</span>
|
||||
<span>{share === null ? "--" : formatRatioPercent(share)}</span>
|
||||
</span>
|
||||
<span className="sbi-rg-share-track">
|
||||
<span
|
||||
className="sbi-rg-share-fill"
|
||||
style={{ width: share === null ? 0 : `${Math.min(100, Math.max(0, share * 100))}%` }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<section className="sbi-card sbi-rg-table-card">
|
||||
<div className="sbi-card-header">
|
||||
<strong>区域下钻</strong>
|
||||
<small>点击区域行展开国家明细 · 按充值降序</small>
|
||||
</div>
|
||||
<div className="sbi-table-scroll">
|
||||
<table className="sbi-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="is-left">地区 / 国家</th>
|
||||
<th className="is-left">App</th>
|
||||
{TABLE_METRICS.map((metric) => (
|
||||
<th key={metric}>
|
||||
<span className="sbi-metric-hint" title={metricTooltip(metric)}>
|
||||
{metricLabel(metric)}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="is-total">
|
||||
<td className="is-left">全部区域</td>
|
||||
<td className="is-left">{formatCount(appCount)} 个 App</td>
|
||||
{TABLE_METRICS.map((metric) => (
|
||||
<td key={metric}>{formatMetric(metric, regionSummary[metric])}</td>
|
||||
))}
|
||||
</tr>
|
||||
{regionRows.map((row) => {
|
||||
const key = `${row.app_code}:${row.region_id}`;
|
||||
const isOpen = expandedRegions.has(key);
|
||||
const children = isOpen ? countriesByRegion.get(key) || [] : [];
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
<tr aria-expanded={isOpen} className="sbi-rg-row" onClick={() => toggleRegion(key)}>
|
||||
<td className="is-left">
|
||||
<span className="sbi-rg-region-cell">
|
||||
<span aria-hidden="true" className={isOpen ? "sbi-rg-caret is-open" : "sbi-rg-caret"}>
|
||||
▸
|
||||
</span>
|
||||
{row.region_name}
|
||||
{row.restricted ? (
|
||||
<span className="sbi-badge" title="数据按你的负责范围裁剪">
|
||||
范围裁剪
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</td>
|
||||
<td className="is-left">
|
||||
<span className="sbi-rg-app-tag">
|
||||
<span className="sbi-rg-app-dot" style={{ background: appColor(row.app_code, appCodes) }} />
|
||||
{row.app_name}
|
||||
</span>
|
||||
</td>
|
||||
{TABLE_METRICS.map((metric) => (
|
||||
<td key={metric}>{formatMetric(metric, row[metric])}</td>
|
||||
))}
|
||||
</tr>
|
||||
{isOpen && !children.length ? (
|
||||
<tr className="sbi-rg-child">
|
||||
<td className="is-left" colSpan={2 + TABLE_METRICS.length}>
|
||||
<span className="sbi-rg-child-name">该区域暂无国家明细</span>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{children.map((child) => (
|
||||
<tr className="sbi-rg-child" key={`${key}:${child.country_code || child.country_name}`}>
|
||||
<td className="is-left">
|
||||
<span className="sbi-rg-child-name">{countryDisplayName(child.country_code, child.country_name)}</span>
|
||||
</td>
|
||||
<td className="is-left sbi-rg-child-app">{child.app_name}</td>
|
||||
{TABLE_METRICS.map((metric) => (
|
||||
<td key={metric}>{formatMetric(metric, child[metric])}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionsSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<div className="sbi-rg-summary" aria-busy="true">
|
||||
{[0, 1, 2].map((index) => (
|
||||
<section className="sbi-card sbi-rg-summary-card" key={index}>
|
||||
<span className="sbi-skeleton" style={{ height: 12, width: 90 }} />
|
||||
<span className="sbi-skeleton" style={{ height: 26, width: 140 }} />
|
||||
<span className="sbi-skeleton" style={{ height: 11, width: 170 }} />
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<section className="sbi-card sbi-rg-map-card" aria-busy="true">
|
||||
<div className="sbi-card-header">
|
||||
<span className="sbi-skeleton" style={{ height: 14, width: 120 }} />
|
||||
</div>
|
||||
<div className="sbi-rg-map-body">
|
||||
<span className="sbi-skeleton" style={{ display: "block", height: "100%", width: "100%" }} />
|
||||
</div>
|
||||
</section>
|
||||
<div className="sbi-rg-grid" aria-busy="true">
|
||||
{[0, 1, 2].map((index) => (
|
||||
<section className="sbi-card sbi-rg-region-card" key={index}>
|
||||
<span className="sbi-skeleton" style={{ height: 14, width: 150 }} />
|
||||
<span className="sbi-skeleton" style={{ height: 24, width: 130 }} />
|
||||
<span className="sbi-skeleton" style={{ height: 34, width: "100%" }} />
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
400
databi/src/social/views/RetentionView.jsx
Normal file
400
databi/src/social/views/RetentionView.jsx
Normal file
@ -0,0 +1,400 @@
|
||||
// RetentionView(留存质量):整体 D1/D7/D30 概要卡、新增×次日留存组合图、
|
||||
// 留存趋势(汇总/单 App)、App/区域留存热力对比表。数据契约见 SocialBiApp.jsx 的 useSocialBi()。
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSocialBi } from "../SocialBiApp.jsx";
|
||||
import { EChart } from "../../charts/EChart.jsx";
|
||||
import { aggregateMetricMaps, formatMetric, metricChartValue, metricLabel, metricTooltip } from "../metrics.js";
|
||||
import { formatCount, formatPercentValue, formatRatioPercent, isBlank } from "../format.js";
|
||||
import { ALL, bucketDay } from "../state.js";
|
||||
import "./retention-view.css";
|
||||
|
||||
// 图表 canvas 内无法读 CSS 变量,这里用与 social-v2.css token 一致的十六进制值。
|
||||
const CHART_ACCENT = "#2557d6"; // var(--sbi-accent)
|
||||
const CHART_ACCENT_2 = "#0e7490"; // var(--sbi-accent-2)
|
||||
const CHART_GRID_LINE = "#eef3f9";
|
||||
const CHART_TEXT = "#5b7089";
|
||||
const CHART_GRID = { bottom: 4, containLabel: true, left: 8, right: 8, top: 28 };
|
||||
const CHART_LEGEND = { itemHeight: 9, itemWidth: 14, textStyle: { color: CHART_TEXT, fontSize: 12 }, top: 0 };
|
||||
const CHART_TOOLTIP = { backgroundColor: "#ffffff", borderColor: CHART_GRID_LINE, textStyle: { color: CHART_TEXT }, trigger: "axis" };
|
||||
|
||||
const RETENTION_SERIES = [
|
||||
{ color: CHART_ACCENT, key: "d1_retention_rate" },
|
||||
{ color: CHART_ACCENT_2, key: "d7_retention_rate" },
|
||||
{ color: "#7c3aed", key: "d30_retention_rate" }
|
||||
];
|
||||
|
||||
const HEAT_KEYS = new Set(["d1_retention_rate", "d7_retention_rate", "d30_retention_rate"]);
|
||||
const TABLE_METRIC_KEYS = [
|
||||
"new_users",
|
||||
"active_users",
|
||||
"d1_retention_rate",
|
||||
"d7_retention_rate",
|
||||
"d30_retention_rate",
|
||||
"paid_conversion_rate"
|
||||
];
|
||||
|
||||
const GRANULARITY_LABELS = { day: "日", month: "月", week: "周" };
|
||||
|
||||
// 把日粒度行按 bucketDay 分桶后逐桶聚合(比例按分子分母重算),桶 key 升序。
|
||||
function bucketAggregates(rows, granularity) {
|
||||
const map = new Map();
|
||||
rows.forEach((row) => {
|
||||
const bucket = bucketDay(row.stat_day, granularity);
|
||||
const list = map.get(bucket);
|
||||
if (list) {
|
||||
list.push(row);
|
||||
} else {
|
||||
map.set(bucket, [row]);
|
||||
}
|
||||
});
|
||||
const buckets = [...map.keys()].sort();
|
||||
return { aggregates: buckets.map((key) => aggregateMetricMaps(map.get(key))), buckets };
|
||||
}
|
||||
|
||||
function categoryAxis(buckets) {
|
||||
return {
|
||||
axisLabel: { color: CHART_TEXT },
|
||||
axisLine: { lineStyle: { color: CHART_GRID_LINE } },
|
||||
axisTick: { show: false },
|
||||
data: buckets,
|
||||
type: "category"
|
||||
};
|
||||
}
|
||||
|
||||
// 留存热力单元格:值越高蓝底越深(上限 0.85),深度超过 0.35 换白字保证对比度;null 无底色显示 "--"。
|
||||
function renderMetricCell(key, value) {
|
||||
if (!HEAT_KEYS.has(key)) {
|
||||
return <td key={key}>{formatMetric(key, value)}</td>;
|
||||
}
|
||||
if (isBlank(value)) {
|
||||
return <td key={key}>--</td>;
|
||||
}
|
||||
const depth = Math.min(0.85, Number(value) * 2);
|
||||
const style = { backgroundColor: `rgba(37, 87, 214, ${Number(depth.toFixed(3))})` };
|
||||
if (depth > 0.35) {
|
||||
style.color = "#ffffff";
|
||||
}
|
||||
return (
|
||||
<td key={key} style={style}>
|
||||
{formatRatioPercent(value)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
export function RetentionView() {
|
||||
const { derived, filters, isLoading } = useSocialBi();
|
||||
const { appDaily, appTotals, regionDaily, regionScoped, regionTotals } = derived;
|
||||
const [trendApp, setTrendApp] = useState(ALL);
|
||||
const [tableDim, setTableDim] = useState("app");
|
||||
|
||||
const granularityLabel = GRANULARITY_LABELS[filters.granularity] || "日";
|
||||
// 筛选变化后所选 App 可能不在结果里,回退到汇总(不直接改 state,渲染期兜底即可)。
|
||||
const activeTrendApp = trendApp !== ALL && appTotals.some((row) => row.app_code === trendApp) ? trendApp : ALL;
|
||||
// 选了具体区域时,全视图(概要/图表/对比表)统一切到区域行口径,避免同屏两套数字。
|
||||
const dailyRows = regionScoped ? regionDaily : appDaily;
|
||||
|
||||
// 1) 概要卡:选了具体区域时 App 级合计改用区域行聚合,与顶栏语义一致。
|
||||
const summary = useMemo(() => {
|
||||
const rows = regionScoped ? regionTotals : appTotals;
|
||||
return rows.length ? aggregateMetricMaps(rows) : null;
|
||||
}, [appTotals, regionScoped, regionTotals]);
|
||||
|
||||
// 2) 新增(柱/左轴)× 次日留存率(线/右轴),按全局粒度分桶;null 让线断开。
|
||||
const comboOption = useMemo(() => {
|
||||
const { aggregates, buckets } = bucketAggregates(dailyRows, filters.granularity);
|
||||
if (!buckets.length) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
grid: { ...CHART_GRID },
|
||||
legend: { ...CHART_LEGEND },
|
||||
series: [
|
||||
{
|
||||
barMaxWidth: 26,
|
||||
data: aggregates.map((row) => metricChartValue("new_users", row.new_users)),
|
||||
itemStyle: { borderRadius: [3, 3, 0, 0], color: CHART_ACCENT },
|
||||
name: metricLabel("new_users"),
|
||||
tooltip: { valueFormatter: (value) => formatCount(value) },
|
||||
type: "bar"
|
||||
},
|
||||
{
|
||||
connectNulls: false,
|
||||
data: aggregates.map((row) => metricChartValue("d1_retention_rate", row.d1_retention_rate)),
|
||||
itemStyle: { color: CHART_ACCENT_2 },
|
||||
lineStyle: { color: CHART_ACCENT_2, width: 2.5 },
|
||||
name: metricLabel("d1_retention_rate"),
|
||||
symbol: "circle",
|
||||
symbolSize: 6,
|
||||
tooltip: { valueFormatter: (value) => formatPercentValue(value) },
|
||||
type: "line",
|
||||
yAxisIndex: 1
|
||||
}
|
||||
],
|
||||
tooltip: { ...CHART_TOOLTIP, axisPointer: { type: "shadow" } },
|
||||
xAxis: categoryAxis(buckets),
|
||||
yAxis: [
|
||||
{
|
||||
axisLabel: { color: CHART_TEXT },
|
||||
splitLine: { lineStyle: { color: CHART_GRID_LINE } },
|
||||
type: "value"
|
||||
},
|
||||
{
|
||||
axisLabel: { color: CHART_TEXT, formatter: "{value}%" },
|
||||
splitLine: { show: false },
|
||||
type: "value"
|
||||
}
|
||||
]
|
||||
};
|
||||
}, [dailyRows, filters.granularity]);
|
||||
|
||||
// 3) D1/D7/D30 留存趋势:汇总或单 App 的日行分桶聚合(区域筛选时用区域日行)。
|
||||
const trendOption = useMemo(() => {
|
||||
const rows = activeTrendApp === ALL ? dailyRows : dailyRows.filter((row) => row.app_code === activeTrendApp);
|
||||
const { aggregates, buckets } = bucketAggregates(rows, filters.granularity);
|
||||
if (!buckets.length) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
grid: { ...CHART_GRID },
|
||||
legend: { ...CHART_LEGEND },
|
||||
series: RETENTION_SERIES.map(({ color, key }) => ({
|
||||
connectNulls: false,
|
||||
data: aggregates.map((row) => metricChartValue(key, row[key])),
|
||||
itemStyle: { color },
|
||||
lineStyle: { color, width: 2.5 },
|
||||
name: metricLabel(key),
|
||||
symbol: "circle",
|
||||
symbolSize: 5,
|
||||
tooltip: { valueFormatter: (value) => formatPercentValue(value) },
|
||||
type: "line"
|
||||
})),
|
||||
tooltip: { ...CHART_TOOLTIP },
|
||||
xAxis: categoryAxis(buckets),
|
||||
yAxis: {
|
||||
axisLabel: { color: CHART_TEXT, formatter: "{value}%" },
|
||||
splitLine: { lineStyle: { color: CHART_GRID_LINE } },
|
||||
type: "value"
|
||||
}
|
||||
};
|
||||
}, [activeTrendApp, dailyRows, filters.granularity]);
|
||||
|
||||
// 4) 热力对比表:按 App / 按区域;区域筛选时"按 App"改为区域行按 App 聚合,保持口径一致。
|
||||
const tableSource = useMemo(() => {
|
||||
if (tableDim === "region") {
|
||||
return regionTotals;
|
||||
}
|
||||
if (!regionScoped) {
|
||||
return appTotals;
|
||||
}
|
||||
const byApp = new Map();
|
||||
regionTotals.forEach((row) => {
|
||||
const list = byApp.get(row.app_code) || [];
|
||||
list.push(row);
|
||||
byApp.set(row.app_code, list);
|
||||
});
|
||||
return [...byApp.entries()].map(([appCode, rows]) => ({
|
||||
app_code: appCode,
|
||||
app_name: rows[0].app_name,
|
||||
...aggregateMetricMaps(rows)
|
||||
}));
|
||||
}, [appTotals, regionScoped, regionTotals, tableDim]);
|
||||
|
||||
const tableRows = useMemo(() => {
|
||||
return tableSource
|
||||
.map((row) => ({
|
||||
key: tableDim === "region" ? `${row.app_code}:${row.region_id}` : row.app_code,
|
||||
label: tableDim === "region" ? `${row.app_name}·${row.region_name}` : row.app_name,
|
||||
row
|
||||
}))
|
||||
.sort((a, b) => (Number(b.row.new_users) || 0) - (Number(a.row.new_users) || 0));
|
||||
}, [tableDim, tableSource]);
|
||||
|
||||
const tableTotal = useMemo(() => {
|
||||
return tableSource.length ? aggregateMetricMaps(tableSource) : null;
|
||||
}, [tableSource]);
|
||||
|
||||
const hasRows = appTotals.length > 0 || appDaily.length > 0 || regionTotals.length > 0;
|
||||
|
||||
if (isLoading && !hasRows) {
|
||||
return (
|
||||
<div className="sbi-rt-view">
|
||||
<div className="sbi-rt-summary">
|
||||
{RETENTION_SERIES.map(({ key }) => (
|
||||
<section className="sbi-card sbi-rt-summary-card" key={key}>
|
||||
<span className="sbi-skeleton sbi-rt-skeleton-line" />
|
||||
<span className="sbi-skeleton sbi-rt-skeleton-value" />
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<section className="sbi-card">
|
||||
<span className="sbi-skeleton sbi-rt-skeleton-chart" />
|
||||
</section>
|
||||
<section className="sbi-card">
|
||||
<span className="sbi-skeleton sbi-rt-skeleton-chart" />
|
||||
</section>
|
||||
<section className="sbi-card">
|
||||
<span className="sbi-skeleton sbi-rt-skeleton-table" />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasRows) {
|
||||
return (
|
||||
<div className="sbi-rt-view">
|
||||
<section className="sbi-card">
|
||||
<div className="sbi-empty">
|
||||
<strong>暂无留存数据</strong>
|
||||
<span>当前筛选没有返回任何 App 数据,试试调整日期区间或放宽 App/区域筛选。</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sbi-rt-view">
|
||||
<div className="sbi-rt-summary">
|
||||
{RETENTION_SERIES.map(({ key }) => {
|
||||
const baseUsers = summary?.[key.replace("_retention_rate", "_retention_base_users")];
|
||||
return (
|
||||
<section className="sbi-card sbi-rt-summary-card" key={key}>
|
||||
<span className="sbi-rt-summary-label sbi-metric-hint" title={metricTooltip(key)}>
|
||||
整体{metricLabel(key)}
|
||||
</span>
|
||||
<span className="sbi-rt-summary-value">{formatRatioPercent(summary?.[key])}</span>
|
||||
<span className="sbi-rt-summary-note">
|
||||
{regionScoped ? "所选区域汇总" : "全部所选 App 汇总"}
|
||||
{isBlank(baseUsers) ? "" : ` · cohort 样本 ${formatCount(baseUsers)} 人`}
|
||||
</span>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<section className="sbi-card">
|
||||
<div className="sbi-card-header">
|
||||
<strong>新增与次日留存</strong>
|
||||
<small>柱=新增(左轴)· 线=次日留存率(右轴)· 按{granularityLabel}分桶</small>
|
||||
</div>
|
||||
{comboOption ? (
|
||||
<EChart className="sbi-rt-chart-combo" option={comboOption} />
|
||||
) : (
|
||||
<div className="sbi-empty">
|
||||
<strong>暂无按日数据</strong>
|
||||
<span>当前筛选没有返回按日序列,无法绘制新增与留存趋势。</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="sbi-card">
|
||||
<div className="sbi-card-header">
|
||||
<strong>留存趋势</strong>
|
||||
<small>D1 / D7 / D30 · 按{granularityLabel}分桶</small>
|
||||
<div className="sbi-card-toolbar">
|
||||
<div className="sbi-seg" role="radiogroup" aria-label="留存趋势维度">
|
||||
<button
|
||||
aria-checked={activeTrendApp === ALL}
|
||||
className={activeTrendApp === ALL ? "is-active" : ""}
|
||||
onClick={() => setTrendApp(ALL)}
|
||||
role="radio"
|
||||
type="button"
|
||||
>
|
||||
汇总
|
||||
</button>
|
||||
{appTotals.map((row) => (
|
||||
<button
|
||||
aria-checked={activeTrendApp === row.app_code}
|
||||
className={activeTrendApp === row.app_code ? "is-active" : ""}
|
||||
key={row.app_code}
|
||||
onClick={() => setTrendApp(row.app_code)}
|
||||
role="radio"
|
||||
type="button"
|
||||
>
|
||||
{row.app_name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{trendOption ? (
|
||||
<EChart className="sbi-rt-chart-trend" option={trendOption} />
|
||||
) : (
|
||||
<div className="sbi-empty">
|
||||
<strong>暂无留存趋势数据</strong>
|
||||
<span>{activeTrendApp === ALL ? "当前筛选没有返回按日序列。" : "该 App 在当前区间没有按日序列。"}</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="sbi-card sbi-rt-table-card">
|
||||
<div className="sbi-card-header">
|
||||
<strong>留存对比</strong>
|
||||
<small>D1/D7/D30 底色越深留存越高</small>
|
||||
<div className="sbi-card-toolbar">
|
||||
<div className="sbi-seg" role="radiogroup" aria-label="留存对比维度">
|
||||
<button
|
||||
aria-checked={tableDim === "app"}
|
||||
className={tableDim === "app" ? "is-active" : ""}
|
||||
onClick={() => setTableDim("app")}
|
||||
role="radio"
|
||||
type="button"
|
||||
>
|
||||
按 App
|
||||
</button>
|
||||
<button
|
||||
aria-checked={tableDim === "region"}
|
||||
className={tableDim === "region" ? "is-active" : ""}
|
||||
onClick={() => setTableDim("region")}
|
||||
role="radio"
|
||||
type="button"
|
||||
>
|
||||
按区域
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{tableRows.length ? (
|
||||
<div className="sbi-table-scroll">
|
||||
<table className="sbi-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="is-left">对象</th>
|
||||
{TABLE_METRIC_KEYS.map((key) => (
|
||||
<th key={key}>
|
||||
<span className="sbi-metric-hint" title={metricTooltip(key)}>
|
||||
{metricLabel(key)}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="is-total">
|
||||
<td className="is-left">合计</td>
|
||||
{TABLE_METRIC_KEYS.map((key) => renderMetricCell(key, tableTotal?.[key]))}
|
||||
</tr>
|
||||
{tableRows.map((item) => (
|
||||
<tr key={item.key}>
|
||||
<td className="is-left">{item.label}</td>
|
||||
{TABLE_METRIC_KEYS.map((key) => renderMetricCell(key, item.row[key]))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="sbi-empty">
|
||||
<strong>暂无对比数据</strong>
|
||||
<span>{tableDim === "region" ? "当前筛选下没有区域行,试试放宽顶栏的区域选择。" : "当前筛选下没有 App 合计行。"}</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<p className="sbi-rt-footnote">
|
||||
留存按注册日 cohort 计算;观察期未到的日期显示 "--"。hyapp App 的按日留存暂不提供(仅整段区间口径)。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
322
databi/src/social/views/TeamKpiView.jsx
Normal file
322
databi/src/social/views/TeamKpiView.jsx
Normal file
@ -0,0 +1,322 @@
|
||||
// TeamKpiView:运营中心视图。后端已按当前登录用户的数据范围裁剪,页面只展示可见运营负责范围内的充值数据。
|
||||
// 金额字段为 USD 分,缺失值显示 "--",不在前端把缺失口径当作 0。
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSocialBi } from "../SocialBiApp.jsx";
|
||||
import { appColor } from "../metrics.js";
|
||||
import { formatCount, formatMoneyMinor, isBlank } from "../format.js";
|
||||
import "./team-kpi-view.css";
|
||||
|
||||
const DIMENSIONS = [
|
||||
{ key: "person", label: "按人员×区域" },
|
||||
{ key: "operatorApp", label: "按人员×App" },
|
||||
{ key: "team", label: "按部门" }
|
||||
];
|
||||
|
||||
function addNullable(sum, value) {
|
||||
if (isBlank(value)) {
|
||||
return sum;
|
||||
}
|
||||
return (sum ?? 0) + Number(value);
|
||||
}
|
||||
|
||||
function numberOr(value, fallback) {
|
||||
return isBlank(value) ? fallback : Number(value);
|
||||
}
|
||||
|
||||
function compareByMonthRecharge(left, right) {
|
||||
return numberOr(right.monthMinor, -1) - numberOr(left.monthMinor, -1);
|
||||
}
|
||||
|
||||
function operatorLabelOf(item) {
|
||||
return item.operator_name || item.operator_account || `#${item.operator_user_id}`;
|
||||
}
|
||||
|
||||
function regionLabelOf(item) {
|
||||
return item.region_name || item.region_code || "全部区域";
|
||||
}
|
||||
|
||||
function operatorCountOf(items) {
|
||||
return new Set(items.map((item) => item.operator_user_id).filter((id) => !isBlank(id))).size;
|
||||
}
|
||||
|
||||
export function TeamKpiView() {
|
||||
const { appCodes, isLoading, kpi } = useSocialBi();
|
||||
const [dimension, setDimension] = useState("person");
|
||||
|
||||
const items = useMemo(() => kpi?.items || [], [kpi]);
|
||||
|
||||
const personRows = useMemo(() => {
|
||||
const rows = items.map((item, index) => ({
|
||||
item,
|
||||
key: `${item.operator_user_id}-${item.app_code}-${item.region_id}-${index}`,
|
||||
monthMinor: item.month_recharge_usd_minor
|
||||
}));
|
||||
rows.sort(compareByMonthRecharge);
|
||||
return rows;
|
||||
}, [items]);
|
||||
|
||||
const operatorAppRows = useMemo(() => {
|
||||
const rows = (kpi?.operator_app_rows || []).map((item, index) => ({
|
||||
item,
|
||||
key: `${item.operator_user_id}-${item.app_code}-${index}`,
|
||||
monthMinor: item.month_recharge_usd_minor
|
||||
}));
|
||||
rows.sort(compareByMonthRecharge);
|
||||
return rows;
|
||||
}, [kpi]);
|
||||
|
||||
const teamRows = useMemo(() => {
|
||||
const groups = new Map();
|
||||
items.forEach((item) => {
|
||||
const teamName = item.team || "未分组";
|
||||
let group = groups.get(teamName);
|
||||
if (!group) {
|
||||
group = { members: new Set(), monthMinor: null, rangeMinor: null, team: teamName };
|
||||
groups.set(teamName, group);
|
||||
}
|
||||
group.members.add(item.operator_user_id);
|
||||
group.rangeMinor = addNullable(group.rangeMinor, item.range_recharge_usd_minor);
|
||||
group.monthMinor = addNullable(group.monthMinor, item.month_recharge_usd_minor);
|
||||
});
|
||||
const rows = [...groups.values()].map((group) => ({
|
||||
memberCount: group.members.size,
|
||||
monthMinor: group.monthMinor,
|
||||
rangeMinor: group.rangeMinor,
|
||||
team: group.team
|
||||
}));
|
||||
rows.sort(compareByMonthRecharge);
|
||||
return rows;
|
||||
}, [items]);
|
||||
|
||||
if (!items.length) {
|
||||
return isLoading ? (
|
||||
<KpiSkeleton />
|
||||
) : (
|
||||
<section className="sbi-card">
|
||||
<div className="sbi-empty">
|
||||
<strong>暂无充值数据</strong>
|
||||
<span>先在用户管理为运营人员分配 App/区域数据范围</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const summary = kpi.summary || {};
|
||||
const operatorCount = isBlank(summary.operator_count) ? operatorCountOf(items) : summary.operator_count;
|
||||
|
||||
return (
|
||||
<div className="sbi-kpi-view">
|
||||
<section className="sbi-card sbi-kpi-board">
|
||||
<div className="sbi-card-header">
|
||||
<strong>充值排行</strong>
|
||||
<small>
|
||||
{kpi.period_month} · {formatCount(operatorCount)} 名运营
|
||||
</small>
|
||||
<div className="sbi-card-toolbar">
|
||||
<div aria-label="排行维度" className="sbi-seg" role="radiogroup">
|
||||
{DIMENSIONS.map((option) => (
|
||||
<button
|
||||
aria-checked={dimension === option.key}
|
||||
className={dimension === option.key ? "is-active" : ""}
|
||||
key={option.key}
|
||||
onClick={() => setDimension(option.key)}
|
||||
role="radio"
|
||||
type="button"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sbi-table-scroll sbi-kpi-board-scroll">
|
||||
{dimension === "person" ? (
|
||||
<PersonTable appCodes={appCodes} rows={personRows} />
|
||||
) : dimension === "operatorApp" ? (
|
||||
<OperatorAppTable appCodes={appCodes} rows={operatorAppRows} />
|
||||
) : (
|
||||
<TeamTable rows={teamRows} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiSkeleton() {
|
||||
return (
|
||||
<div aria-busy="true" className="sbi-kpi-view">
|
||||
<section className="sbi-card">
|
||||
<div className="sbi-kpi-table-skeleton">
|
||||
{[0, 1, 2, 3, 4, 5].map((index) => (
|
||||
<span className="sbi-skeleton" key={index} style={{ height: 18, width: "100%" }} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RankBadge({ rank }) {
|
||||
const medal = rank === 1 ? " is-gold" : rank === 2 ? " is-silver" : rank === 3 ? " is-bronze" : "";
|
||||
return <span className={`sbi-kpi-rank${medal}`}>{rank}</span>;
|
||||
}
|
||||
|
||||
function OperatorAppTable({ appCodes, rows }) {
|
||||
return (
|
||||
<table className="sbi-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="is-left">名次</th>
|
||||
<th className="is-left">运营人员</th>
|
||||
<th className="is-left">App</th>
|
||||
<th className="is-left">负责区域</th>
|
||||
<th>
|
||||
<span className="sbi-metric-hint" title="所选区间内该运营在此 App 负责范围的充值合计(USD)">
|
||||
区间充值
|
||||
</span>
|
||||
</th>
|
||||
<th>
|
||||
<span className="sbi-metric-hint" title="本自然月至今该运营在此 App 负责范围的充值合计(USD)">
|
||||
当月充值
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => {
|
||||
const item = row.item;
|
||||
return (
|
||||
<tr key={row.key}>
|
||||
<td className="is-left">
|
||||
<RankBadge rank={index + 1} />
|
||||
</td>
|
||||
<td className="is-left">
|
||||
<OperatorCell item={item} />
|
||||
</td>
|
||||
<td className="is-left">
|
||||
<AppCell appCodes={appCodes} item={item} />
|
||||
</td>
|
||||
<td className="is-left">{item.whole_app_scope ? "全部区域" : item.region_names || `${formatCount(item.region_count)} 个区域`}</td>
|
||||
<td>{formatMoneyMinor(item.range_recharge_usd_minor)}</td>
|
||||
<td>{formatMoneyMinor(item.month_recharge_usd_minor)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonTable({ appCodes, rows }) {
|
||||
return (
|
||||
<table className="sbi-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="is-left">名次</th>
|
||||
<th className="is-left">运营人员</th>
|
||||
<th className="is-left">App</th>
|
||||
<th className="is-left">区域</th>
|
||||
<th>
|
||||
<span className="sbi-metric-hint" title="所选区间内负责范围的充值合计(USD)">
|
||||
区间充值
|
||||
</span>
|
||||
</th>
|
||||
<th>
|
||||
<span className="sbi-metric-hint" title="本自然月至今负责范围的充值合计(USD)">
|
||||
当月充值
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => {
|
||||
const item = row.item;
|
||||
return (
|
||||
<tr key={row.key}>
|
||||
<td className="is-left">
|
||||
<RankBadge rank={index + 1} />
|
||||
</td>
|
||||
<td className="is-left">
|
||||
<OperatorCell item={item} />
|
||||
</td>
|
||||
<td className="is-left">
|
||||
<AppCell appCodes={appCodes} item={item} />
|
||||
</td>
|
||||
<td className="is-left">{regionLabelOf(item)}</td>
|
||||
<td>{formatMoneyMinor(item.range_recharge_usd_minor)}</td>
|
||||
<td>{formatMoneyMinor(item.month_recharge_usd_minor)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamTable({ rows }) {
|
||||
return (
|
||||
<table className="sbi-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="is-left">名次</th>
|
||||
<th className="is-left">部门</th>
|
||||
<th>
|
||||
<span className="sbi-metric-hint" title="部门内去重运营人数">
|
||||
人数
|
||||
</span>
|
||||
</th>
|
||||
<th>
|
||||
<span className="sbi-metric-hint" title="部门内区间充值合计(USD)">
|
||||
区间充值
|
||||
</span>
|
||||
</th>
|
||||
<th>
|
||||
<span className="sbi-metric-hint" title="部门内本月充值合计(USD)">
|
||||
当月充值
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr key={row.team}>
|
||||
<td className="is-left">
|
||||
<RankBadge rank={index + 1} />
|
||||
</td>
|
||||
<td className="is-left">{row.team}</td>
|
||||
<td>{formatCount(row.memberCount)}</td>
|
||||
<td>{formatMoneyMinor(row.rangeMinor)}</td>
|
||||
<td>{formatMoneyMinor(row.monthMinor)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function OperatorCell({ item }) {
|
||||
return (
|
||||
<div className="sbi-kpi-person">
|
||||
<span className="sbi-kpi-person-top">
|
||||
<strong>{operatorLabelOf(item)}</strong>
|
||||
{item.data_error ? (
|
||||
<span className="sbi-badge is-warn" title={item.data_error}>
|
||||
数据异常
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<small>{[item.operator_account, item.team || "未分组"].filter(Boolean).join(" · ")}</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppCell({ appCodes, item }) {
|
||||
return (
|
||||
<span className="sbi-kpi-app">
|
||||
<span className="sbi-kpi-app-dot" style={{ background: appColor(item.app_code, appCodes) }} />
|
||||
{item.app_name || item.app_code}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
139
databi/src/social/views/data-table-view.css
Normal file
139
databi/src/social/views/data-table-view.css
Normal file
@ -0,0 +1,139 @@
|
||||
/* 数据明细视图(DataTableView)专属样式,类名前缀 sbi-dt-。
|
||||
颜色/圆角只使用 social-v2.css 的 token 与其既有中性色(#f1f5fa / #f7fafd / rgba(16,34,56,…))。 */
|
||||
|
||||
.sbi-dt-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 18px 14px;
|
||||
}
|
||||
|
||||
.sbi-dt-groups {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sbi-dt-section-tabs {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sbi-dt-section-tabs > button {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--sbi-border);
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
color: var(--sbi-text-2);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sbi-dt-section-tabs > button.is-active {
|
||||
border-color: var(--sbi-accent);
|
||||
color: var(--sbi-accent);
|
||||
box-shadow: 0 0 0 3px rgba(37, 87, 214, 0.1);
|
||||
}
|
||||
|
||||
.sbi-dt-filter-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sbi-dt-groups-label {
|
||||
color: var(--sbi-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sbi-dt-export {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 表格纵向滚动容器:限制高度让两级表头的 sticky 生效,底部跟随卡片圆角。 */
|
||||
.sbi-dt-scroll {
|
||||
max-height: 72vh;
|
||||
border-top: 1px solid var(--sbi-border);
|
||||
border-radius: 0 0 var(--sbi-radius) var(--sbi-radius);
|
||||
}
|
||||
|
||||
/* 第一行:列组表头(浅灰底、居中、固定高度,供第二行 sticky 偏移用)。 */
|
||||
.sbi-dt-table th.sbi-dt-group-th {
|
||||
box-sizing: border-box;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
background: #f1f5fa;
|
||||
color: var(--sbi-text-3);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 第二行:指标表头,sticky 吸附在列组行下方,可点击排序。 */
|
||||
.sbi-dt-table th.sbi-dt-metric-th {
|
||||
top: 34px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sbi-dt-table th.sbi-dt-metric-th:hover,
|
||||
.sbi-dt-table th.sbi-dt-metric-th.is-sorted {
|
||||
color: var(--sbi-accent);
|
||||
}
|
||||
|
||||
.sbi-dt-req-table th:first-child,
|
||||
.sbi-dt-req-table td:first-child {
|
||||
min-width: 104px;
|
||||
}
|
||||
|
||||
.sbi-dt-sort {
|
||||
margin-left: 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 首列冻结:白底 + 右侧阴影;合计行/悬浮行的底色由 social-v2.css 中更高优先级规则接管。 */
|
||||
.sbi-dt-table .sbi-dt-sticky,
|
||||
.sbi-dt-req-table .sbi-dt-sticky {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
box-shadow: 6px 0 10px -8px rgba(16, 34, 56, 0.28);
|
||||
}
|
||||
|
||||
.sbi-dt-table td.sbi-dt-sticky,
|
||||
.sbi-dt-req-table td.sbi-dt-sticky {
|
||||
z-index: 1;
|
||||
background: var(--sbi-card);
|
||||
}
|
||||
|
||||
.sbi-dt-table th.sbi-dt-sticky,
|
||||
.sbi-dt-req-table th.sbi-dt-sticky {
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
/* 截断提示(表尾整行)。 */
|
||||
.sbi-dt-note td {
|
||||
padding: 10px 12px;
|
||||
background: #f7fafd;
|
||||
color: var(--sbi-text-3);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* 无数据时的加载骨架。 */
|
||||
.sbi-dt-skeleton {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 6px 18px 20px;
|
||||
}
|
||||
|
||||
.sbi-dt-skeleton .sbi-skeleton {
|
||||
height: 14px;
|
||||
}
|
||||
221
databi/src/social/views/funnel-view.css
Normal file
221
databi/src/social/views/funnel-view.css
Normal file
@ -0,0 +1,221 @@
|
||||
/* 埋点漏斗视图专属样式:类名前缀 sbi-funnel-,沿用 social-v2 token。 */
|
||||
|
||||
.sbi-funnel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sbi-funnel-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sbi-funnel-head h1 {
|
||||
margin: 0 0 4px;
|
||||
color: var(--sbi-text);
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sbi-funnel-head span {
|
||||
color: var(--sbi-text-2);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.sbi-funnel-apps {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sbi-funnel-apps span {
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--sbi-border);
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
color: var(--sbi-text-2);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sbi-funnel-warning {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #f3ddc0;
|
||||
border-radius: 10px;
|
||||
background: var(--sbi-warn-bg);
|
||||
color: var(--sbi-warn-text);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.sbi-funnel-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sbi-funnel-kpi {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.sbi-funnel-kpi span {
|
||||
color: var(--sbi-text-2);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sbi-funnel-kpi strong {
|
||||
color: var(--sbi-text);
|
||||
font-size: 23px;
|
||||
line-height: 1.1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sbi-funnel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(380px, 0.95fr) minmax(520px, 1.25fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sbi-funnel-chart-card,
|
||||
.sbi-funnel-table-card,
|
||||
.sbi-funnel-cohort-card {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sbi-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 18px 0;
|
||||
}
|
||||
|
||||
.sbi-card-title h2 {
|
||||
margin: 0 0 3px;
|
||||
color: var(--sbi-text);
|
||||
font-size: 15px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sbi-card-title span {
|
||||
color: var(--sbi-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sbi-funnel-chart {
|
||||
height: 358px;
|
||||
margin: 8px 8px 12px;
|
||||
}
|
||||
|
||||
.sbi-funnel-step-scroll {
|
||||
max-height: 390px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.sbi-funnel-step-table td:first-child {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.sbi-funnel-step-table td strong {
|
||||
display: block;
|
||||
color: var(--sbi-text);
|
||||
}
|
||||
|
||||
.sbi-funnel-step-table td span {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--sbi-text-3);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.sbi-funnel-step-table tr.is-failure td {
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.sbi-funnel-step-table tr.is-failure td:first-child strong {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.sbi-funnel-cohort-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sbi-funnel-cohort-title {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.sbi-funnel-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
padding: 3px;
|
||||
border: 1px solid var(--sbi-border);
|
||||
border-radius: 9px;
|
||||
background: #f1f5fa;
|
||||
}
|
||||
|
||||
.sbi-funnel-tabs button {
|
||||
padding: 6px 10px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--sbi-text-2);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sbi-funnel-tabs button.is-active {
|
||||
background: #ffffff;
|
||||
color: var(--sbi-accent);
|
||||
box-shadow: 0 1px 3px rgba(16, 34, 56, 0.12);
|
||||
}
|
||||
|
||||
.sbi-funnel-cohort-scroll {
|
||||
max-height: 520px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.sbi-funnel-cohort-table th,
|
||||
.sbi-funnel-cohort-table td {
|
||||
min-width: 104px;
|
||||
}
|
||||
|
||||
.sbi-funnel-cohort-table th:first-child,
|
||||
.sbi-funnel-cohort-table td:first-child {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.sbi-funnel-empty-cell {
|
||||
padding: 34px 12px !important;
|
||||
color: var(--sbi-text-3);
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.sbi-funnel-skeleton {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.sbi-funnel-skeleton .sbi-skeleton {
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
@media (max-width: 1360px) {
|
||||
.sbi-funnel-summary {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.sbi-funnel-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
195
databi/src/social/views/overview-view.css
Normal file
195
databi/src/social/views/overview-view.css
Normal file
@ -0,0 +1,195 @@
|
||||
/* 经营概览视图专属样式:只消费 social-v2.css 的 token,类名前缀 sbi-ov-。 */
|
||||
|
||||
.sbi-ov {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sbi-ov-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ---------- Hero KPI ---------- */
|
||||
|
||||
.sbi-ov-hero {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sbi-ov-hero-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 14px 16px 10px;
|
||||
}
|
||||
|
||||
.sbi-ov-hero-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sbi-ov-hero-label {
|
||||
overflow: hidden;
|
||||
color: var(--sbi-text-2);
|
||||
font-size: 12.5px;
|
||||
font-weight: 640;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sbi-ov-hero-value {
|
||||
color: var(--sbi-text);
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
line-height: 1.15;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sbi-ov-hero-spark {
|
||||
height: 38px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.sbi-ov-spark-chart {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
/* ---------- 卡片主体 / 图表容器 ---------- */
|
||||
|
||||
.sbi-ov-card-body {
|
||||
padding: 12px 18px 16px;
|
||||
}
|
||||
|
||||
.sbi-ov-trend-chart {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.sbi-ov-channel-chart {
|
||||
height: 240px;
|
||||
}
|
||||
|
||||
.sbi-ov-duo {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ---------- App 对比 / 区域 Top5 横向条 ---------- */
|
||||
|
||||
.sbi-ov-compare {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sbi-ov-compare-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(110px, 150px) minmax(0, 1fr) max-content max-content;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sbi-ov-compare-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--sbi-text);
|
||||
font-size: 13px;
|
||||
font-weight: 640;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sbi-ov-dot {
|
||||
flex: none;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.sbi-ov-bar-track {
|
||||
height: 10px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #eef3f9;
|
||||
}
|
||||
|
||||
.sbi-ov-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--sbi-accent-grad);
|
||||
}
|
||||
|
||||
.sbi-ov-num {
|
||||
min-width: 84px;
|
||||
color: var(--sbi-text);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sbi-ov-link {
|
||||
padding: 2px 6px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--sbi-accent);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sbi-ov-link:hover {
|
||||
background: rgba(37, 87, 214, 0.08);
|
||||
}
|
||||
|
||||
.sbi-ov-top {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sbi-ov-top-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 260px) minmax(0, 1fr) max-content;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sbi-ov-top-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 640;
|
||||
}
|
||||
|
||||
.sbi-ov-top-name > strong {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ---------- 骨架 ---------- */
|
||||
|
||||
.sbi-ov-skeleton-card {
|
||||
display: grid;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.sbi-ov-hero {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
222
databi/src/social/views/regions-view.css
Normal file
222
databi/src/social/views/regions-view.css
Normal file
@ -0,0 +1,222 @@
|
||||
/* 地区洞察(RegionsView)专属样式,类名前缀 sbi-rg-;颜色/圆角一律取 social-v2.css 的 token。 */
|
||||
|
||||
/* ---------- 概要条 ---------- */
|
||||
|
||||
.sbi-rg-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sbi-rg-summary-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.sbi-rg-summary-label {
|
||||
color: var(--sbi-text-2);
|
||||
font-size: 12.5px;
|
||||
font-weight: 640;
|
||||
}
|
||||
|
||||
.sbi-rg-summary-value {
|
||||
color: var(--sbi-text);
|
||||
font-size: 24px;
|
||||
font-weight: 760;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sbi-rg-summary-sub {
|
||||
color: var(--sbi-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ---------- 世界地图卡 ---------- */
|
||||
|
||||
.sbi-rg-map-card {
|
||||
display: flex;
|
||||
height: 380px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sbi-rg-map-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 6px 12px 12px;
|
||||
}
|
||||
|
||||
.sbi-rg-map-body .sbi-empty {
|
||||
height: 100%;
|
||||
place-content: center;
|
||||
}
|
||||
|
||||
.sbi-rg-map-chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ---------- 区域卡片网格 ---------- */
|
||||
|
||||
.sbi-rg-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sbi-rg-region-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.sbi-rg-region-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sbi-rg-region-name {
|
||||
overflow: hidden;
|
||||
font-size: 14.5px;
|
||||
font-weight: 720;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sbi-rg-app-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--sbi-text-2);
|
||||
font-size: 12px;
|
||||
font-weight: 640;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sbi-rg-region-head .sbi-rg-app-tag {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.sbi-rg-app-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.sbi-rg-region-money {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sbi-rg-region-money small {
|
||||
color: var(--sbi-text-3);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.sbi-rg-region-money strong {
|
||||
font-size: 22px;
|
||||
font-weight: 760;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sbi-rg-region-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sbi-rg-region-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sbi-rg-region-metric small {
|
||||
color: var(--sbi-text-3);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.sbi-rg-region-metric span {
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sbi-rg-share {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.sbi-rg-share-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--sbi-text-3);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.sbi-rg-share-track {
|
||||
display: block;
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: var(--sbi-bg);
|
||||
}
|
||||
|
||||
.sbi-rg-share-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--sbi-accent-grad);
|
||||
transition: width 200ms ease;
|
||||
}
|
||||
|
||||
/* ---------- 下钻表 ---------- */
|
||||
|
||||
.sbi-rg-table-card .sbi-table-scroll {
|
||||
max-height: 540px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.sbi-rg-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sbi-rg-region-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sbi-rg-caret {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
color: var(--sbi-text-3);
|
||||
text-align: center;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.sbi-rg-caret.is-open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.sbi-rg-child td {
|
||||
background: var(--sbi-bg);
|
||||
color: var(--sbi-text-2);
|
||||
}
|
||||
|
||||
.sbi-rg-child-name {
|
||||
display: inline-block;
|
||||
padding-left: 26px;
|
||||
}
|
||||
|
||||
.sbi-rg-child-app {
|
||||
color: var(--sbi-text-3);
|
||||
}
|
||||
102
databi/src/social/views/retention-view.css
Normal file
102
databi/src/social/views/retention-view.css
Normal file
@ -0,0 +1,102 @@
|
||||
/* 留存质量视图(RetentionView)专属样式;公共原语(卡片/表格/seg/骨架)见 social-v2.css。 */
|
||||
|
||||
.sbi-rt-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ---------- 概要卡 ---------- */
|
||||
|
||||
.sbi-rt-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sbi-rt-summary-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.sbi-rt-summary-label {
|
||||
color: var(--sbi-text-2);
|
||||
font-size: 12.5px;
|
||||
font-weight: 640;
|
||||
}
|
||||
|
||||
.sbi-rt-summary-value {
|
||||
color: var(--sbi-text);
|
||||
font-size: 26px;
|
||||
font-weight: 760;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.sbi-rt-summary-note {
|
||||
color: var(--sbi-text-3);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
/* ---------- 图表卡(EChart 容器必须有确定高度) ---------- */
|
||||
|
||||
.sbi-rt-chart-combo {
|
||||
height: 300px;
|
||||
margin: 4px 12px 12px;
|
||||
}
|
||||
|
||||
.sbi-rt-chart-trend {
|
||||
height: 280px;
|
||||
margin: 4px 12px 12px;
|
||||
}
|
||||
|
||||
/* ---------- 热力表 ---------- */
|
||||
|
||||
.sbi-rt-table-card .sbi-table-scroll {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* ---------- 底部口径说明 ---------- */
|
||||
|
||||
.sbi-rt-footnote {
|
||||
margin: 0;
|
||||
color: var(--sbi-text-3);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ---------- 骨架 ---------- */
|
||||
|
||||
.sbi-rt-skeleton-line {
|
||||
display: block;
|
||||
width: 96px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.sbi-rt-skeleton-value {
|
||||
display: block;
|
||||
width: 150px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.sbi-rt-skeleton-chart {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: 232px;
|
||||
margin: 14px 16px 16px;
|
||||
}
|
||||
|
||||
.sbi-rt-skeleton-table {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: 180px;
|
||||
margin: 14px 16px 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.sbi-rt-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
76
databi/src/social/views/team-kpi-view.css
Normal file
76
databi/src/social/views/team-kpi-view.css
Normal file
@ -0,0 +1,76 @@
|
||||
/* 运营中心充值排行视图专属样式:仅消费 social-v2.css 的 token,类名前缀 sbi-kpi-。 */
|
||||
|
||||
.sbi-kpi-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sbi-kpi-board-scroll {
|
||||
max-height: 620px;
|
||||
margin-top: 12px;
|
||||
border-radius: 0 0 var(--sbi-radius) var(--sbi-radius);
|
||||
}
|
||||
|
||||
.sbi-kpi-rank {
|
||||
display: inline-grid;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: #f1f5fa;
|
||||
color: var(--sbi-text-2);
|
||||
font-size: 12px;
|
||||
font-weight: 740;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sbi-kpi-rank.is-gold {
|
||||
background: linear-gradient(135deg, #f6c34c, #d28f0e);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.sbi-kpi-rank.is-silver {
|
||||
background: linear-gradient(135deg, #cfd8e3, #97a4b4);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.sbi-kpi-rank.is-bronze {
|
||||
background: linear-gradient(135deg, #d9a06b, #a56a3c);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.sbi-kpi-person {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sbi-kpi-person-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sbi-kpi-person small {
|
||||
color: var(--sbi-text-3);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.sbi-kpi-app {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.sbi-kpi-app-dot {
|
||||
flex: none;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.sbi-kpi-table-skeleton {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
}
|
||||
33
databi/src/styles/base.css
Normal file
33
databi/src/styles/base.css
Normal file
@ -0,0 +1,33 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 1280px;
|
||||
min-height: 720px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
background:
|
||||
linear-gradient(rgba(29, 216, 242, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(29, 216, 242, 0.04) 1px, transparent 1px),
|
||||
radial-gradient(circle at 18% 24%, rgba(28, 216, 242, 0.12), transparent 28%),
|
||||
radial-gradient(circle at 82% 12%, rgba(31, 201, 170, 0.1), transparent 26%),
|
||||
#031322;
|
||||
background-size:
|
||||
24px 24px,
|
||||
24px 24px,
|
||||
auto,
|
||||
auto,
|
||||
auto;
|
||||
color: #e9f6ff;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
386
databi/src/styles/cards.css
Normal file
386
databi/src/styles/cards.css
Normal file
@ -0,0 +1,386 @@
|
||||
.metric-card,
|
||||
.databi-panel,
|
||||
.mini-stat {
|
||||
border: 1px solid rgba(35, 128, 180, 0.46);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(9, 43, 70, 0.95), rgba(5, 27, 47, 0.92)),
|
||||
rgba(8, 34, 56, 0.92);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
grid-column: span 2;
|
||||
display: grid;
|
||||
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;
|
||||
height: 40px;
|
||||
place-items: center;
|
||||
color: #27e4f5;
|
||||
}
|
||||
|
||||
.metric-icon svg {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.9;
|
||||
}
|
||||
|
||||
.metric-content {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto 22px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metric-label-row,
|
||||
.mini-stat > span,
|
||||
.section-label,
|
||||
.rank-title {
|
||||
color: #98afc9;
|
||||
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;
|
||||
align-self: center;
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
color: #f6fbff;
|
||||
font-size: clamp(20px, 1.25vw, 25px);
|
||||
font-weight: 780;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-delta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid rgba(120, 169, 205, 0.16);
|
||||
color: #6f8aa4;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.metric-delta--compact {
|
||||
margin-top: 2px;
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.metric-delta b,
|
||||
.positive {
|
||||
color: #24d79f;
|
||||
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;
|
||||
}
|
||||
|
||||
.funnel-layout .side-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
align-content: start;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.funnel-layout .mini-stat {
|
||||
height: 74px;
|
||||
padding: 10px 11px;
|
||||
}
|
||||
|
||||
.mini-stat {
|
||||
min-width: 0;
|
||||
padding: 10px 11px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mini-stat strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
color: #f6fbff;
|
||||
font-size: 20px;
|
||||
font-weight: 780;
|
||||
line-height: 1.15;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.side-metrics .mini-stat strong {
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.mini-stat small {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
margin-top: 3px;
|
||||
overflow: hidden;
|
||||
color: #6f8aa4;
|
||||
font-size: 10px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.four-metric-row .mini-stat small,
|
||||
.five-metric-row .mini-stat small {
|
||||
gap: 2px;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.mini-stat small span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mini-stat small b {
|
||||
flex: 0 0 auto;
|
||||
color: #24d79f;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.mini-stat-delta--down b {
|
||||
color: #f5a623;
|
||||
}
|
||||
|
||||
.two-metric-row,
|
||||
.four-metric-row,
|
||||
.five-metric-row {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.two-metric-row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.four-metric-row {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.five-metric-row {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.five-metric-row .mini-stat strong {
|
||||
font-size: clamp(10px, 0.72vw, 16px);
|
||||
}
|
||||
|
||||
.four-metric-row .mini-stat strong {
|
||||
font-size: clamp(13px, 0.8vw, 16px);
|
||||
}
|
||||
|
||||
.five-metric-row .mini-stat {
|
||||
padding-inline: 6px;
|
||||
}
|
||||
195
databi/src/styles/charts.css
Normal file
195
databi/src/styles/charts.css
Normal file
@ -0,0 +1,195 @@
|
||||
.databi-chart {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.geo-chart {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 232px;
|
||||
}
|
||||
|
||||
.databi-grid--top .geo-chart {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.trend-chart,
|
||||
.funnel-chart {
|
||||
height: 232px;
|
||||
}
|
||||
|
||||
.databi-grid--top .trend-chart {
|
||||
flex: 1;
|
||||
height: auto;
|
||||
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;
|
||||
}
|
||||
|
||||
.bar-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.databi-grid--middle .bar-list {
|
||||
gap: 15px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.bar-row {
|
||||
display: grid;
|
||||
grid-template-columns: 20px minmax(86px, 112px) minmax(0, 1fr) 86px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.bar-track {
|
||||
position: relative;
|
||||
height: 9px;
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
background: rgba(95, 151, 193, 0.18);
|
||||
}
|
||||
|
||||
.bar-track i {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
background: linear-gradient(90deg, #25d8f5, #1aa7ef);
|
||||
box-shadow: 0 0 12px rgba(37, 216, 245, 0.42);
|
||||
}
|
||||
|
||||
.donut-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.databi-grid--middle .donut-layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.donut-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.distribution-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.distribution-row {
|
||||
display: grid;
|
||||
grid-template-columns: 8px minmax(150px, 1fr) 58px 86px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.distribution-row span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #25d8f5;
|
||||
}
|
||||
|
||||
.distribution-row:nth-child(2) span {
|
||||
background: #1dc9aa;
|
||||
}
|
||||
|
||||
.distribution-row:nth-child(3) span {
|
||||
background: #367be8;
|
||||
}
|
||||
|
||||
.distribution-row:nth-child(4) span {
|
||||
background: #9a9cfb;
|
||||
}
|
||||
|
||||
.distribution-row em {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #cfe3f7;
|
||||
font-style: normal;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.distribution-row strong {
|
||||
color: #d9ebff;
|
||||
font-weight: 680;
|
||||
}
|
||||
|
||||
.game-table {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.databi-grid--middle .game-table {
|
||||
gap: 13px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.game-table-head {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 86px 64px;
|
||||
gap: 10px;
|
||||
color: #9eb7ce;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.game-row {
|
||||
display: grid;
|
||||
grid-template-columns: 20px minmax(82px, 126px) minmax(0, 1fr) 86px 64px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.game-row strong {
|
||||
color: #24d79f;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.donut-center {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: grid;
|
||||
width: 82px;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.donut-center strong {
|
||||
color: #f6fbff;
|
||||
font-size: 18px;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.donut-center span {
|
||||
color: #9eb7ce;
|
||||
font-size: 11px;
|
||||
}
|
||||
9
databi/src/styles/index.css
Normal file
9
databi/src/styles/index.css
Normal file
@ -0,0 +1,9 @@
|
||||
@import "./tokens.css";
|
||||
@import "./base.css";
|
||||
@import "./layout.css";
|
||||
@import "./cards.css";
|
||||
@import "./panels.css";
|
||||
@import "./charts.css";
|
||||
@import "./tables.css";
|
||||
@import "./report.css";
|
||||
@import "./responsive.css";
|
||||
1023
databi/src/styles/layout.css
Normal file
1023
databi/src/styles/layout.css
Normal file
File diff suppressed because it is too large
Load Diff
811
databi/src/styles/panels.css
Normal file
811
databi/src/styles/panels.css
Normal file
@ -0,0 +1,811 @@
|
||||
.databi-panel {
|
||||
min-width: 0;
|
||||
min-height: 236px;
|
||||
padding: 16px 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.panel--table {
|
||||
min-height: 190px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 28px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
color: #f3f9ff;
|
||||
font-size: 18px;
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.panel-info {
|
||||
display: grid;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(157, 199, 232, 0.72);
|
||||
border-radius: 50%;
|
||||
color: #9dc7e8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.geo-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(218px, 0.72fr);
|
||||
align-items: start;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.databi-grid--top .geo-layout,
|
||||
.databi-grid--top .funnel-layout {
|
||||
flex: 1;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.databi-grid--top .geo-map {
|
||||
aspect-ratio: 1.66;
|
||||
height: auto;
|
||||
max-height: 210px;
|
||||
}
|
||||
|
||||
.geo-map {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%, rgba(39, 228, 245, 0.1), transparent 48%),
|
||||
radial-gradient(ellipse at 48% 52%, rgba(36, 116, 154, 0.2), transparent 68%),
|
||||
linear-gradient(180deg, rgba(11, 48, 74, 0.62), rgba(5, 25, 43, 0.24));
|
||||
}
|
||||
|
||||
.geo-map::before {
|
||||
position: absolute;
|
||||
inset: 26px 18px;
|
||||
z-index: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(39, 228, 245, 0.055) 1px, transparent 1px) 0 0 / 25% 100%,
|
||||
linear-gradient(rgba(39, 228, 245, 0.05) 1px, transparent 1px) 0 0 / 100% 33%;
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.geo-map::after {
|
||||
position: absolute;
|
||||
inset: 28px 28px;
|
||||
z-index: 1;
|
||||
border-top: 1px solid rgba(39, 228, 245, 0.06);
|
||||
border-bottom: 1px solid rgba(39, 228, 245, 0.06);
|
||||
background:
|
||||
radial-gradient(ellipse at 24% 50%, rgba(37, 228, 245, 0.09), transparent 43%),
|
||||
radial-gradient(ellipse at 74% 52%, rgba(37, 228, 245, 0.08), transparent 48%);
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rank-block {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
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;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.globe-map.is-dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.globe-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.globe-tooltip {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 128px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(52, 199, 229, 0.48);
|
||||
border-radius: 4px;
|
||||
background: rgba(4, 24, 41, 0.94);
|
||||
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.32), 0 0 18px rgba(37, 228, 245, 0.2);
|
||||
color: #cfe3f7;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.globe-tooltip strong {
|
||||
color: #f2fbff;
|
||||
font-size: 12px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.globe-tooltip span {
|
||||
color: #9eb7ce;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.geo-scale {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
z-index: 4;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
color: #cdeeff;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.geo-scale i {
|
||||
width: 13px;
|
||||
height: 58px;
|
||||
border: 1px solid rgba(43, 229, 245, 0.36);
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(180deg, #24d79f 0%, #27e4f5 48%, #4281ff 100%);
|
||||
box-shadow: 0 0 16px rgba(39, 228, 245, 0.32);
|
||||
}
|
||||
|
||||
.rank-list {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
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;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
min-height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(81, 154, 203, 0.2);
|
||||
border-radius: 8px;
|
||||
background: rgba(6, 27, 46, 0.72);
|
||||
}
|
||||
|
||||
.rank-flag {
|
||||
display: inline-grid;
|
||||
min-width: 18px;
|
||||
place-items: center;
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.rank-row span,
|
||||
.bar-row span,
|
||||
.game-row span {
|
||||
color: #9eb7ce;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rank-row strong,
|
||||
.bar-row em,
|
||||
.game-row em {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #e9f6ff;
|
||||
font-style: normal;
|
||||
font-weight: 640;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rank-row strong,
|
||||
.rank-row b {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.all-countries-button {
|
||||
height: 34px;
|
||||
border: 1px solid rgba(39, 148, 203, 0.5);
|
||||
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,
|
||||
.country-table td {
|
||||
color: #cfe3f7;
|
||||
font-weight: 620;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.funnel-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
align-content: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.funnel-stack {
|
||||
display: grid;
|
||||
width: min(420px, 88%);
|
||||
justify-self: center;
|
||||
gap: 5px;
|
||||
padding: 2px 0 0;
|
||||
}
|
||||
|
||||
.funnel-stage-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(210px, 1fr);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.funnel-stage-row > b {
|
||||
display: none;
|
||||
color: #9eb7ce;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.funnel-stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 18px;
|
||||
height: 37px;
|
||||
padding: 0 26px;
|
||||
clip-path: polygon(4% 0, 96% 0, 88% 100%, 12% 100%);
|
||||
border: 1px solid rgba(196, 241, 255, 0.65);
|
||||
background: linear-gradient(180deg, rgba(54, 138, 234, 0.94), rgba(30, 106, 196, 0.92));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18);
|
||||
color: #edf8ff;
|
||||
}
|
||||
|
||||
.funnel-stage span {
|
||||
font-size: 14px;
|
||||
font-weight: 720;
|
||||
line-height: 1.1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.funnel-stage strong {
|
||||
color: #f6fbff;
|
||||
font-size: 19px;
|
||||
font-weight: 820;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.funnel-layout .side-metrics {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.funnel-layout .mini-stat {
|
||||
height: 72px;
|
||||
overflow: hidden;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.funnel-layout .mini-stat > span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.funnel-layout .mini-stat strong {
|
||||
margin-top: 2px;
|
||||
font-size: 18px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.funnel-layout .mini-stat small {
|
||||
margin-top: 3px;
|
||||
font-size: 9px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.funnel-stage--2 {
|
||||
margin-inline: 18px;
|
||||
background: linear-gradient(180deg, rgba(20, 174, 194, 0.94), rgba(16, 137, 173, 0.92));
|
||||
}
|
||||
|
||||
.funnel-stage--3 {
|
||||
margin-inline: 36px;
|
||||
background: linear-gradient(180deg, rgba(31, 190, 177, 0.94), rgba(19, 155, 157, 0.92));
|
||||
}
|
||||
|
||||
.funnel-stage--4 {
|
||||
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;
|
||||
}
|
||||
1139
databi/src/styles/report.css
Normal file
1139
databi/src/styles/report.css
Normal file
File diff suppressed because it is too large
Load Diff
196
databi/src/styles/responsive.css
Normal file
196
databi/src/styles/responsive.css
Normal file
@ -0,0 +1,196 @@
|
||||
@media (max-width: 1440px) {
|
||||
.metric-card {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.metric-content strong {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.funnel-layout {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.funnel-stack {
|
||||
width: min(330px, 100%);
|
||||
gap: 5px;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.funnel-stage-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.funnel-stage {
|
||||
height: 37px;
|
||||
padding: 0 18px;
|
||||
}
|
||||
|
||||
.funnel-stage span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.funnel-stage strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.funnel-stage--2 {
|
||||
margin-inline: 8px;
|
||||
}
|
||||
|
||||
.funnel-stage--3 {
|
||||
margin-inline: 14px;
|
||||
}
|
||||
|
||||
.funnel-stage--4 {
|
||||
margin-inline: 20px;
|
||||
}
|
||||
|
||||
.funnel-layout .mini-stat {
|
||||
height: 72px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.funnel-layout .mini-stat strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.funnel-layout .mini-stat small {
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.donut-layout {
|
||||
grid-template-columns: 128px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.donut-chart {
|
||||
height: 132px;
|
||||
}
|
||||
|
||||
.distribution-row {
|
||||
grid-template-columns: 8px minmax(80px, 1fr) 42px 56px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.distribution-row em {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.distribution-row strong,
|
||||
.distribution-row b {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.five-metric-row {
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.five-metric-row .mini-stat {
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
.five-metric-row .mini-stat strong {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.five-metric-row .mini-stat small {
|
||||
font-size: 7px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
body,
|
||||
.databi-shell {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.databi-header,
|
||||
.header-controls {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.databi-grid--top,
|
||||
.databi-grid--middle {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.databi-grid--top .databi-panel:last-child {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1300px) {
|
||||
.databi-shell {
|
||||
min-width: 1280px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.metric-content strong {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.metric-delta {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.metric-delta span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.funnel-layout {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.funnel-stage-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.funnel-stage {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.funnel-stage strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.funnel-stage--2 {
|
||||
margin-inline: 5px;
|
||||
}
|
||||
|
||||
.funnel-stage--3 {
|
||||
margin-inline: 9px;
|
||||
}
|
||||
|
||||
.funnel-stage--4 {
|
||||
margin-inline: 13px;
|
||||
}
|
||||
|
||||
.funnel-layout .mini-stat {
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.funnel-layout .mini-stat strong {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 1ms !important;
|
||||
transition-duration: 1ms !important;
|
||||
}
|
||||
}
|
||||
588
databi/src/styles/social-v2.css
Normal file
588
databi/src/styles/social-v2.css
Normal file
@ -0,0 +1,588 @@
|
||||
/* 社交 BI v2 设计基座:与 finance v2 同一设计语言(深色海军侧栏 + 浅色内容区 + 蓝青渐变强调)。
|
||||
各视图自己的样式放在 databi/src/social/views/*.css,只允许使用这里定义的 token。 */
|
||||
|
||||
.sbi-shell {
|
||||
--sbi-sidebar-bg: #101b2d;
|
||||
--sbi-sidebar-hover: rgba(120, 165, 255, 0.12);
|
||||
--sbi-accent: #2557d6;
|
||||
--sbi-accent-2: #0e7490;
|
||||
--sbi-accent-grad: linear-gradient(135deg, #2557d6, #0e7490);
|
||||
--sbi-bg: #f6f8fb;
|
||||
--sbi-card: #ffffff;
|
||||
--sbi-border: #e3eaf3;
|
||||
--sbi-text: #14263c;
|
||||
--sbi-text-2: #5b7089;
|
||||
--sbi-text-3: #8aa0b8;
|
||||
--sbi-up: #0f9d58;
|
||||
--sbi-down: #d93025;
|
||||
--sbi-warn-bg: #fdf3e7;
|
||||
--sbi-warn-text: #9a5b13;
|
||||
--sbi-radius: 12px;
|
||||
--sbi-shadow: 0 1px 2px rgba(16, 34, 56, 0.06), 0 4px 16px rgba(16, 34, 56, 0.05);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 216px minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
min-width: 1080px;
|
||||
background: var(--sbi-bg);
|
||||
color: var(--sbi-text);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ---------- 侧栏 ---------- */
|
||||
|
||||
.sbi-sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 18px 12px;
|
||||
background: var(--sbi-sidebar-bg);
|
||||
color: #dbe7f7;
|
||||
}
|
||||
|
||||
.sbi-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.sbi-brand-mark {
|
||||
display: grid;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
background: var(--sbi-accent-grad);
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.sbi-brand-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sbi-brand-copy strong {
|
||||
font-size: 15px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.sbi-brand-copy small {
|
||||
color: #7e8db0;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.sbi-nav {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sbi-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: #a9bcd8;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.sbi-nav-item:hover {
|
||||
background: var(--sbi-sidebar-hover);
|
||||
color: #e9f2ff;
|
||||
}
|
||||
|
||||
.sbi-nav-item.is-active {
|
||||
background: var(--sbi-accent-grad);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.sbi-scope-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(126, 141, 176, 0.35);
|
||||
border-radius: 9px;
|
||||
color: #a9bcd8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sbi-scope-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #38bdf8;
|
||||
}
|
||||
|
||||
/* ---------- 主区 / 顶栏 ---------- */
|
||||
|
||||
.sbi-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sbi-topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px 22px;
|
||||
border-bottom: 1px solid var(--sbi-border);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.sbi-topbar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sbi-date-presets {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px;
|
||||
border: 1px solid var(--sbi-border);
|
||||
border-radius: 9px;
|
||||
background: #f1f5fa;
|
||||
}
|
||||
|
||||
.sbi-date-presets > button {
|
||||
padding: 6px 12px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--sbi-text-2);
|
||||
font-size: 13px;
|
||||
font-weight: 640;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sbi-date-presets > button.is-active {
|
||||
background: #ffffff;
|
||||
color: var(--sbi-accent);
|
||||
box-shadow: 0 1px 3px rgba(16, 34, 56, 0.12);
|
||||
}
|
||||
|
||||
/* 自定义区间触发按钮:复用全局 TimeRangeFilter(@/shared/ui),这里只做与预设条的对齐微调。 */
|
||||
.sbi-custom-range {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.sbi-granularity {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 3px;
|
||||
border: 1px solid var(--sbi-border);
|
||||
border-radius: 9px;
|
||||
background: #f1f5fa;
|
||||
}
|
||||
|
||||
.sbi-granularity > button {
|
||||
padding: 6px 12px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--sbi-text-2);
|
||||
font-size: 13px;
|
||||
font-weight: 640;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sbi-granularity > button.is-active {
|
||||
background: #ffffff;
|
||||
color: var(--sbi-accent);
|
||||
box-shadow: 0 1px 3px rgba(16, 34, 56, 0.12);
|
||||
}
|
||||
|
||||
.sbi-refresh {
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
margin-left: auto;
|
||||
border: 1px solid var(--sbi-border);
|
||||
border-radius: 9px;
|
||||
background: #ffffff;
|
||||
color: var(--sbi-text-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sbi-refresh:hover {
|
||||
color: var(--sbi-accent);
|
||||
border-color: var(--sbi-accent);
|
||||
}
|
||||
|
||||
.sbi-app-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sbi-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 6px 13px;
|
||||
border: 1px solid var(--sbi-border);
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
color: var(--sbi-text-2);
|
||||
font-size: 13px;
|
||||
font-weight: 640;
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.sbi-chip:hover {
|
||||
border-color: var(--sbi-accent);
|
||||
}
|
||||
|
||||
.sbi-chip.is-active {
|
||||
border-color: var(--sbi-accent);
|
||||
color: var(--sbi-accent);
|
||||
box-shadow: 0 0 0 3px rgba(37, 87, 214, 0.1);
|
||||
}
|
||||
|
||||
.sbi-chip-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.sbi-region-select {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sbi-region-trigger {
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--sbi-border);
|
||||
border-radius: 999px;
|
||||
background: #ffffff;
|
||||
color: var(--sbi-text-2);
|
||||
font-size: 13px;
|
||||
font-weight: 640;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sbi-region-trigger:hover {
|
||||
border-color: var(--sbi-accent);
|
||||
}
|
||||
|
||||
.sbi-region-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 40;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 260px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--sbi-border);
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--sbi-shadow);
|
||||
}
|
||||
|
||||
.sbi-region-group {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px dashed var(--sbi-border);
|
||||
}
|
||||
|
||||
.sbi-region-option {
|
||||
padding: 7px 10px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--sbi-text);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sbi-region-option:hover {
|
||||
background: #f1f5fa;
|
||||
}
|
||||
|
||||
.sbi-region-option.is-group {
|
||||
color: var(--sbi-text-2);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sbi-region-option.is-active {
|
||||
background: rgba(37, 87, 214, 0.08);
|
||||
color: var(--sbi-accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sbi-region-empty {
|
||||
margin: 6px;
|
||||
color: var(--sbi-text-3);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.sbi-range-label {
|
||||
color: var(--sbi-text-3);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.sbi-loading-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--sbi-accent);
|
||||
animation: sbi-pulse 900ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes sbi-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.25;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.sbi-error-banner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin: 12px 22px 0;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #f3ddc0;
|
||||
border-radius: 10px;
|
||||
background: var(--sbi-warn-bg);
|
||||
color: var(--sbi-warn-text);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.sbi-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 18px 22px 40px;
|
||||
}
|
||||
|
||||
/* ---------- 视图共用原语(卡片/表格/骨架/空态/徽标) ---------- */
|
||||
|
||||
.sbi-card {
|
||||
border: 1px solid var(--sbi-border);
|
||||
border-radius: var(--sbi-radius);
|
||||
background: var(--sbi-card);
|
||||
box-shadow: var(--sbi-shadow);
|
||||
}
|
||||
|
||||
.sbi-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 18px 0;
|
||||
}
|
||||
|
||||
.sbi-card-header strong {
|
||||
font-size: 14.5px;
|
||||
}
|
||||
|
||||
.sbi-card-header small {
|
||||
color: var(--sbi-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sbi-card-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.sbi-seg {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--sbi-border);
|
||||
border-radius: 8px;
|
||||
background: #f1f5fa;
|
||||
}
|
||||
|
||||
.sbi-seg > button {
|
||||
padding: 4px 10px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--sbi-text-2);
|
||||
font-size: 12px;
|
||||
font-weight: 640;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sbi-seg > button.is-active {
|
||||
background: #ffffff;
|
||||
color: var(--sbi-accent);
|
||||
box-shadow: 0 1px 2px rgba(16, 34, 56, 0.12);
|
||||
}
|
||||
|
||||
.sbi-delta {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sbi-delta.is-up {
|
||||
color: var(--sbi-up);
|
||||
}
|
||||
|
||||
.sbi-delta.is-down {
|
||||
color: var(--sbi-down);
|
||||
}
|
||||
|
||||
.sbi-delta.is-flat {
|
||||
color: var(--sbi-text-3);
|
||||
}
|
||||
|
||||
.sbi-table-scroll {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sbi-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sbi-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--sbi-border);
|
||||
background: #f7fafd;
|
||||
color: var(--sbi-text-2);
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sbi-table th.is-left,
|
||||
.sbi-table td.is-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sbi-table td {
|
||||
padding: 9px 12px;
|
||||
border-bottom: 1px solid #eef3f9;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sbi-table tbody tr:hover td {
|
||||
background: #f4f8fd;
|
||||
}
|
||||
|
||||
.sbi-table tr.is-total td {
|
||||
background: #f0f5fc;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.sbi-empty {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
place-items: center;
|
||||
padding: 46px 20px;
|
||||
color: var(--sbi-text-3);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sbi-empty strong {
|
||||
color: var(--sbi-text-2);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sbi-skeleton {
|
||||
display: inline-block;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, #edf2f8 25%, #f7fafd 50%, #edf2f8 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: sbi-shimmer 1.1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes sbi-shimmer {
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sbi-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
background: rgba(37, 87, 214, 0.08);
|
||||
color: var(--sbi-accent);
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sbi-badge.is-warn {
|
||||
background: var(--sbi-warn-bg);
|
||||
color: var(--sbi-warn-text);
|
||||
}
|
||||
|
||||
.sbi-metric-hint {
|
||||
cursor: help;
|
||||
border-bottom: 1px dotted var(--sbi-text-3);
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.sbi-shell {
|
||||
grid-template-columns: 68px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sbi-brand-copy,
|
||||
.sbi-nav-item span,
|
||||
.sbi-scope-badge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sbi-nav-item {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sbi-shell *,
|
||||
.sbi-shell *::before,
|
||||
.sbi-shell *::after {
|
||||
animation-duration: 1ms !important;
|
||||
transition-duration: 1ms !important;
|
||||
}
|
||||
}
|
||||
253
databi/src/styles/tables.css
Normal file
253
databi/src/styles/tables.css
Normal file
@ -0,0 +1,253 @@
|
||||
.country-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.country-table {
|
||||
width: 100%;
|
||||
min-width: 720px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.country-table--wide {
|
||||
min-width: 1680px;
|
||||
}
|
||||
|
||||
.country-table th,
|
||||
.country-table td {
|
||||
height: 25px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid rgba(108, 157, 196, 0.22);
|
||||
border-right: 1px solid rgba(108, 157, 196, 0.15);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.country-table th {
|
||||
background: rgba(128, 171, 211, 0.08);
|
||||
color: #9eb7ce;
|
||||
font-size: 12px;
|
||||
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;
|
||||
}
|
||||
|
||||
.country-table th:first-child,
|
||||
.country-table td:first-child {
|
||||
width: 42px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-flag {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
margin-right: 8px;
|
||||
font-style: normal;
|
||||
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;
|
||||
padding: 2px;
|
||||
border: 1px solid rgba(61, 141, 190, 0.42);
|
||||
border-radius: 4px;
|
||||
background: rgba(7, 28, 47, 0.72);
|
||||
}
|
||||
|
||||
.table-tabs button {
|
||||
height: 24px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: #9eb7ce;
|
||||
font-size: 12px;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.table-tabs button.is-active {
|
||||
background: rgba(37, 216, 245, 0.18);
|
||||
color: #e9f6ff;
|
||||
box-shadow: inset 0 0 0 1px rgba(37, 216, 245, 0.38);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.self-game-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.self-game-table {
|
||||
width: 100%;
|
||||
min-width: 520px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.self-game-table--wide {
|
||||
min-width: 980px;
|
||||
}
|
||||
|
||||
.self-game-table th,
|
||||
.self-game-table td {
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border-bottom: 1px solid rgba(108, 157, 196, 0.22);
|
||||
color: #d8ebff;
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.self-game-table th {
|
||||
background: rgba(128, 171, 211, 0.08);
|
||||
color: #9eb7ce;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.self-game-table th:first-child,
|
||||
.self-game-table td:first-child,
|
||||
.self-game-table th:nth-child(2),
|
||||
.self-game-table td:nth-child(2) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.definition-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.definition-row {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid rgba(81, 154, 203, 0.2);
|
||||
border-radius: 8px;
|
||||
background: rgba(6, 27, 46, 0.72);
|
||||
}
|
||||
|
||||
.definition-row strong {
|
||||
color: #e9f6ff;
|
||||
font-size: 13px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.definition-row span {
|
||||
color: #9eb7ce;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.risk-user-cell {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: 30px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.risk-user-avatar {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(39, 228, 245, 0.28);
|
||||
border-radius: 50%;
|
||||
background: rgba(39, 228, 245, 0.12);
|
||||
color: #e9f6ff;
|
||||
font-size: 12px;
|
||||
font-weight: 760;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.risk-user-avatar--empty {
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.risk-user-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.risk-user-copy strong,
|
||||
.risk-user-copy small {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.risk-user-copy strong {
|
||||
color: #e9f6ff;
|
||||
font-size: 12px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.risk-user-copy small {
|
||||
color: #88a7c2;
|
||||
font-size: 10px;
|
||||
}
|
||||
7
databi/src/styles/tokens.css
Normal file
7
databi/src/styles/tokens.css
Normal file
@ -0,0 +1,7 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
text-rendering: geometricPrecision;
|
||||
}
|
||||
56
databi/src/utils/format.js
Normal file
56
databi/src/utils/format.js
Normal file
@ -0,0 +1,56 @@
|
||||
import { numberValue } from "./number.js";
|
||||
|
||||
export function formatMoney(value) {
|
||||
const amount = moneyMajor(value);
|
||||
if (Math.abs(amount) >= 1_000_000) {
|
||||
return `$${(amount / 1_000_000).toFixed(2)}M`;
|
||||
}
|
||||
if (Math.abs(amount) >= 10_000) {
|
||||
return `$${Math.round(amount).toLocaleString("en-US")}`;
|
||||
}
|
||||
return `$${amount.toLocaleString("en-US", { maximumFractionDigits: 2, minimumFractionDigits: amount < 10 ? 2 : 0 })}`;
|
||||
}
|
||||
|
||||
export function formatMoneyFull(value) {
|
||||
return formatSignedDollar(Math.round(moneyMajor(value)));
|
||||
}
|
||||
|
||||
export function formatWholeMoney(value) {
|
||||
return formatSignedDollar(numberValue(value));
|
||||
}
|
||||
|
||||
export function formatCoin(value) {
|
||||
return numberValue(value).toLocaleString("en-US");
|
||||
}
|
||||
|
||||
export function formatNumber(value) {
|
||||
return numberValue(value).toLocaleString("en-US");
|
||||
}
|
||||
|
||||
export function formatPercent(value) {
|
||||
return `${(numberValue(value) * 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
export function formatCompactPercent(value) {
|
||||
return `${(numberValue(value) * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function compactMoneyAxis(value) {
|
||||
if (Math.abs(value) >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
if (Math.abs(value) >= 1_000) {
|
||||
return `${Math.round(value / 1_000)}K`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function moneyMajor(value) {
|
||||
return numberValue(value) / 100;
|
||||
}
|
||||
|
||||
function formatSignedDollar(value) {
|
||||
const amount = Number(value) || 0;
|
||||
const sign = amount < 0 ? "-" : "";
|
||||
return `${sign}$${Math.abs(amount).toLocaleString("en-US")}`;
|
||||
}
|
||||
9
databi/src/utils/number.js
Normal file
9
databi/src/utils/number.js
Normal file
@ -0,0 +1,9 @@
|
||||
export function numberValue(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
}
|
||||
|
||||
export function ratio(numerator, denominator) {
|
||||
const den = numberValue(denominator);
|
||||
return den > 0 ? numberValue(numerator) / den : 0;
|
||||
}
|
||||
149
databi/src/utils/time.js
Normal file
149
databi/src/utils/time.js
Normal file
@ -0,0 +1,149 @@
|
||||
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) {
|
||||
return value ? new Date(`${value}T00:00:00`).getTime() : "";
|
||||
}
|
||||
|
||||
export function endOfDay(value) {
|
||||
return value ? new Date(`${value}T23:59:59`).getTime() : "";
|
||||
}
|
||||
|
||||
export function rangeEndMs(range, timeZone = DEFAULT_TIME_ZONE) {
|
||||
return dateTimeMs(range?.end, range?.endTime || "23:59:59", timeZone);
|
||||
}
|
||||
|
||||
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) {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
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));
|
||||
});
|
||||
@ -20,6 +20,15 @@ server {
|
||||
proxy_pass http://127.0.0.1:13100/api/;
|
||||
}
|
||||
|
||||
location /ops-api/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://127.0.0.1:13114/ops-api/;
|
||||
}
|
||||
|
||||
location = /healthz {
|
||||
proxy_pass http://127.0.0.1:13100/healthz;
|
||||
}
|
||||
|
||||
12
finance/index.html
Normal file
12
finance/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>HYApp 财务系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="finance-root"></div>
|
||||
<script type="module" src="/finance/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1015
finance/src/FinanceApp.jsx
Normal file
1015
finance/src/FinanceApp.jsx
Normal file
File diff suppressed because it is too large
Load Diff
145
finance/src/FinanceApp.test.jsx
Normal file
145
finance/src/FinanceApp.test.jsx
Normal file
@ -0,0 +1,145 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||
import { FinanceApp } from "./FinanceApp.jsx";
|
||||
import {
|
||||
fetchFinanceApplicationOptions,
|
||||
fetchFinanceRechargeApps,
|
||||
fetchFinanceRechargeOverview,
|
||||
fetchFinanceRechargeSummaries,
|
||||
fetchFinanceSession,
|
||||
listFinanceApplications,
|
||||
listFinanceCoinSellerRechargeOrders,
|
||||
listFinanceRechargeDetails,
|
||||
listFinanceRechargeRegions,
|
||||
listFinanceWithdrawalApplications,
|
||||
listMyFinanceApplications
|
||||
} from "./api.js";
|
||||
import { EMPTY_RECHARGE_OVERVIEW, EMPTY_RECHARGE_SUMMARY } from "./format.js";
|
||||
|
||||
vi.mock("./api.js", () => ({
|
||||
approveFinanceApplication: vi.fn(),
|
||||
approveFinanceWithdrawalApplication: vi.fn(),
|
||||
createFinanceApplication: vi.fn(),
|
||||
createFinanceCoinSellerRechargeOrder: vi.fn(),
|
||||
exportFinanceRechargeBills: vi.fn(),
|
||||
fetchFinanceApplicationOptions: vi.fn(),
|
||||
fetchFinanceRechargeApps: vi.fn(),
|
||||
fetchFinanceRechargeOverview: vi.fn(),
|
||||
fetchFinanceRechargeSummaries: vi.fn(),
|
||||
fetchFinanceSession: vi.fn(),
|
||||
filterOperationsByPermissions: vi.fn((operations, permissions) => operations.filter((item) => permissions.includes(item.permissionCode))),
|
||||
grantFinanceCoinSellerRechargeOrder: vi.fn(),
|
||||
listFinanceApplications: vi.fn(),
|
||||
listFinanceCoinSellerRechargeOrders: vi.fn(),
|
||||
listFinanceRechargeDetails: vi.fn(),
|
||||
listFinanceRechargeRegions: vi.fn(),
|
||||
listFinanceWithdrawalApplications: vi.fn(),
|
||||
listMyFinanceApplications: vi.fn(),
|
||||
rejectFinanceApplication: vi.fn(),
|
||||
rejectFinanceWithdrawalApplication: vi.fn(),
|
||||
refreshFinanceGoogleRechargePaid: vi.fn(),
|
||||
verifyFinanceCoinSellerRechargeOrder: vi.fn()
|
||||
}));
|
||||
|
||||
const defaultSession = {
|
||||
canAccessFinance: true,
|
||||
canAuditApplication: false,
|
||||
canCreateApplication: true,
|
||||
canCreateCoinSellerRechargeOrder: false,
|
||||
canGrantCoinSellerRechargeOrder: false,
|
||||
canVerifyCoinSellerRechargeOrder: false,
|
||||
canViewAppRechargeDetails: false,
|
||||
canViewCoinSellerRechargeOrders: false,
|
||||
canViewWithdrawalApplications: false,
|
||||
permissions: ["finance-application:create", "finance-operation:user-coin-credit"],
|
||||
user: { account: "admin", name: "admin" }
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchFinanceApplicationOptions).mockResolvedValue({
|
||||
apps: [{ appCode: "lalu", appName: "lalu" }],
|
||||
operations: [{ label: "增加用户金币", permissionCode: "finance-operation:user-coin-credit", value: "user_coin_credit" }],
|
||||
walletIdentities: []
|
||||
});
|
||||
vi.mocked(fetchFinanceRechargeApps).mockResolvedValue([{ appCode: "lalu", appName: "Lalu" }]);
|
||||
vi.mocked(fetchFinanceRechargeOverview).mockResolvedValue(EMPTY_RECHARGE_OVERVIEW);
|
||||
vi.mocked(fetchFinanceRechargeSummaries).mockResolvedValue({ merged: EMPTY_RECHARGE_SUMMARY, perApp: [] });
|
||||
vi.mocked(fetchFinanceSession).mockResolvedValue(defaultSession);
|
||||
vi.mocked(listFinanceApplications).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||
vi.mocked(listFinanceCoinSellerRechargeOrders).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||
vi.mocked(listFinanceRechargeDetails).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||
vi.mocked(listFinanceRechargeRegions).mockResolvedValue([]);
|
||||
vi.mocked(listFinanceWithdrawalApplications).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||
vi.mocked(listMyFinanceApplications).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
window.history.pushState(null, "", "/");
|
||||
});
|
||||
|
||||
test("preserves the finance entry when the expired session asks the user to log in again", async () => {
|
||||
vi.mocked(fetchFinanceSession).mockRejectedValueOnce(new Error("登录已失效"));
|
||||
window.history.pushState(null, "", "/finance/?view=rechargeDetails");
|
||||
|
||||
render(
|
||||
<ToastProvider>
|
||||
<FinanceApp />
|
||||
</ToastProvider>
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("link", { name: "重新登录" })).toHaveAttribute(
|
||||
"href",
|
||||
"/login?redirect=%2Ffinance%2F%3Fview%3DrechargeDetails"
|
||||
);
|
||||
});
|
||||
|
||||
test("defaults recharge finance views to yesterday in China timezone", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(Date.UTC(2026, 6, 8, 4));
|
||||
vi.mocked(fetchFinanceSession).mockResolvedValue({
|
||||
...defaultSession,
|
||||
canCreateApplication: false,
|
||||
canViewAppRechargeDetails: true,
|
||||
permissions: ["finance-recharge-detail:view"]
|
||||
});
|
||||
|
||||
render(
|
||||
<ToastProvider>
|
||||
<FinanceApp />
|
||||
</ToastProvider>
|
||||
);
|
||||
|
||||
const yesterdayButton = await screen.findByRole("button", { name: "昨天" });
|
||||
expect(yesterdayButton).toHaveClass("is-on");
|
||||
expect(screen.getByRole("button", { name: "今天" })).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => expect(listFinanceRechargeDetails).toHaveBeenCalled());
|
||||
const [query] = vi.mocked(listFinanceRechargeDetails).mock.calls.at(-1);
|
||||
expect(query.start_at_ms).toBe(Date.UTC(2026, 6, 6, 16));
|
||||
expect(query.end_at_ms).toBe(Date.UTC(2026, 6, 7, 16));
|
||||
});
|
||||
|
||||
test("keeps create application dialog open when backdrop is clicked", async () => {
|
||||
render(
|
||||
<ToastProvider>
|
||||
<FinanceApp />
|
||||
</ToastProvider>
|
||||
);
|
||||
|
||||
await screen.findByRole("button", { name: "添加" });
|
||||
expect(document.querySelector(".finance-header")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "添加" }));
|
||||
expect(within(screen.getByRole("dialog")).getByRole("heading", { name: "发起申请" })).toBeInTheDocument();
|
||||
|
||||
const backdrop = document.querySelector(".MuiBackdrop-root");
|
||||
expect(backdrop).toBeTruthy();
|
||||
fireEvent.mouseDown(backdrop);
|
||||
fireEvent.click(backdrop);
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "取消" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
});
|
||||
399
finance/src/api.js
Normal file
399
finance/src/api.js
Normal file
@ -0,0 +1,399 @@
|
||||
import { getMe, refreshSession } from "@/features/auth/api";
|
||||
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||
import { apiRequest, getAccessToken } from "@/shared/api/request";
|
||||
import { FINANCE_OPERATIONS, FINANCE_PERMISSIONS, WALLET_IDENTITIES } from "./constants.js";
|
||||
import {
|
||||
mergeRechargeOverviews,
|
||||
mergeRechargeSummaries,
|
||||
normalizeApplicationPage,
|
||||
normalizeCoinSellerRechargeOrder,
|
||||
normalizeCoinSellerRechargeOrderPage,
|
||||
normalizeRechargeBillPage,
|
||||
normalizeRechargeOverview,
|
||||
normalizeRechargeRegions,
|
||||
normalizeRechargeSummary,
|
||||
normalizeWithdrawalApplicationPage
|
||||
} from "./format.js";
|
||||
|
||||
const appCodeHeader = "X-App-Code";
|
||||
|
||||
export async function fetchFinanceSession() {
|
||||
const session = await currentSession();
|
||||
const permissions = Array.isArray(session.permissions) ? session.permissions : [];
|
||||
const canCreateApplication = permissions.includes(FINANCE_PERMISSIONS.createApplication);
|
||||
const canAuditApplication = permissions.includes(FINANCE_PERMISSIONS.auditApplication);
|
||||
const canAuditWithdrawalApplications =
|
||||
permissions.includes(FINANCE_PERMISSIONS.auditWithdrawalApplications) || permissions.includes(FINANCE_PERMISSIONS.auditApplication);
|
||||
const canViewWithdrawalApplications = permissions.includes(FINANCE_PERMISSIONS.viewWithdrawalApplications) || canAuditWithdrawalApplications;
|
||||
const canViewAppRechargeDetails = permissions.includes(FINANCE_PERMISSIONS.viewAppRechargeDetails);
|
||||
const canViewCoinSellerRechargeOrders = permissions.includes(FINANCE_PERMISSIONS.viewCoinSellerRechargeOrders);
|
||||
const canCreateCoinSellerRechargeOrder = permissions.includes(FINANCE_PERMISSIONS.createCoinSellerRechargeOrder);
|
||||
const canVerifyCoinSellerRechargeOrder = permissions.includes(FINANCE_PERMISSIONS.verifyCoinSellerRechargeOrder);
|
||||
const canGrantCoinSellerRechargeOrder = permissions.includes(FINANCE_PERMISSIONS.grantCoinSellerRechargeOrder);
|
||||
|
||||
return {
|
||||
...session,
|
||||
canAccessFinance:
|
||||
canCreateApplication ||
|
||||
canAuditApplication ||
|
||||
canViewWithdrawalApplications ||
|
||||
canViewAppRechargeDetails ||
|
||||
canViewCoinSellerRechargeOrders,
|
||||
canAuditApplication,
|
||||
canAuditWithdrawalApplications,
|
||||
canCreateApplication,
|
||||
canCreateCoinSellerRechargeOrder,
|
||||
canGrantCoinSellerRechargeOrder,
|
||||
canVerifyCoinSellerRechargeOrder,
|
||||
canViewAppRechargeDetails,
|
||||
canViewCoinSellerRechargeOrders,
|
||||
canViewWithdrawalApplications,
|
||||
permissions
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchFinanceApplicationOptions() {
|
||||
const endpoint = API_ENDPOINTS.listApps;
|
||||
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.listApps), {
|
||||
method: endpoint.method
|
||||
});
|
||||
return normalizeOptions({ apps: data });
|
||||
}
|
||||
|
||||
// 充值详情的 App 目录与其他财务页不同:包含 Yumi 这类 legacy 外部账单源,只用于账单查询。
|
||||
export async function fetchFinanceRechargeApps() {
|
||||
const endpoint = API_ENDPOINTS.listRechargeApps;
|
||||
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.listRechargeApps), {
|
||||
method: endpoint.method
|
||||
});
|
||||
return normalizeApps(data);
|
||||
}
|
||||
|
||||
export async function listFinanceApplications(query = {}) {
|
||||
const endpoint = API_ENDPOINTS.listFinanceApplications;
|
||||
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.listFinanceApplications), {
|
||||
method: endpoint.method,
|
||||
query
|
||||
});
|
||||
return normalizeApplicationPage(data);
|
||||
}
|
||||
|
||||
export async function listMyFinanceApplications(query = {}) {
|
||||
const endpoint = API_ENDPOINTS.listMyFinanceApplications;
|
||||
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.listMyFinanceApplications), {
|
||||
method: endpoint.method,
|
||||
query
|
||||
});
|
||||
return normalizeApplicationPage(data);
|
||||
}
|
||||
|
||||
export async function listFinanceWithdrawalApplications(query = {}) {
|
||||
const endpoint = API_ENDPOINTS.listFinanceWithdrawalApplications;
|
||||
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.listFinanceWithdrawalApplications), {
|
||||
method: endpoint.method,
|
||||
query
|
||||
});
|
||||
return normalizeWithdrawalApplicationPage(data);
|
||||
}
|
||||
|
||||
export async function listFinanceCoinSellerRechargeOrders(query = {}) {
|
||||
const endpoint = API_ENDPOINTS.listFinanceCoinSellerRechargeOrders;
|
||||
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.listFinanceCoinSellerRechargeOrders), {
|
||||
method: endpoint.method,
|
||||
query
|
||||
});
|
||||
return normalizeCoinSellerRechargeOrderPage(data);
|
||||
}
|
||||
|
||||
export async function createFinanceCoinSellerRechargeOrder(payload) {
|
||||
const endpoint = API_ENDPOINTS.createFinanceCoinSellerRechargeOrder;
|
||||
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.createFinanceCoinSellerRechargeOrder), {
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
});
|
||||
return normalizeCoinSellerRechargeOrder(data);
|
||||
}
|
||||
|
||||
export async function verifyFinanceCoinSellerRechargeReceipt(payload) {
|
||||
const endpoint = API_ENDPOINTS.verifyFinanceCoinSellerRechargeReceipt;
|
||||
return apiRequest(apiEndpointPath(API_OPERATIONS.verifyFinanceCoinSellerRechargeReceipt), {
|
||||
body: cleanPayload(payload),
|
||||
method: endpoint.method
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyFinanceCoinSellerRechargeOrder(orderId) {
|
||||
const endpoint = API_ENDPOINTS.verifyFinanceCoinSellerRechargeOrder;
|
||||
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.verifyFinanceCoinSellerRechargeOrder, { order_id: orderId }), {
|
||||
method: endpoint.method
|
||||
});
|
||||
return normalizeCoinSellerRechargeOrder(data);
|
||||
}
|
||||
|
||||
export async function grantFinanceCoinSellerRechargeOrder(orderId) {
|
||||
const endpoint = API_ENDPOINTS.grantFinanceCoinSellerRechargeOrder;
|
||||
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.grantFinanceCoinSellerRechargeOrder, { order_id: orderId }), {
|
||||
method: endpoint.method
|
||||
});
|
||||
return normalizeCoinSellerRechargeOrder(data);
|
||||
}
|
||||
|
||||
export async function listFinanceRechargeDetails(query = {}, apps = []) {
|
||||
const page = positiveInteger(query.page, 1);
|
||||
const pageSize = positiveInteger(query.page_size ?? query.pageSize, 50);
|
||||
const selectedAppCode = stringValue(query.app_code ?? query.appCode);
|
||||
const cleanQuery = cleanRechargeBillQuery(query);
|
||||
|
||||
if (selectedAppCode) {
|
||||
const data = await listRechargeBillsForApp(selectedAppCode, { ...cleanQuery, page, page_size: pageSize });
|
||||
return { ...data, page, pageSize };
|
||||
}
|
||||
|
||||
const appCodes = normalizeApps(apps).map((app) => app.appCode);
|
||||
if (!appCodes.length) {
|
||||
return { items: [], page, pageSize, total: 0 };
|
||||
}
|
||||
|
||||
const mergedPageSize = page * pageSize;
|
||||
const pageResults = await Promise.allSettled(
|
||||
appCodes.map((appCode) => listRechargeBillsForApp(appCode, { ...cleanQuery, page: 1, page_size: mergedPageSize }))
|
||||
);
|
||||
const pages = pageResults.filter((result) => result.status === "fulfilled").map((result) => result.value);
|
||||
const items = pages
|
||||
.flatMap((data) => data.items || [])
|
||||
.sort((left, right) => (right.createdAtMs || 0) - (left.createdAtMs || 0) || String(right.transactionId).localeCompare(String(left.transactionId)));
|
||||
const start = (page - 1) * pageSize;
|
||||
|
||||
return {
|
||||
items: items.slice(start, start + pageSize),
|
||||
page,
|
||||
pageSize,
|
||||
total: pages.reduce((sum, data) => sum + Number(data.total || 0), 0)
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchFinanceRechargeSummary(query = {}, apps = []) {
|
||||
const selectedAppCode = stringValue(query.app_code ?? query.appCode);
|
||||
const cleanQuery = cleanRechargeBillQuery(query);
|
||||
|
||||
if (selectedAppCode) {
|
||||
return getRechargeSummaryForApp(selectedAppCode, cleanQuery);
|
||||
}
|
||||
|
||||
const appCodes = normalizeApps(apps).map((app) => app.appCode);
|
||||
if (!appCodes.length) {
|
||||
return mergeRechargeSummaries([]);
|
||||
}
|
||||
const summaries = await Promise.all(appCodes.map((appCode) => getRechargeSummaryForApp(appCode, cleanQuery)));
|
||||
return mergeRechargeSummaries(summaries);
|
||||
}
|
||||
|
||||
export async function listFinanceRechargeRegions(appCode) {
|
||||
const endpoint = API_ENDPOINTS.listRechargeRegions;
|
||||
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.listRechargeRegions), {
|
||||
headers: { [appCodeHeader]: appCode },
|
||||
method: endpoint.method
|
||||
});
|
||||
return normalizeRechargeRegions(data);
|
||||
}
|
||||
|
||||
// fetchFinanceRechargeSummaries 返回逐 App 的汇总(App 对比表用)和全局合并值。
|
||||
export async function fetchFinanceRechargeSummaries(query = {}, apps = []) {
|
||||
const selectedAppCode = stringValue(query.app_code ?? query.appCode);
|
||||
const cleanQuery = cleanRechargeBillQuery(query);
|
||||
const appList = selectedAppCode
|
||||
? normalizeApps(apps).filter((app) => app.appCode === selectedAppCode)
|
||||
: normalizeApps(apps);
|
||||
if (!appList.length && selectedAppCode) {
|
||||
appList.push({ appCode: selectedAppCode, appName: selectedAppCode });
|
||||
}
|
||||
// 单个 App 查询失败(如后端灰度中)按空汇总降级,不拖垮其余 App 的数据。
|
||||
const results = await Promise.allSettled(appList.map((app) => getRechargeSummaryForApp(app.appCode, cleanQuery)));
|
||||
const summaries = results.map((result) =>
|
||||
result.status === "fulfilled" ? result.value : normalizeRechargeSummary({})
|
||||
);
|
||||
const perApp = appList.map((app, index) => ({ ...app, summary: summaries[index] }));
|
||||
return { merged: mergeRechargeSummaries(summaries), perApp };
|
||||
}
|
||||
|
||||
export async function fetchFinanceRechargeOverview(query = {}, apps = []) {
|
||||
const selectedAppCode = stringValue(query.app_code ?? query.appCode);
|
||||
const cleanQuery = { ...cleanRechargeBillQuery(query), tz_offset_minutes: 480 };
|
||||
if (selectedAppCode) {
|
||||
return getRechargeOverviewForApp(selectedAppCode, cleanQuery);
|
||||
}
|
||||
const appCodes = normalizeApps(apps).map((app) => app.appCode);
|
||||
if (!appCodes.length) {
|
||||
return normalizeRechargeOverview({});
|
||||
}
|
||||
const results = await Promise.allSettled(appCodes.map((appCode) => getRechargeOverviewForApp(appCode, cleanQuery)));
|
||||
return mergeRechargeOverviews(
|
||||
results.filter((result) => result.status === "fulfilled").map((result) => result.value)
|
||||
);
|
||||
}
|
||||
|
||||
export async function exportFinanceRechargeBills(appCode, query = {}) {
|
||||
const endpoint = API_ENDPOINTS.exportRechargeBills;
|
||||
return apiRequest(apiEndpointPath(API_OPERATIONS.exportRechargeBills), {
|
||||
headers: { [appCodeHeader]: appCode },
|
||||
method: endpoint.method,
|
||||
query: cleanRechargeBillQuery(query),
|
||||
raw: true
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshFinanceGoogleRechargePaid(appCode, transactionIds) {
|
||||
const endpoint = API_ENDPOINTS.refreshGoogleRechargePaid;
|
||||
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.refreshGoogleRechargePaid), {
|
||||
body: { transactionIds },
|
||||
headers: { [appCodeHeader]: appCode },
|
||||
method: endpoint.method
|
||||
});
|
||||
return Array.isArray(data?.items) ? data.items : [];
|
||||
}
|
||||
|
||||
export async function createFinanceApplication(payload) {
|
||||
const endpoint = API_ENDPOINTS.createFinanceApplication;
|
||||
return apiRequest(apiEndpointPath(API_OPERATIONS.createFinanceApplication), {
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
});
|
||||
}
|
||||
|
||||
export async function approveFinanceApplication(applicationId, payload) {
|
||||
const endpoint = API_ENDPOINTS.approveFinanceApplication;
|
||||
return apiRequest(apiEndpointPath(API_OPERATIONS.approveFinanceApplication, { application_id: applicationId }), {
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
});
|
||||
}
|
||||
|
||||
export async function rejectFinanceApplication(applicationId, payload) {
|
||||
const endpoint = API_ENDPOINTS.rejectFinanceApplication;
|
||||
return apiRequest(apiEndpointPath(API_OPERATIONS.rejectFinanceApplication, { application_id: applicationId }), {
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
});
|
||||
}
|
||||
|
||||
export async function approveFinanceWithdrawalApplication(applicationId, payload) {
|
||||
const endpoint = API_ENDPOINTS.approveFinanceWithdrawalApplication;
|
||||
return apiRequest(apiEndpointPath(API_OPERATIONS.approveFinanceWithdrawalApplication, { application_id: applicationId }), {
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
});
|
||||
}
|
||||
|
||||
export async function rejectFinanceWithdrawalApplication(applicationId, payload) {
|
||||
const endpoint = API_ENDPOINTS.rejectFinanceWithdrawalApplication;
|
||||
return apiRequest(apiEndpointPath(API_OPERATIONS.rejectFinanceWithdrawalApplication, { application_id: applicationId }), {
|
||||
body: payload,
|
||||
method: endpoint.method
|
||||
});
|
||||
}
|
||||
|
||||
export function filterOperationsByPermissions(operations, permissions) {
|
||||
const allowed = new Set(permissions || []);
|
||||
return (operations || []).filter((operation) => !operation.permissionCode || allowed.has(operation.permissionCode));
|
||||
}
|
||||
|
||||
async function currentSession() {
|
||||
try {
|
||||
return await refreshSession();
|
||||
} catch {
|
||||
if (getAccessToken()) {
|
||||
return getMe();
|
||||
}
|
||||
throw new Error("登录已失效");
|
||||
}
|
||||
}
|
||||
|
||||
async function getRechargeOverviewForApp(appCode, query = {}) {
|
||||
const endpoint = API_ENDPOINTS.getRechargeBillOverview;
|
||||
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.getRechargeBillOverview), {
|
||||
headers: { [appCodeHeader]: appCode },
|
||||
method: endpoint.method,
|
||||
query
|
||||
});
|
||||
return normalizeRechargeOverview(data);
|
||||
}
|
||||
|
||||
async function getRechargeSummaryForApp(appCode, query = {}) {
|
||||
const endpoint = API_ENDPOINTS.getRechargeBillSummary;
|
||||
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.getRechargeBillSummary), {
|
||||
headers: { [appCodeHeader]: appCode },
|
||||
method: endpoint.method,
|
||||
query
|
||||
});
|
||||
return normalizeRechargeSummary(data);
|
||||
}
|
||||
|
||||
async function listRechargeBillsForApp(appCode, query = {}) {
|
||||
const endpoint = API_ENDPOINTS.listRechargeBills;
|
||||
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.listRechargeBills), {
|
||||
headers: { [appCodeHeader]: appCode },
|
||||
method: endpoint.method,
|
||||
query
|
||||
});
|
||||
return normalizeRechargeBillPage(data);
|
||||
}
|
||||
|
||||
function normalizeOptions(data = {}) {
|
||||
return {
|
||||
apps: normalizeApps(data.apps),
|
||||
operations: FINANCE_OPERATIONS,
|
||||
walletIdentities: WALLET_IDENTITIES
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeApps(data) {
|
||||
return listItems(data)
|
||||
.map((item) => {
|
||||
const appCode = stringValue(item.appCode ?? item.app_code ?? item.code);
|
||||
if (!appCode) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
appCode,
|
||||
appName: stringValue(item.appName ?? item.app_name ?? item.name) || appCode
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function listItems(data) {
|
||||
if (Array.isArray(data)) {
|
||||
return data;
|
||||
}
|
||||
if (Array.isArray(data?.items)) {
|
||||
return data.items;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function cleanRechargeBillQuery(query = {}) {
|
||||
const {
|
||||
app_code: _appCode,
|
||||
appCode: _camelAppCode,
|
||||
page: _page,
|
||||
page_size: _pageSize,
|
||||
pageSize: _camelPageSize,
|
||||
...rest
|
||||
} = query;
|
||||
return rest;
|
||||
}
|
||||
|
||||
function cleanPayload(payload = {}) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(payload).filter(([, value]) => value !== undefined && value !== null && value !== "")
|
||||
);
|
||||
}
|
||||
|
||||
function positiveInteger(value, fallback) {
|
||||
const number = Number(value);
|
||||
return Number.isInteger(number) && number > 0 ? number : fallback;
|
||||
}
|
||||
|
||||
function stringValue(value) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
223
finance/src/api.test.js
Normal file
223
finance/src/api.test.js
Normal file
@ -0,0 +1,223 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { setAccessToken } from "@/shared/api/request";
|
||||
import {
|
||||
approveFinanceApplication,
|
||||
approveFinanceWithdrawalApplication,
|
||||
createFinanceApplication,
|
||||
createFinanceCoinSellerRechargeOrder,
|
||||
fetchFinanceApplicationOptions,
|
||||
filterOperationsByPermissions,
|
||||
grantFinanceCoinSellerRechargeOrder,
|
||||
listFinanceApplications,
|
||||
listFinanceCoinSellerRechargeOrders,
|
||||
listFinanceRechargeDetails,
|
||||
listMyFinanceApplications,
|
||||
listFinanceWithdrawalApplications,
|
||||
rejectFinanceApplication,
|
||||
rejectFinanceWithdrawalApplication,
|
||||
verifyFinanceCoinSellerRechargeReceipt,
|
||||
verifyFinanceCoinSellerRechargeOrder
|
||||
} from "./api.js";
|
||||
import { FINANCE_OPERATIONS } from "./constants.js";
|
||||
|
||||
afterEach(() => {
|
||||
setAccessToken("");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("finance application APIs use generated endpoint paths", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url) => {
|
||||
if (String(url).includes("/admin/apps")) {
|
||||
return new Response(JSON.stringify({ code: 0, data: { items: [{ appCode: "lalu", appName: "lalu" }], total: 1 } }));
|
||||
}
|
||||
if (String(url).includes("/applications?")) {
|
||||
return new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 50, total: 0 } }));
|
||||
}
|
||||
if (String(url).includes("/applications/mine?")) {
|
||||
return new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 50, total: 0 } }));
|
||||
}
|
||||
if (String(url).includes("/withdrawal-applications?")) {
|
||||
return new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 50, total: 0 } }));
|
||||
}
|
||||
return new Response(JSON.stringify({ code: 0, data: { id: 12 } }));
|
||||
})
|
||||
);
|
||||
|
||||
const options = await fetchFinanceApplicationOptions();
|
||||
await listFinanceApplications({ page: 1, page_size: 50, status: "pending" });
|
||||
await listMyFinanceApplications({ page: 1, page_size: 50 });
|
||||
await listFinanceWithdrawalApplications({ page: 1, page_size: 50 });
|
||||
await createFinanceApplication({
|
||||
appCode: "lalu",
|
||||
coinAmount: 100,
|
||||
operation: "coin_seller_coin_credit",
|
||||
rechargeAmount: 10,
|
||||
targetUserId: "10001"
|
||||
});
|
||||
await approveFinanceApplication(12, { auditRemark: "ok" });
|
||||
await rejectFinanceApplication(13, { auditRemark: "bad" });
|
||||
await approveFinanceWithdrawalApplication(14, { auditImageUrl: "https://cdn.example.com/pay.png", auditRemark: "ok" });
|
||||
await rejectFinanceWithdrawalApplication(15, { auditRemark: "bad" });
|
||||
|
||||
const calls = vi.mocked(fetch).mock.calls;
|
||||
expect(options.apps).toEqual([{ appCode: "lalu", appName: "lalu" }]);
|
||||
expect(options.operations.map((item) => item.label)).toEqual([
|
||||
"增加用户金币",
|
||||
"减少用户金币",
|
||||
"用户钱包扣减",
|
||||
"用户钱包增加",
|
||||
"增加币商金币",
|
||||
"减少币商金币"
|
||||
]);
|
||||
expect(String(calls[0][0])).toContain("/api/v1/admin/apps");
|
||||
expect(calls[0][1]?.method).toBe("GET");
|
||||
expect(String(calls[1][0])).toContain("/api/v1/admin/finance/applications?");
|
||||
expect(String(calls[1][0])).toContain("status=pending");
|
||||
expect(calls[1][1]?.method).toBe("GET");
|
||||
expect(String(calls[2][0])).toContain("/api/v1/admin/finance/applications/mine?");
|
||||
expect(calls[2][1]?.method).toBe("GET");
|
||||
expect(String(calls[3][0])).toContain("/api/v1/admin/finance/withdrawal-applications?");
|
||||
expect(calls[3][1]?.method).toBe("GET");
|
||||
expect(String(calls[4][0])).toContain("/api/v1/admin/finance/applications");
|
||||
expect(calls[4][1]?.method).toBe("POST");
|
||||
expect(JSON.parse(String(calls[4][1]?.body))).toMatchObject({ appCode: "lalu", operation: "coin_seller_coin_credit" });
|
||||
expect(String(calls[5][0])).toContain("/api/v1/admin/finance/applications/12/approve");
|
||||
expect(calls[5][1]?.method).toBe("POST");
|
||||
expect(JSON.parse(String(calls[5][1]?.body))).toEqual({ auditRemark: "ok" });
|
||||
expect(String(calls[6][0])).toContain("/api/v1/admin/finance/applications/13/reject");
|
||||
expect(calls[6][1]?.method).toBe("POST");
|
||||
expect(JSON.parse(String(calls[6][1]?.body))).toEqual({ auditRemark: "bad" });
|
||||
expect(String(calls[7][0])).toContain("/api/v1/admin/finance/withdrawal-applications/14/approve");
|
||||
expect(calls[7][1]?.method).toBe("POST");
|
||||
expect(JSON.parse(String(calls[7][1]?.body))).toEqual({ auditImageUrl: "https://cdn.example.com/pay.png", auditRemark: "ok" });
|
||||
expect(String(calls[8][0])).toContain("/api/v1/admin/finance/withdrawal-applications/15/reject");
|
||||
expect(calls[8][1]?.method).toBe("POST");
|
||||
expect(JSON.parse(String(calls[8][1]?.body))).toEqual({ auditRemark: "bad" });
|
||||
});
|
||||
|
||||
test("finance coin seller recharge order APIs use generated endpoint paths", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url) => {
|
||||
if (String(url).includes("/coin-seller-recharges?")) {
|
||||
return new Response(JSON.stringify({ code: 0, data: { items: [], page: 1, pageSize: 50, total: 0 } }));
|
||||
}
|
||||
return new Response(JSON.stringify({ code: 0, data: { id: "order-1", targetUserId: "10001" } }));
|
||||
})
|
||||
);
|
||||
|
||||
await listFinanceCoinSellerRechargeOrders({ page: 1, page_size: 50, provider_code: "mifapay" });
|
||||
await createFinanceCoinSellerRechargeOrder({
|
||||
appCode: "lalu",
|
||||
coinAmount: 100000,
|
||||
externalOrderNo: "MIFA-001",
|
||||
providerAmountMinor: 125050,
|
||||
providerCode: "mifapay",
|
||||
providerCurrencyCode: "PHP",
|
||||
targetUserId: "10001",
|
||||
usdAmount: 10
|
||||
});
|
||||
await verifyFinanceCoinSellerRechargeReceipt({
|
||||
appCode: "lalu",
|
||||
externalOrderNo: "MIFA-001",
|
||||
providerAmountMinor: 125050,
|
||||
providerCode: "mifapay",
|
||||
providerCurrencyCode: "PHP",
|
||||
usdAmount: 10
|
||||
});
|
||||
await verifyFinanceCoinSellerRechargeOrder("order-1");
|
||||
await grantFinanceCoinSellerRechargeOrder("order-1");
|
||||
|
||||
const calls = vi.mocked(fetch).mock.calls;
|
||||
expect(String(calls[0][0])).toContain("/api/v1/admin/finance/orders/coin-seller-recharges?");
|
||||
expect(String(calls[0][0])).toContain("provider_code=mifapay");
|
||||
expect(calls[0][1]?.method).toBe("GET");
|
||||
expect(String(calls[1][0])).toContain("/api/v1/admin/finance/orders/coin-seller-recharges");
|
||||
expect(calls[1][1]?.method).toBe("POST");
|
||||
expect(JSON.parse(String(calls[1][1]?.body))).toEqual({
|
||||
appCode: "lalu",
|
||||
coinAmount: 100000,
|
||||
externalOrderNo: "MIFA-001",
|
||||
providerAmountMinor: 125050,
|
||||
providerCode: "mifapay",
|
||||
providerCurrencyCode: "PHP",
|
||||
targetUserId: "10001",
|
||||
usdAmount: 10
|
||||
});
|
||||
expect(String(calls[2][0])).toContain("/api/v1/admin/finance/orders/coin-seller-recharges/receipt-verifications");
|
||||
expect(calls[2][1]?.method).toBe("POST");
|
||||
expect(JSON.parse(String(calls[2][1]?.body))).toEqual({
|
||||
appCode: "lalu",
|
||||
externalOrderNo: "MIFA-001",
|
||||
providerAmountMinor: 125050,
|
||||
providerCode: "mifapay",
|
||||
providerCurrencyCode: "PHP",
|
||||
usdAmount: 10
|
||||
});
|
||||
expect(String(calls[3][0])).toContain("/api/v1/admin/finance/orders/coin-seller-recharges/order-1/verify");
|
||||
expect(calls[3][1]?.method).toBe("POST");
|
||||
expect(String(calls[4][0])).toContain("/api/v1/admin/finance/orders/coin-seller-recharges/order-1/grant");
|
||||
expect(calls[4][1]?.method).toBe("POST");
|
||||
});
|
||||
|
||||
test("filters operation options by backend-configured permissions", () => {
|
||||
const allowed = filterOperationsByPermissions(FINANCE_OPERATIONS, [
|
||||
"finance-operation:user-coin-credit",
|
||||
"finance-operation:coin-seller-coin-debit"
|
||||
]);
|
||||
|
||||
expect(allowed.map((item) => item.value)).toEqual(["user_coin_credit", "coin_seller_coin_debit"]);
|
||||
});
|
||||
|
||||
test("finance recharge details query every app with app header and merge by recharge time", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url, init) => {
|
||||
const appCode = init?.headers?.["X-App-Code"];
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
appCode,
|
||||
coinAmount: appCode === "aslan" ? 2000 : 1000,
|
||||
createdAtMs: appCode === "aslan" ? 1760003600000 : 1760000000000,
|
||||
exchangeCoinAmount: appCode === "aslan" ? 2000 : 1000,
|
||||
exchangeUsdMinorAmount: appCode === "aslan" ? 200 : 100,
|
||||
rechargeType: "google_play_recharge",
|
||||
status: "succeeded",
|
||||
transactionId: `${appCode}-tx`,
|
||||
usdMinorAmount: appCode === "aslan" ? 200 : 100,
|
||||
userId: "10001"
|
||||
}
|
||||
],
|
||||
page: 1,
|
||||
pageSize: 2,
|
||||
total: 1
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
const page = await listFinanceRechargeDetails(
|
||||
{ page: 1, page_size: 2, start_at_ms: 1760000000000, status: "succeeded" },
|
||||
[
|
||||
{ appCode: "lalu", appName: "lalu" },
|
||||
{ appCode: "aslan", appName: "aslan" }
|
||||
]
|
||||
);
|
||||
|
||||
const calls = vi.mocked(fetch).mock.calls;
|
||||
expect(page.total).toBe(2);
|
||||
expect(page.items.map((item) => item.transactionId)).toEqual(["aslan-tx", "lalu-tx"]);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls.map(([, init]) => init?.headers?.["X-App-Code"])).toEqual(["lalu", "aslan"]);
|
||||
expect(String(calls[0][0])).toContain("/api/v1/admin/payment/recharge-bills?");
|
||||
expect(String(calls[0][0])).toContain("page_size=2");
|
||||
expect(String(calls[0][0])).toContain("status=succeeded");
|
||||
expect(String(calls[0][0])).toContain("start_at_ms=1760000000000");
|
||||
});
|
||||
142
finance/src/components/FinanceApplicationForm.jsx
Normal file
142
finance/src/components/FinanceApplicationForm.jsx
Normal file
@ -0,0 +1,142 @@
|
||||
import SendOutlined from "@mui/icons-material/SendOutlined";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Button from "@mui/material/Button";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||
|
||||
export function FinanceApplicationForm({
|
||||
apps,
|
||||
disabled,
|
||||
error,
|
||||
form,
|
||||
loading,
|
||||
operations,
|
||||
walletIdentities,
|
||||
onChange,
|
||||
onCancel,
|
||||
onSubmit
|
||||
}) {
|
||||
const selectedOperation = operations.find((operation) => operation.value === form.operation);
|
||||
const needsWalletIdentity = Boolean(selectedOperation?.requiresWalletIdentity);
|
||||
const canSubmit = !disabled && !loading && apps.length > 0 && operations.length > 0;
|
||||
|
||||
const updateField = (key, value) => {
|
||||
if (key === "operation") {
|
||||
const nextOperation = operations.find((operation) => operation.value === value);
|
||||
onChange({ ...form, operation: value, walletIdentity: nextOperation?.requiresWalletIdentity ? form.walletIdentity : "" });
|
||||
return;
|
||||
}
|
||||
onChange({ ...form, [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={onCancel ? "finance-form finance-form--dialog" : "finance-panel finance-form"} onSubmit={onSubmit}>
|
||||
{error ? <Alert severity="error">{error}</Alert> : null}
|
||||
{disabled ? <Alert severity="warning">无发起财务申请权限</Alert> : null}
|
||||
{!loading && !disabled && (!apps.length || !operations.length) ? <Alert severity="warning">暂无可用 APP 或操作权限</Alert> : null}
|
||||
<div className="finance-form-grid">
|
||||
<TextField
|
||||
disabled={disabled || loading}
|
||||
label="APP"
|
||||
required
|
||||
select
|
||||
value={form.appCode}
|
||||
onChange={(event) => updateField("appCode", event.target.value)}
|
||||
>
|
||||
{apps.map((app) => (
|
||||
<MenuItem key={app.appCode} value={app.appCode}>
|
||||
{app.appName || app.appCode}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled || loading}
|
||||
label="操作内容"
|
||||
required
|
||||
select
|
||||
value={form.operation}
|
||||
onChange={(event) => updateField("operation", event.target.value)}
|
||||
>
|
||||
{operations.map((operation) => (
|
||||
<MenuItem key={operation.value} value={operation.value}>
|
||||
{operation.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
{needsWalletIdentity ? (
|
||||
<TextField
|
||||
disabled={disabled || loading}
|
||||
label="钱包身份"
|
||||
required
|
||||
select
|
||||
value={form.walletIdentity}
|
||||
onChange={(event) => updateField("walletIdentity", event.target.value)}
|
||||
>
|
||||
{walletIdentities.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
) : null}
|
||||
<TextField
|
||||
disabled={disabled || loading}
|
||||
label="目标用户 ID"
|
||||
required
|
||||
value={form.targetUserId}
|
||||
onChange={(event) => updateField("targetUserId", event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled || loading}
|
||||
inputProps={{ min: 0, step: "1" }}
|
||||
label="金币数量"
|
||||
required
|
||||
type="number"
|
||||
value={form.coinAmount}
|
||||
onChange={(event) => updateField("coinAmount", event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled || loading}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">USD</InputAdornment> }}
|
||||
inputProps={{ min: 0, step: "0.01" }}
|
||||
label="充值金额"
|
||||
required
|
||||
type="number"
|
||||
value={form.rechargeAmount}
|
||||
onChange={(event) => updateField("rechargeAmount", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="finance-evidence-grid">
|
||||
<UploadField
|
||||
className="finance-credential-upload"
|
||||
disabled={disabled || loading}
|
||||
hideType
|
||||
label="凭证图片"
|
||||
previewClassName="finance-credential-preview"
|
||||
value={form.credentialImageUrl}
|
||||
onChange={(value) => updateField("credentialImageUrl", value)}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled || loading}
|
||||
label="凭证文字"
|
||||
minRows={7}
|
||||
multiline
|
||||
value={form.credentialText}
|
||||
onChange={(event) => updateField("credentialText", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="finance-form-actions">
|
||||
{onCancel ? (
|
||||
<Button disabled={loading} variant="outlined" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
) : null}
|
||||
<Button disabled={!canSubmit} startIcon={<SendOutlined fontSize="small" />} type="submit" variant="contained">
|
||||
发起申请
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
193
finance/src/components/FinanceApplicationList.jsx
Normal file
193
finance/src/components/FinanceApplicationList.jsx
Normal file
@ -0,0 +1,193 @@
|
||||
import CheckCircleOutlineOutlined from "@mui/icons-material/CheckCircleOutlineOutlined";
|
||||
import CloseOutlined from "@mui/icons-material/CloseOutlined";
|
||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||
import Button from "@mui/material/Button";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import TableContainer from "@mui/material/TableContainer";
|
||||
import TableHead from "@mui/material/TableHead";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import { APPLICATION_STATUS } from "../constants.js";
|
||||
import { formatAmount, formatTime, operationLabel, statusLabel, walletIdentityLabel } from "../format.js";
|
||||
|
||||
export function FinanceApplicationList({ applications, error, filters, loading, onAudit, onFiltersChange, onReload, operations, options }) {
|
||||
const items = applications.items || [];
|
||||
const page = Number(applications.page || filters.page || 1);
|
||||
const pageSize = Number(applications.pageSize || 50);
|
||||
const total = Number(applications.total || 0);
|
||||
const hasNextPage = page * pageSize < total;
|
||||
|
||||
return (
|
||||
<section className="finance-panel finance-list">
|
||||
<div className="finance-toolbar">
|
||||
<TextField
|
||||
label="APP"
|
||||
select
|
||||
value={filters.appCode}
|
||||
onChange={(event) => onFiltersChange({ appCode: event.target.value })}
|
||||
>
|
||||
<MenuItem value="">全部 APP</MenuItem>
|
||||
{options.apps.map((app) => (
|
||||
<MenuItem key={app.appCode} value={app.appCode}>
|
||||
{app.appName || app.appCode}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="操作内容"
|
||||
select
|
||||
value={filters.operation}
|
||||
onChange={(event) => onFiltersChange({ operation: event.target.value })}
|
||||
>
|
||||
<MenuItem value="">全部操作</MenuItem>
|
||||
{operations.map((operation) => (
|
||||
<MenuItem key={operation.value} value={operation.value}>
|
||||
{operation.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField label="状态" select value={filters.status} onChange={(event) => onFiltersChange({ status: event.target.value })}>
|
||||
<MenuItem value="">全部状态</MenuItem>
|
||||
{APPLICATION_STATUS.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="搜索"
|
||||
value={filters.keyword}
|
||||
onChange={(event) => onFiltersChange({ keyword: event.target.value })}
|
||||
/>
|
||||
<Button startIcon={<RefreshOutlined fontSize="small" />} variant="outlined" onClick={onReload}>
|
||||
刷新列表
|
||||
</Button>
|
||||
</div>
|
||||
{error ? <div className="finance-error">{error}</div> : null}
|
||||
{loading && !items.length ? <div className="finance-skeleton" /> : null}
|
||||
{!loading || items.length ? (
|
||||
<TableContainer className="finance-table-wrap">
|
||||
<Table className="finance-table" size="small" stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>APP</TableCell>
|
||||
<TableCell>操作</TableCell>
|
||||
<TableCell>目标用户</TableCell>
|
||||
<TableCell align="right">金币数量</TableCell>
|
||||
<TableCell align="right">充值金额</TableCell>
|
||||
<TableCell>凭证</TableCell>
|
||||
<TableCell>申请人</TableCell>
|
||||
<TableCell className="finance-time-cell">发起申请时间</TableCell>
|
||||
<TableCell>审批人</TableCell>
|
||||
<TableCell className="finance-time-cell">审核时间</TableCell>
|
||||
<TableCell>状态</TableCell>
|
||||
<TableCell align="right">审核</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{items.length ? (
|
||||
items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{item.appName || item.appCode || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Stacked primary={operationLabel(item.operation, operations)} secondary={walletIdentityLabel(item.walletIdentity)} />
|
||||
</TableCell>
|
||||
<TableCell>{item.targetUserId || "-"}</TableCell>
|
||||
<TableCell align="right">{formatAmount(item.coinAmount)}</TableCell>
|
||||
<TableCell align="right">{formatAmount(item.rechargeAmount)}</TableCell>
|
||||
<TableCell>
|
||||
<Evidence item={item} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stacked primary={item.applicantName || "-"} secondary={item.applicantUserId || ""} />
|
||||
</TableCell>
|
||||
<TableCell className="finance-time-cell">{formatTime(item.createdAtMs)}</TableCell>
|
||||
<TableCell>
|
||||
<Stacked primary={item.auditorName || "-"} secondary={item.auditorUserId || ""} />
|
||||
</TableCell>
|
||||
<TableCell className="finance-time-cell">{formatTime(item.auditedAtMs)}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={item.status} />
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{item.status === "pending" ? (
|
||||
<span className="finance-row-actions">
|
||||
<Tooltip arrow title="通过">
|
||||
<IconButton aria-label="通过" onClick={() => onAudit({ ...item, decision: "approved" })}>
|
||||
<CheckCircleOutlineOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip arrow title="拒绝">
|
||||
<IconButton aria-label="拒绝" onClick={() => onAudit({ ...item, decision: "rejected" })}>
|
||||
<CloseOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</span>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell align="center" colSpan={12}>
|
||||
当前无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : null}
|
||||
{total > pageSize ? (
|
||||
<div className="finance-pagination">
|
||||
<Button disabled={page <= 1 || loading} variant="outlined" onClick={() => onFiltersChange({ page: page - 1 })}>
|
||||
上一页
|
||||
</Button>
|
||||
<span>
|
||||
第 {page} 页 / 共 {total} 条
|
||||
</span>
|
||||
<Button disabled={!hasNextPage || loading} variant="outlined" onClick={() => onFiltersChange({ page: page + 1 })}>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Evidence({ item }) {
|
||||
if (!item.credentialImageUrl && !item.credentialText) {
|
||||
return "-";
|
||||
}
|
||||
return (
|
||||
<span className="finance-evidence">
|
||||
{item.credentialImageUrl ? (
|
||||
<a href={item.credentialImageUrl} rel="noreferrer" target="_blank">
|
||||
图片
|
||||
</a>
|
||||
) : null}
|
||||
{item.credentialText ? <span title={item.credentialText}>{item.credentialText}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Stacked({ primary, secondary }) {
|
||||
return (
|
||||
<span className="finance-stack">
|
||||
<span>{primary || "-"}</span>
|
||||
<small>{secondary || ""}</small>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
const tone = status === "approved" ? "success" : status === "rejected" ? "danger" : "warning";
|
||||
return <span className={`finance-status finance-status--${tone}`}>{statusLabel(status)}</span>;
|
||||
}
|
||||
67
finance/src/components/FinanceApplicationList.test.jsx
Normal file
67
finance/src/components/FinanceApplicationList.test.jsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { FinanceApplicationList } from "./FinanceApplicationList.jsx";
|
||||
|
||||
const baseProps = {
|
||||
applications: {
|
||||
items: [],
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 0
|
||||
},
|
||||
error: "",
|
||||
filters: {
|
||||
appCode: "",
|
||||
keyword: "",
|
||||
operation: "",
|
||||
page: 1,
|
||||
status: ""
|
||||
},
|
||||
loading: false,
|
||||
onAudit: vi.fn(),
|
||||
onFiltersChange: vi.fn(),
|
||||
onReload: vi.fn(),
|
||||
operations: [{ label: "增加币商金币", value: "coin_seller_coin_credit" }],
|
||||
options: { apps: [] }
|
||||
};
|
||||
|
||||
test("renders finance audit table with explicit applicant and audit time columns", () => {
|
||||
render(
|
||||
<FinanceApplicationList
|
||||
{...baseProps}
|
||||
applications={{
|
||||
...baseProps.applications,
|
||||
items: [
|
||||
{
|
||||
appCode: "lalu",
|
||||
applicantName: "张三",
|
||||
auditedAtMs: 1760003600000,
|
||||
auditorName: "王财务",
|
||||
auditorUserId: 99,
|
||||
coinAmount: 200,
|
||||
createdAtMs: 1760000000000,
|
||||
id: 9,
|
||||
operation: "coin_seller_coin_credit",
|
||||
rechargeAmount: 20,
|
||||
status: "approved",
|
||||
targetUserId: "88"
|
||||
}
|
||||
],
|
||||
total: 1
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("columnheader", { name: "发起申请时间" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "审批人" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "审核时间" })).toBeInTheDocument();
|
||||
expect(screen.getByText("王财务")).toBeInTheDocument();
|
||||
expect(screen.getByText("99")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps empty finance audit table colspan aligned with visible columns", () => {
|
||||
const { container } = render(<FinanceApplicationList {...baseProps} />);
|
||||
|
||||
expect(container.querySelector("tbody td")?.colSpan).toBe(12);
|
||||
expect(screen.getByText("当前无数据")).toBeInTheDocument();
|
||||
});
|
||||
70
finance/src/components/FinanceAuditDialog.jsx
Normal file
70
finance/src/components/FinanceAuditDialog.jsx
Normal file
@ -0,0 +1,70 @@
|
||||
import Button from "@mui/material/Button";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useEffect, useState } from "react";
|
||||
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||
import { operationLabel } from "../format.js";
|
||||
|
||||
export function FinanceAuditDialog({ application, loading, onClose, onSubmit, open, operations, requireApprovalImage = false, requireRejectRemark = false }) {
|
||||
const [remark, setRemark] = useState("");
|
||||
const [auditImageUrl, setAuditImageUrl] = useState("");
|
||||
const decision = application?.decision || "approved";
|
||||
const needsImage = decision === "approved" && requireApprovalImage;
|
||||
const needsRejectRemark = decision === "rejected" && requireRejectRemark;
|
||||
const submitDisabled = loading || (needsImage && !auditImageUrl) || (needsRejectRemark && !remark.trim());
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRemark("");
|
||||
setAuditImageUrl("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const submit = () => {
|
||||
onSubmit({ auditImageUrl, decision, remark });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog fullWidth maxWidth="sm" open={open} onClose={loading ? undefined : onClose}>
|
||||
<DialogTitle>{decision === "approved" ? "通过申请" : "拒绝申请"}</DialogTitle>
|
||||
<DialogContent className="finance-audit-dialog">
|
||||
<div className="finance-audit-summary">
|
||||
<span>{application?.appName || application?.appCode || "-"}</span>
|
||||
<strong>{operationLabel(application?.operation, operations)}</strong>
|
||||
<span>{application?.targetUserId || "-"}</span>
|
||||
</div>
|
||||
<TextField
|
||||
disabled={loading}
|
||||
label="审核备注"
|
||||
minRows={4}
|
||||
multiline
|
||||
value={remark}
|
||||
onChange={(event) => setRemark(event.target.value)}
|
||||
/>
|
||||
{needsRejectRemark && !remark.trim() ? <span className="finance-audit-hint">拒绝时必须填写原因</span> : null}
|
||||
{needsImage ? (
|
||||
<UploadField
|
||||
disabled={loading}
|
||||
hideType
|
||||
kind="image"
|
||||
label="打款凭证图片"
|
||||
uploadKind="image"
|
||||
value={auditImageUrl}
|
||||
onChange={setAuditImageUrl}
|
||||
/>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button disabled={loading} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={submitDisabled} variant="contained" onClick={submit}>
|
||||
确认
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
670
finance/src/components/FinanceCoinSellerRechargeOrderList.jsx
Normal file
670
finance/src/components/FinanceCoinSellerRechargeOrderList.jsx
Normal file
@ -0,0 +1,670 @@
|
||||
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||
import FactCheckOutlined from "@mui/icons-material/FactCheckOutlined";
|
||||
import PaidOutlined from "@mui/icons-material/PaidOutlined";
|
||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import Button from "@mui/material/Button";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import InputAdornment from "@mui/material/InputAdornment";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
COIN_SELLER_RECHARGE_CHAINS,
|
||||
COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES,
|
||||
COIN_SELLER_RECHARGE_PROVIDERS,
|
||||
COIN_SELLER_RECHARGE_STATUS,
|
||||
DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM,
|
||||
} from "../constants.js";
|
||||
import {
|
||||
buildCoinSellerRechargeReceiptPayload,
|
||||
buildCoinSellerRechargeOrderPayload,
|
||||
coinSellerRechargeChainLabel,
|
||||
coinSellerRechargeGrantStatusLabel,
|
||||
coinSellerRechargeProviderLabel,
|
||||
coinSellerRechargeStatusLabel,
|
||||
coinSellerRechargeStatusTone,
|
||||
coinSellerRechargeVerifyStatusLabel,
|
||||
formatAmount,
|
||||
formatTime,
|
||||
formatUsdMinor,
|
||||
isCoinSellerRechargeGranted,
|
||||
isCoinSellerRechargeVerified,
|
||||
validateCoinSellerRechargeOrderPayload,
|
||||
} from "../format.js";
|
||||
|
||||
export function FinanceCoinSellerRechargeOrderList({
|
||||
actionLoading,
|
||||
apps,
|
||||
canCreate,
|
||||
canGrant,
|
||||
canVerify,
|
||||
error,
|
||||
filters,
|
||||
loading,
|
||||
onCreate,
|
||||
onFiltersChange,
|
||||
onGrant,
|
||||
onReload,
|
||||
onVerify,
|
||||
onVerifyReceipt,
|
||||
orders,
|
||||
}) {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form, setForm] = useState(DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM);
|
||||
const [formError, setFormError] = useState("");
|
||||
const [receiptVerification, setReceiptVerification] = useState(null);
|
||||
const [receiptLoading, setReceiptLoading] = useState(false);
|
||||
const items = orders.items || [];
|
||||
const page = Number(orders.page || filters.page || 1);
|
||||
const pageSize = Number(orders.pageSize || 50);
|
||||
const total = Number(orders.total || 0);
|
||||
const hasNextPage = page * pageSize < total;
|
||||
|
||||
const openCreate = () => {
|
||||
setForm({
|
||||
...DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM,
|
||||
appCode: filters.appCode || apps[0]?.appCode || "",
|
||||
});
|
||||
setFormError("");
|
||||
setReceiptVerification(null);
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const updateForm = (key, value) => {
|
||||
if (key === "providerCode") {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
chain: "",
|
||||
providerAmount: "",
|
||||
providerCode: value,
|
||||
providerCurrencyCode: "",
|
||||
}));
|
||||
setFormError("");
|
||||
setReceiptVerification(null);
|
||||
return;
|
||||
}
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
setFormError("");
|
||||
// 订单号校验结果绑定当前 APP、用户输入金额、渠道、链和订单号;任一字段变化都不能复用旧凭证。
|
||||
setReceiptVerification(null);
|
||||
};
|
||||
|
||||
const submitCreate = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!receiptVerification?.verified) {
|
||||
setFormError("请先校验订单号");
|
||||
return;
|
||||
}
|
||||
const payload = buildCoinSellerRechargeOrderPayload(form);
|
||||
const validationMessage = validateCoinSellerRechargeOrderPayload(payload);
|
||||
if (validationMessage) {
|
||||
setFormError(validationMessage);
|
||||
return;
|
||||
}
|
||||
const created = await onCreate(payload);
|
||||
if (created) {
|
||||
setCreateOpen(false);
|
||||
setReceiptVerification(null);
|
||||
}
|
||||
};
|
||||
|
||||
const verifyCreateReceipt = async () => {
|
||||
const payload = buildCoinSellerRechargeReceiptPayload(form);
|
||||
const validationMessage = validateCoinSellerRechargeOrderPayload({
|
||||
...buildCoinSellerRechargeOrderPayload(form),
|
||||
coinAmount: 1,
|
||||
targetUserId: "verification-only",
|
||||
});
|
||||
if (validationMessage) {
|
||||
setFormError(validationMessage);
|
||||
return;
|
||||
}
|
||||
setFormError("");
|
||||
setReceiptLoading(true);
|
||||
try {
|
||||
const verification = await onVerifyReceipt(payload);
|
||||
setReceiptVerification(verification);
|
||||
if (!verification?.verified) {
|
||||
setFormError(verification?.failureReason || "订单号校验未通过");
|
||||
}
|
||||
} finally {
|
||||
setReceiptLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmGrant = (order) => {
|
||||
const userText = order.targetDisplayUserId || order.targetUserId || "-";
|
||||
const ok = window.confirm(
|
||||
isZeroCoinMakeupOrder(order)
|
||||
? `确认完成用户 ${userText} 的补单?该订单计入充值,不发放金币。`
|
||||
: `确认给用户 ${userText} 发放 ${formatAmount(order.coinAmount)} 金币?`,
|
||||
);
|
||||
if (ok) {
|
||||
onGrant(order);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="finance-card finance-list">
|
||||
<div className="finance-filterrow">
|
||||
<TextField
|
||||
className="finance-filterrow__region"
|
||||
label="APP"
|
||||
select
|
||||
size="small"
|
||||
value={filters.appCode}
|
||||
onChange={(event) => onFiltersChange({ appCode: event.target.value })}
|
||||
>
|
||||
<MenuItem value="">全部 APP</MenuItem>
|
||||
{apps.map((app) => (
|
||||
<MenuItem key={app.appCode} value={app.appCode}>
|
||||
{app.appName || app.appCode}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="渠道"
|
||||
select
|
||||
size="small"
|
||||
value={filters.providerCode}
|
||||
onChange={(event) => onFiltersChange({ providerCode: event.target.value })}
|
||||
>
|
||||
<MenuItem value="">全部渠道</MenuItem>
|
||||
{COIN_SELLER_RECHARGE_PROVIDERS.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="订单状态"
|
||||
select
|
||||
size="small"
|
||||
value={filters.status}
|
||||
onChange={(event) => onFiltersChange({ status: event.target.value })}
|
||||
>
|
||||
<MenuItem value="">全部状态</MenuItem>
|
||||
{COIN_SELLER_RECHARGE_STATUS.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
label="搜索"
|
||||
size="small"
|
||||
value={filters.keyword}
|
||||
onChange={(event) => onFiltersChange({ keyword: event.target.value })}
|
||||
/>
|
||||
<span className="finance-filterrow__spacer" />
|
||||
<Button startIcon={<RefreshOutlined fontSize="small" />} variant="outlined" onClick={onReload}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!canCreate}
|
||||
startIcon={<AddOutlined fontSize="small" />}
|
||||
variant="contained"
|
||||
onClick={openCreate}
|
||||
>
|
||||
创建币商充值
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error ? <div className="finance-error">{error}</div> : null}
|
||||
{loading && !items.length ? <div className="finance-skeleton" /> : null}
|
||||
{!loading || items.length ? (
|
||||
<div className="finance-tablewrap">
|
||||
<table className="finance-flat-table finance-flat-table--orders">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>订单</th>
|
||||
<th>APP</th>
|
||||
<th>目标用户</th>
|
||||
<th className="r">金额</th>
|
||||
<th>渠道</th>
|
||||
<th>三方订单</th>
|
||||
<th>备注</th>
|
||||
<th>状态</th>
|
||||
<th>钱包</th>
|
||||
<th>操作人</th>
|
||||
<th>时间</th>
|
||||
<th className="r">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length ? (
|
||||
items.map((item) => (
|
||||
<tr className="finance-flat-table__row" key={item.id}>
|
||||
<td className="finance-order-cell">{item.id || "-"}</td>
|
||||
<td>{appName(item.appCode, apps)}</td>
|
||||
<td>
|
||||
<Stacked
|
||||
primary={item.targetDisplayUserId || item.targetUserId}
|
||||
secondary={item.targetUserId}
|
||||
/>
|
||||
</td>
|
||||
<td className="r num">
|
||||
<Stacked
|
||||
primary={usdText(item)}
|
||||
secondary={`${formatAmount(item.coinAmount)} 金币${isZeroCoinMakeupOrder(item) ? "(补单)" : ""}`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Stacked
|
||||
primary={coinSellerRechargeProviderLabel(item.providerCode)}
|
||||
secondary={providerMeta(item)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Stacked
|
||||
primary={item.externalOrderNo || "-"}
|
||||
secondary={providerAmountText(item)}
|
||||
/>
|
||||
</td>
|
||||
<td className="finance-remark-cell">{item.remark || "-"}</td>
|
||||
<td>
|
||||
<Stacked
|
||||
primary={<StatusBadge status={item.status} />}
|
||||
secondary={`${coinSellerRechargeVerifyStatusLabel(item.verifyStatus)} / ${coinSellerRechargeGrantStatusLabel(item.grantStatus)}`}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Stacked
|
||||
primary={item.walletTransactionId || item.walletCommandId || "-"}
|
||||
secondary={walletMeta(item)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Stacked
|
||||
primary={item.operatorName || "-"}
|
||||
secondary={[
|
||||
item.verifiedByName && `校验:${item.verifiedByName}`,
|
||||
item.grantedByName &&
|
||||
`${isZeroCoinMakeupOrder(item) ? "补单" : "发放"}:${item.grantedByName}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" / ")}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Stacked
|
||||
primary={formatTime(item.createdAtMs)}
|
||||
secondary={[
|
||||
item.verifiedAtMs && `校验 ${formatTime(item.verifiedAtMs)}`,
|
||||
item.grantedAtMs &&
|
||||
`${isZeroCoinMakeupOrder(item) ? "补单" : "发放"} ${formatTime(item.grantedAtMs)}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" / ")}
|
||||
/>
|
||||
</td>
|
||||
<td className="r">
|
||||
<span className="finance-row-actions">
|
||||
<Button
|
||||
disabled={
|
||||
!canVerify ||
|
||||
actionLoading === `verify:${item.id}` ||
|
||||
isCoinSellerRechargeVerified(item) ||
|
||||
isCoinSellerRechargeGranted(item)
|
||||
}
|
||||
size="small"
|
||||
startIcon={<FactCheckOutlined fontSize="small" />}
|
||||
variant="outlined"
|
||||
onClick={() => onVerify(item)}
|
||||
>
|
||||
校验
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
!canGrant ||
|
||||
actionLoading === `grant:${item.id}` ||
|
||||
!isCoinSellerRechargeVerified(item) ||
|
||||
isCoinSellerRechargeGranted(item)
|
||||
}
|
||||
size="small"
|
||||
startIcon={<PaidOutlined fontSize="small" />}
|
||||
variant="contained"
|
||||
onClick={() => confirmGrant(item)}
|
||||
>
|
||||
{isZeroCoinMakeupOrder(item) ? "完成补单" : "发放"}
|
||||
</Button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={12} style={{ textAlign: "center" }}>
|
||||
当前无数据
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
{total > pageSize ? (
|
||||
<div className="finance-pagination">
|
||||
<Button
|
||||
disabled={page <= 1 || loading}
|
||||
variant="outlined"
|
||||
onClick={() => onFiltersChange({ page: page - 1 })}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span>
|
||||
第 {page} 页 / 共 {total} 条
|
||||
</span>
|
||||
<Button
|
||||
disabled={!hasNextPage || loading}
|
||||
variant="outlined"
|
||||
onClick={() => onFiltersChange({ page: page + 1 })}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<CreateDialog
|
||||
apps={apps}
|
||||
form={form}
|
||||
formError={formError}
|
||||
loading={actionLoading === "create"}
|
||||
open={createOpen}
|
||||
receiptLoading={receiptLoading}
|
||||
receiptVerification={receiptVerification}
|
||||
onChange={updateForm}
|
||||
onClose={() => {
|
||||
setCreateOpen(false);
|
||||
setReceiptVerification(null);
|
||||
setFormError("");
|
||||
}}
|
||||
onSubmit={submitCreate}
|
||||
onVerifyReceipt={verifyCreateReceipt}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateDialog({
|
||||
apps,
|
||||
form,
|
||||
formError,
|
||||
loading,
|
||||
onChange,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onVerifyReceipt,
|
||||
open,
|
||||
receiptLoading,
|
||||
receiptVerification,
|
||||
}) {
|
||||
const disabled = loading || receiptLoading;
|
||||
const canCreate = receiptVerification?.verified === true && !disabled;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
fullWidth
|
||||
maxWidth={false}
|
||||
open={open}
|
||||
slotProps={{ paper: { className: "finance-create-dialog-paper finance-create-dialog-paper--coin-seller" } }}
|
||||
onClose={disabled ? undefined : onClose}
|
||||
>
|
||||
<DialogTitle>创建币商充值</DialogTitle>
|
||||
<DialogContent className="finance-create-dialog">
|
||||
<form
|
||||
className="finance-form finance-form--dialog"
|
||||
id="coin-seller-recharge-order-form"
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{formError ? <Alert severity="error">{formError}</Alert> : null}
|
||||
<div
|
||||
className={
|
||||
form.providerCode === "usdt"
|
||||
? "finance-form-grid finance-form-grid--coin-seller-create"
|
||||
: "finance-form-grid finance-form-grid--coin-seller-create finance-form-grid--coin-seller-no-chain"
|
||||
}
|
||||
>
|
||||
{apps.length ? (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="APP"
|
||||
required
|
||||
select
|
||||
value={form.appCode}
|
||||
onChange={(event) => onChange("appCode", event.target.value)}
|
||||
>
|
||||
{apps.map((app) => (
|
||||
<MenuItem key={app.appCode} value={app.appCode}>
|
||||
{app.appName || app.appCode}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
) : (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="APP"
|
||||
required
|
||||
value={form.appCode}
|
||||
onChange={(event) => onChange("appCode", event.target.value)}
|
||||
/>
|
||||
)}
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="目标用户 ID"
|
||||
required
|
||||
value={form.targetUserId}
|
||||
onChange={(event) => onChange("targetUserId", event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start">USD</InputAdornment> }}
|
||||
inputProps={{ min: 0, step: "0.01" }}
|
||||
label="USD 金额"
|
||||
required
|
||||
type="number"
|
||||
value={form.usdAmount}
|
||||
onChange={(event) => onChange("usdAmount", event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
InputProps={{ endAdornment: <InputAdornment position="end">金币</InputAdornment> }}
|
||||
inputProps={{ min: 0, step: "1" }}
|
||||
label="充值金币"
|
||||
required
|
||||
type="number"
|
||||
value={form.coinAmount}
|
||||
onChange={(event) => onChange("coinAmount", event.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="支付渠道"
|
||||
required
|
||||
select
|
||||
value={form.providerCode}
|
||||
onChange={(event) => onChange("providerCode", event.target.value)}
|
||||
>
|
||||
{COIN_SELLER_RECHARGE_PROVIDERS.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
{form.providerCode === "usdt" ? (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="USDT 链"
|
||||
required
|
||||
select
|
||||
value={form.chain}
|
||||
onChange={(event) => onChange("chain", event.target.value)}
|
||||
>
|
||||
{COIN_SELLER_RECHARGE_CHAINS.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
) : null}
|
||||
{form.providerCode !== "usdt" ? (
|
||||
<>
|
||||
<Autocomplete
|
||||
disabled={disabled}
|
||||
getOptionLabel={currencyOptionLabel}
|
||||
isOptionEqualToValue={(option, value) => option === value}
|
||||
noOptionsText="无匹配币种"
|
||||
options={providerCurrencyOptions(form.providerCode)}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} label="币种" required />
|
||||
)}
|
||||
value={form.providerCurrencyCode || null}
|
||||
onChange={(_, value) => onChange("providerCurrencyCode", value || "")}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ min: 0, step: "0.01" }}
|
||||
label="币种金额"
|
||||
required
|
||||
type="number"
|
||||
value={form.providerAmount}
|
||||
onChange={(event) => onChange("providerAmount", event.target.value)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<div className="finance-receipt-field">
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="三方订单号"
|
||||
required
|
||||
value={form.externalOrderNo}
|
||||
onChange={(event) => onChange("externalOrderNo", event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
disabled={disabled || !onVerifyReceipt}
|
||||
startIcon={<FactCheckOutlined fontSize="small" />}
|
||||
variant="outlined"
|
||||
onClick={onVerifyReceipt}
|
||||
>
|
||||
校验
|
||||
</Button>
|
||||
</div>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ maxLength: 512 }}
|
||||
label="备注"
|
||||
value={form.remark}
|
||||
onChange={(event) => onChange("remark", event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{receiptVerification ? <ReceiptVerificationBanner verification={receiptVerification} /> : null}
|
||||
</form>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button disabled={disabled} variant="outlined" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={!canCreate} form="coin-seller-recharge-order-form" type="submit" variant="contained">
|
||||
创建
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function isZeroCoinMakeupOrder(order) {
|
||||
return Number(order?.coinAmount || 0) === 0;
|
||||
}
|
||||
|
||||
function providerCurrencyOptions(providerCode) {
|
||||
return COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES[providerCode] || [];
|
||||
}
|
||||
|
||||
function currencyOptionLabel(option) {
|
||||
return option || "";
|
||||
}
|
||||
|
||||
function ReceiptVerificationBanner({ verification }) {
|
||||
const detail = [
|
||||
verification.status && `状态 ${verification.status}`,
|
||||
verification.providerOrderId && `三方单 ${verification.providerOrderId}`,
|
||||
verification.currencyCode && verification.providerAmountMinor !== undefined
|
||||
? formatUsdMinor(verification.providerAmountMinor, verification.currencyCode)
|
||||
: "",
|
||||
verification.failureReason,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
return (
|
||||
<Alert className="finance-receipt-result" severity={verification.verified ? "success" : "error"}>
|
||||
{verification.verified ? "校验通过" : "校验未通过"}
|
||||
{detail ? `:${detail}` : ""}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
return (
|
||||
<span className={`finance-status finance-status--${coinSellerRechargeStatusTone(status)}`}>
|
||||
{coinSellerRechargeStatusLabel(status)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Stacked({ primary, secondary }) {
|
||||
return (
|
||||
<span className="finance-stack">
|
||||
<span>{primary || "-"}</span>
|
||||
<small>{secondary || ""}</small>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function appName(appCode, apps) {
|
||||
return apps.find((app) => app.appCode === appCode)?.appName || appCode || "-";
|
||||
}
|
||||
|
||||
function usdText(item) {
|
||||
if (item.usdAmount !== undefined && item.usdAmount !== null && item.usdAmount !== "") {
|
||||
return `USD ${item.usdAmount}`;
|
||||
}
|
||||
return formatUsdMinor(item.usdMinorAmount, "USD");
|
||||
}
|
||||
|
||||
function providerAmountText(item) {
|
||||
return item.providerAmountMinor === null || item.providerAmountMinor === undefined
|
||||
? "-"
|
||||
: formatUsdMinor(item.providerAmountMinor, item.providerCurrencyCode || "USD");
|
||||
}
|
||||
|
||||
function providerMeta(item) {
|
||||
if (item.providerCode === "usdt") {
|
||||
return [coinSellerRechargeChainLabel(item.chain), item.providerStatus].filter(Boolean).join(" / ") || "-";
|
||||
}
|
||||
if (item.providerCode === "mifapay") {
|
||||
return [item.providerStatus, item.providerOrderId].filter(Boolean).join(" / ") || "-";
|
||||
}
|
||||
if (item.providerCode === "v5pay") {
|
||||
return (
|
||||
[item.providerStatus, item.providerCurrencyCode, item.providerOrderId].filter(Boolean).join(" / ") || "-"
|
||||
);
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
function walletMeta(item) {
|
||||
return [
|
||||
item.walletAssetType,
|
||||
item.walletAmountDelta === null || item.walletAmountDelta === undefined
|
||||
? ""
|
||||
: `变动 ${formatAmount(item.walletAmountDelta)}`,
|
||||
item.walletBalanceAfter === null || item.walletBalanceAfter === undefined
|
||||
? ""
|
||||
: `余额 ${formatAmount(item.walletBalanceAfter)}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
}
|
||||
@ -0,0 +1,184 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { FinanceCoinSellerRechargeOrderList } from "./FinanceCoinSellerRechargeOrderList.jsx";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("coin seller recharge order create dialog verifies receipt before create", async () => {
|
||||
const onCreate = vi.fn(async () => ({ id: "order-2" }));
|
||||
const onVerifyReceipt = vi.fn(async () => ({
|
||||
currencyCode: "PHP",
|
||||
providerAmountMinor: 4800,
|
||||
providerOrderId: "V5-001",
|
||||
status: "paid",
|
||||
verified: true,
|
||||
}));
|
||||
render(
|
||||
<FinanceCoinSellerRechargeOrderList {...baseProps()} onCreate={onCreate} onVerifyReceipt={onVerifyReceipt} />,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "创建币商充值" }));
|
||||
fireEvent.mouseDown(screen.getByRole("combobox", { name: "支付渠道" }));
|
||||
fireEvent.click(screen.getByRole("option", { name: "V5Pay" }));
|
||||
|
||||
expect(screen.getByRole("combobox", { name: "币种" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("spinbutton", { name: "币种金额" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("textbox", { name: "支付类型" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("textbox", { name: "订单日期" })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "目标用户 ID" }), { target: { value: "10001" } });
|
||||
fireEvent.change(screen.getByRole("spinbutton", { name: "USD 金额" }), { target: { value: "10" } });
|
||||
fireEvent.change(screen.getByRole("spinbutton", { name: "充值金币" }), { target: { value: "0" } });
|
||||
fireEvent.mouseDown(screen.getByRole("combobox", { name: "币种" }));
|
||||
fireEvent.click(await screen.findByRole("option", { name: "PHP" }));
|
||||
fireEvent.change(screen.getByRole("spinbutton", { name: "币种金额" }), { target: { value: "48" } });
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "三方订单号" }), { target: { value: "V5-001" } });
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "备注" }), { target: { value: "补单,不发金币" } });
|
||||
expect(screen.getByRole("button", { name: "创建" })).toBeDisabled();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "校验" }));
|
||||
await screen.findByText(/校验通过/);
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "创建" })).not.toBeDisabled());
|
||||
expect(onVerifyReceipt).toHaveBeenCalledWith({
|
||||
appCode: "lalu",
|
||||
externalOrderNo: "V5-001",
|
||||
providerAmountMinor: 4800,
|
||||
providerCode: "v5pay",
|
||||
providerCurrencyCode: "PHP",
|
||||
usdAmount: 10,
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "创建" }));
|
||||
await waitFor(() =>
|
||||
expect(onCreate).toHaveBeenCalledWith({
|
||||
appCode: "lalu",
|
||||
coinAmount: 0,
|
||||
externalOrderNo: "V5-001",
|
||||
providerAmountMinor: 4800,
|
||||
providerCode: "v5pay",
|
||||
providerCurrencyCode: "PHP",
|
||||
remark: "补单,不发金币",
|
||||
targetUserId: "10001",
|
||||
usdAmount: 10,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("coin seller recharge order actions follow verify and grant status", () => {
|
||||
const onVerify = vi.fn();
|
||||
const onGrant = vi.fn();
|
||||
vi.stubGlobal(
|
||||
"confirm",
|
||||
vi.fn(() => true),
|
||||
);
|
||||
|
||||
render(
|
||||
<FinanceCoinSellerRechargeOrderList
|
||||
{...baseProps({
|
||||
orders: {
|
||||
items: [
|
||||
orderFixture({ id: "pending", verifyStatus: "pending", grantStatus: "pending" }),
|
||||
orderFixture({ id: "verified", verifyStatus: "verified", grantStatus: "pending" }),
|
||||
],
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 2,
|
||||
},
|
||||
})}
|
||||
onGrant={onGrant}
|
||||
onVerify={onVerify}
|
||||
/>,
|
||||
);
|
||||
|
||||
const verifyButtons = screen.getAllByRole("button", { name: "校验" });
|
||||
const grantButtons = screen.getAllByRole("button", { name: "发放" });
|
||||
fireEvent.click(verifyButtons[0]);
|
||||
fireEvent.click(grantButtons[1]);
|
||||
|
||||
expect(onVerify).toHaveBeenCalledWith(expect.objectContaining({ id: "pending" }));
|
||||
expect(onGrant).toHaveBeenCalledWith(expect.objectContaining({ id: "verified" }));
|
||||
expect(screen.getAllByText("人工补单")[0]).toBeInTheDocument();
|
||||
expect(grantButtons[0]).toBeDisabled();
|
||||
expect(verifyButtons[1]).toBeDisabled();
|
||||
});
|
||||
|
||||
test("zero coin seller recharge order uses makeup completion action", () => {
|
||||
const onGrant = vi.fn();
|
||||
const confirm = vi.fn(() => true);
|
||||
vi.stubGlobal("confirm", confirm);
|
||||
|
||||
render(
|
||||
<FinanceCoinSellerRechargeOrderList
|
||||
{...baseProps({
|
||||
orders: {
|
||||
items: [
|
||||
orderFixture({ coinAmount: 0, grantStatus: "pending", id: "makeup", verifyStatus: "verified" }),
|
||||
],
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 1,
|
||||
},
|
||||
})}
|
||||
onGrant={onGrant}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "完成补单" }));
|
||||
|
||||
expect(confirm).toHaveBeenCalledWith(expect.stringContaining("不发放金币"));
|
||||
expect(onGrant).toHaveBeenCalledWith(expect.objectContaining({ id: "makeup", coinAmount: 0 }));
|
||||
});
|
||||
|
||||
function baseProps(overrides = {}) {
|
||||
return {
|
||||
actionLoading: "",
|
||||
apps: [{ appCode: "lalu", appName: "lalu" }],
|
||||
canCreate: true,
|
||||
canGrant: true,
|
||||
canVerify: true,
|
||||
error: "",
|
||||
filters: {
|
||||
appCode: "",
|
||||
grantStatus: "",
|
||||
keyword: "",
|
||||
page: 1,
|
||||
providerCode: "",
|
||||
status: "",
|
||||
verifyStatus: "",
|
||||
},
|
||||
loading: false,
|
||||
onCreate: vi.fn(),
|
||||
onFiltersChange: vi.fn(),
|
||||
onGrant: vi.fn(),
|
||||
onReload: vi.fn(),
|
||||
onVerify: vi.fn(),
|
||||
onVerifyReceipt: vi.fn(),
|
||||
orders: {
|
||||
items: [orderFixture()],
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 1,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function orderFixture(overrides = {}) {
|
||||
return {
|
||||
appCode: "lalu",
|
||||
coinAmount: 100000,
|
||||
createdAtMs: 1760000000000,
|
||||
externalOrderNo: "MIFA-001",
|
||||
grantStatus: "pending",
|
||||
id: "order-1",
|
||||
providerCode: "mifapay",
|
||||
remark: "人工补单",
|
||||
status: "pending",
|
||||
targetDisplayUserId: "165075",
|
||||
targetUserId: "10001",
|
||||
usdAmount: "10.00",
|
||||
verifyStatus: "pending",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
156
finance/src/components/FinanceMyApplicationList.jsx
Normal file
156
finance/src/components/FinanceMyApplicationList.jsx
Normal file
@ -0,0 +1,156 @@
|
||||
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||
import Button from "@mui/material/Button";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
import TableContainer from "@mui/material/TableContainer";
|
||||
import TableHead from "@mui/material/TableHead";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { APPLICATION_STATUS } from "../constants.js";
|
||||
import { formatAmount, formatTime, operationLabel, statusLabel, walletIdentityLabel } from "../format.js";
|
||||
|
||||
export function FinanceMyApplicationList({ applications, error, filters, loading, onCreate, onFiltersChange, onReload, operations, options }) {
|
||||
const items = applications.items || [];
|
||||
const page = Number(applications.page || filters.page || 1);
|
||||
const pageSize = Number(applications.pageSize || 50);
|
||||
const total = Number(applications.total || 0);
|
||||
const hasNextPage = page * pageSize < total;
|
||||
|
||||
return (
|
||||
<section className="finance-panel finance-list">
|
||||
<div className="finance-toolbar finance-toolbar--with-action">
|
||||
<TextField label="APP" select value={filters.appCode} onChange={(event) => onFiltersChange({ appCode: event.target.value })}>
|
||||
<MenuItem value="">全部 APP</MenuItem>
|
||||
{options.apps.map((app) => (
|
||||
<MenuItem key={app.appCode} value={app.appCode}>
|
||||
{app.appName || app.appCode}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField label="操作内容" select value={filters.operation} onChange={(event) => onFiltersChange({ operation: event.target.value })}>
|
||||
<MenuItem value="">全部操作</MenuItem>
|
||||
{operations.map((operation) => (
|
||||
<MenuItem key={operation.value} value={operation.value}>
|
||||
{operation.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField label="状态" select value={filters.status} onChange={(event) => onFiltersChange({ status: event.target.value })}>
|
||||
<MenuItem value="">全部状态</MenuItem>
|
||||
{APPLICATION_STATUS.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField label="搜索" value={filters.keyword} onChange={(event) => onFiltersChange({ keyword: event.target.value })} />
|
||||
<Button startIcon={<RefreshOutlined fontSize="small" />} variant="outlined" onClick={onReload}>
|
||||
刷新列表
|
||||
</Button>
|
||||
<Button startIcon={<AddOutlined fontSize="small" />} variant="contained" onClick={onCreate}>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
{error ? <div className="finance-error">{error}</div> : null}
|
||||
{loading && !items.length ? <div className="finance-skeleton" /> : null}
|
||||
{!loading || items.length ? (
|
||||
<TableContainer className="finance-table-wrap">
|
||||
<Table className="finance-table" size="small" stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>APP</TableCell>
|
||||
<TableCell>操作</TableCell>
|
||||
<TableCell>目标用户</TableCell>
|
||||
<TableCell align="right">金币数量</TableCell>
|
||||
<TableCell align="right">充值金额</TableCell>
|
||||
<TableCell>凭证</TableCell>
|
||||
<TableCell>状态</TableCell>
|
||||
<TableCell className="finance-time-cell">发起申请时间</TableCell>
|
||||
<TableCell>审批人</TableCell>
|
||||
<TableCell className="finance-time-cell">审核时间</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{items.length ? (
|
||||
items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{item.appName || item.appCode || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Stacked primary={operationLabel(item.operation, operations)} secondary={walletIdentityLabel(item.walletIdentity)} />
|
||||
</TableCell>
|
||||
<TableCell>{item.targetUserId || "-"}</TableCell>
|
||||
<TableCell align="right">{formatAmount(item.coinAmount)}</TableCell>
|
||||
<TableCell align="right">{formatAmount(item.rechargeAmount)}</TableCell>
|
||||
<TableCell>
|
||||
<Evidence item={item} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={item.status} />
|
||||
</TableCell>
|
||||
<TableCell className="finance-time-cell">{formatTime(item.createdAtMs)}</TableCell>
|
||||
<TableCell>
|
||||
<Stacked primary={item.auditorName || "-"} secondary={item.auditorUserId || ""} />
|
||||
</TableCell>
|
||||
<TableCell className="finance-time-cell">{formatTime(item.auditedAtMs)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell align="center" colSpan={10}>
|
||||
当前无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : null}
|
||||
{total > pageSize ? (
|
||||
<div className="finance-pagination">
|
||||
<Button disabled={page <= 1 || loading} variant="outlined" onClick={() => onFiltersChange({ page: page - 1 })}>
|
||||
上一页
|
||||
</Button>
|
||||
<span>
|
||||
第 {page} 页 / 共 {total} 条
|
||||
</span>
|
||||
<Button disabled={!hasNextPage || loading} variant="outlined" onClick={() => onFiltersChange({ page: page + 1 })}>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Evidence({ item }) {
|
||||
if (!item.credentialImageUrl && !item.credentialText) {
|
||||
return "-";
|
||||
}
|
||||
return (
|
||||
<span className="finance-evidence">
|
||||
{item.credentialImageUrl ? (
|
||||
<a href={item.credentialImageUrl} rel="noreferrer" target="_blank">
|
||||
图片
|
||||
</a>
|
||||
) : null}
|
||||
{item.credentialText ? <span title={item.credentialText}>{item.credentialText}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Stacked({ primary, secondary }) {
|
||||
return (
|
||||
<span className="finance-stack">
|
||||
<span>{primary || "-"}</span>
|
||||
<small>{secondary || ""}</small>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
const tone = status === "approved" ? "success" : status === "rejected" ? "danger" : "warning";
|
||||
return <span className={`finance-status finance-status--${tone}`}>{statusLabel(status)}</span>;
|
||||
}
|
||||
81
finance/src/components/FinanceMyApplicationList.test.jsx
Normal file
81
finance/src/components/FinanceMyApplicationList.test.jsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { FinanceMyApplicationList } from "./FinanceMyApplicationList.jsx";
|
||||
|
||||
const baseProps = {
|
||||
applications: {
|
||||
items: [],
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 0
|
||||
},
|
||||
error: "",
|
||||
filters: {
|
||||
appCode: "",
|
||||
keyword: "",
|
||||
operation: "",
|
||||
page: 1,
|
||||
status: ""
|
||||
},
|
||||
loading: false,
|
||||
onCreate: vi.fn(),
|
||||
onFiltersChange: vi.fn(),
|
||||
onReload: vi.fn(),
|
||||
operations: [{ label: "增加用户金币", value: "user_coin_credit" }],
|
||||
options: { apps: [] }
|
||||
};
|
||||
|
||||
test("renders my finance application list and create action", () => {
|
||||
const onCreate = vi.fn();
|
||||
render(
|
||||
<FinanceMyApplicationList
|
||||
{...baseProps}
|
||||
applications={{
|
||||
...baseProps.applications,
|
||||
items: [
|
||||
{
|
||||
appCode: "lalu",
|
||||
auditedAtMs: 1760007200000,
|
||||
auditorName: "财务一",
|
||||
auditorUserId: 21,
|
||||
coinAmount: 100,
|
||||
createdAtMs: 1760000000000,
|
||||
credentialText: "receipt",
|
||||
id: 7,
|
||||
operation: "user_coin_credit",
|
||||
rechargeAmount: 12.5,
|
||||
status: "approved",
|
||||
targetUserId: "10001"
|
||||
}
|
||||
],
|
||||
total: 1
|
||||
}}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("columnheader", { name: "APP" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "操作" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "目标用户" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "金币数量" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "充值金额" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "凭证" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "状态" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "发起申请时间" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "审批人" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "审核时间" })).toBeInTheDocument();
|
||||
expect(screen.getByText("lalu")).toBeInTheDocument();
|
||||
expect(screen.getByText("增加用户金币")).toBeInTheDocument();
|
||||
expect(screen.getByText("10001")).toBeInTheDocument();
|
||||
expect(screen.getByText("已通过")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "添加" }));
|
||||
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("keeps empty my finance application table colspan aligned with visible columns", () => {
|
||||
const { container } = render(<FinanceMyApplicationList {...baseProps} />);
|
||||
|
||||
expect(container.querySelector("tbody td")?.colSpan).toBe(10);
|
||||
expect(screen.getByText("当前无数据")).toBeInTheDocument();
|
||||
});
|
||||
432
finance/src/components/FinanceOverview.jsx
Normal file
432
finance/src/components/FinanceOverview.jsx
Normal file
@ -0,0 +1,432 @@
|
||||
import { EMPTY_RECHARGE_OVERVIEW, EMPTY_RECHARGE_SUMMARY, formatAmount, formatUsdMinor } from "../format.js";
|
||||
|
||||
const KPI_CARDS = [
|
||||
{ filter: "", key: "total", label: "充值总和", tone: "total" },
|
||||
{ filter: "google_play_recharge", key: "googlePlay", label: "谷歌充值", tone: "google" },
|
||||
{ filter: "third_party", key: "thirdParty", label: "三方充值", tone: "thirdparty" },
|
||||
{ filter: "coin_seller", key: "coinSeller", label: "币商充值", tone: "coinseller" },
|
||||
];
|
||||
|
||||
const DAILY_FIELDS = {
|
||||
coinSeller: { coin: "coinSellerCoinAmount", usd: "coinSellerUsdMinor" },
|
||||
googlePlay: { coin: "googleCoinAmount", usd: "googleUsdMinor" },
|
||||
thirdParty: { coin: "thirdPartyCoinAmount", usd: "thirdPartyUsdMinor" },
|
||||
};
|
||||
|
||||
export function FinanceOverview({
|
||||
currency,
|
||||
filters,
|
||||
loading,
|
||||
onFiltersChange,
|
||||
onGoUnsynced,
|
||||
onOpenApp,
|
||||
onOpenWithdrawals,
|
||||
overview,
|
||||
perAppSummaries,
|
||||
prevSummary,
|
||||
regions,
|
||||
summary,
|
||||
}) {
|
||||
const data = summary || EMPTY_RECHARGE_SUMMARY;
|
||||
const trend = overview || EMPTY_RECHARGE_OVERVIEW;
|
||||
const showCoins = currency === "coin";
|
||||
const totalUsd = Number(data.total?.usdMinorAmount) || 0;
|
||||
const unsynced = trend.googlePaid.unsyncedCount;
|
||||
const selectedApp = filters.appCode;
|
||||
|
||||
return (
|
||||
<div className="finance-overview">
|
||||
{unsynced > 0 ? (
|
||||
<div className="finance-alerts">
|
||||
<span className="finance-alert finance-alert--warn">
|
||||
⚠ {formatAmount(unsynced)} 笔谷歌订单实付未同步,手续费口径尚不完整
|
||||
<button type="button" onClick={onGoUnsynced}>
|
||||
去同步
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="finance-kpi-band">
|
||||
{KPI_CARDS.map(({ filter, key, label, tone }) => {
|
||||
const bucket = data[key] || { billCount: 0, coinAmount: 0, usdMinorAmount: 0 };
|
||||
const active = (filters.rechargeType || "") === filter;
|
||||
return (
|
||||
<button
|
||||
className={["finance-kpi", `finance-kpi--${tone}`, active ? "finance-kpi--active" : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
key={key}
|
||||
title={filter ? `点击只看${label}` : "查看全部来源"}
|
||||
type="button"
|
||||
onClick={() => onFiltersChange({ rechargeType: active && filter ? "" : filter })}
|
||||
>
|
||||
<span className="finance-kpi__label">{label}</span>
|
||||
<strong className="finance-kpi__value">
|
||||
{loading
|
||||
? "…"
|
||||
: showCoins
|
||||
? `${formatAmount(bucket.coinAmount)} 金币`
|
||||
: formatUsdMinor(bucket.usdMinorAmount, "USD")}
|
||||
</strong>
|
||||
<small className="finance-kpi__meta">
|
||||
{loading ? "统计中" : kpiMeta(bucket, key, totalUsd, prevSummary)}
|
||||
</small>
|
||||
<Sparkline daily={trend.daily} field={key} showCoins={showCoins} tone={tone} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<WithdrawalKpi
|
||||
loading={loading}
|
||||
totalUsd={totalUsd}
|
||||
withdrawal={trend.withdrawal}
|
||||
onOpenWithdrawals={onOpenWithdrawals}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="finance-overview__split">
|
||||
<section className="finance-card">
|
||||
<div className="finance-card__head">
|
||||
<span className="finance-card__title">
|
||||
充值趋势<small>按渠道堆叠 · 日 · {showCoins ? "金币" : "USD"}</small>
|
||||
</span>
|
||||
<div className="finance-card__tools finance-chartlegend">
|
||||
<span>
|
||||
<i style={{ background: "var(--fin-google)" }} />
|
||||
谷歌
|
||||
</span>
|
||||
<span>
|
||||
<i style={{ background: "var(--fin-third)" }} />
|
||||
三方
|
||||
</span>
|
||||
<span>
|
||||
<i style={{ background: "var(--fin-seller)" }} />
|
||||
币商
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="finance-card__body">
|
||||
<TrendChart daily={trend.daily} showCoins={showCoins} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="finance-card">
|
||||
<div className="finance-card__head">
|
||||
<span className="finance-card__title">
|
||||
净入账拆解<small>谷歌已同步口径</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="finance-card__body">
|
||||
<DeductionBreakdown paid={trend.googlePaid} />
|
||||
<div className="finance-card__divider" />
|
||||
<div className="finance-card__title" style={{ marginBottom: 8 }}>
|
||||
{selectedApp ? "区域分布 Top 5" : "区域分布"}
|
||||
</div>
|
||||
{selectedApp ? (
|
||||
<RegionRanks regions={regions} />
|
||||
) : (
|
||||
<p className="finance-hint">选择单个 App 后查看区域分布</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="finance-card">
|
||||
<div className="finance-card__head">
|
||||
<span className="finance-card__title">
|
||||
App 对比<small>点击行进入该 App 流水</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="finance-tablewrap">
|
||||
<table className="finance-flat-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>App</th>
|
||||
<th className="r">充值流水</th>
|
||||
<th className="r">笔数</th>
|
||||
<th className="r">谷歌</th>
|
||||
<th className="r">三方</th>
|
||||
<th className="r">币商</th>
|
||||
<th style={{ width: 180 }}>占比</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(perAppSummaries || []).map((app) => {
|
||||
const appTotal = Number(app.summary?.total?.usdMinorAmount) || 0;
|
||||
const maxTotal = Math.max(
|
||||
1,
|
||||
...(perAppSummaries || []).map(
|
||||
(item) => Number(item.summary?.total?.usdMinorAmount) || 0,
|
||||
),
|
||||
);
|
||||
return (
|
||||
<tr
|
||||
className="finance-flat-table__row"
|
||||
key={app.appCode}
|
||||
onClick={() => onOpenApp(app.appCode)}
|
||||
>
|
||||
<td>
|
||||
<b>{app.appName || app.appCode}</b>
|
||||
</td>
|
||||
<td className="r num">{formatUsdMinor(appTotal, "USD")}</td>
|
||||
<td className="r num">{formatAmount(app.summary?.total?.billCount || 0)}</td>
|
||||
<td className="r num">
|
||||
{formatUsdMinor(app.summary?.googlePlay?.usdMinorAmount || 0, "USD")}
|
||||
</td>
|
||||
<td className="r num">
|
||||
{formatUsdMinor(app.summary?.thirdParty?.usdMinorAmount || 0, "USD")}
|
||||
</td>
|
||||
<td className="r num">
|
||||
{formatUsdMinor(app.summary?.coinSeller?.usdMinorAmount || 0, "USD")}
|
||||
</td>
|
||||
<td>
|
||||
<span className="finance-rankbar">
|
||||
<i style={{ width: `${(appTotal / maxTotal) * 100}%` }} />
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{!perAppSummaries?.length ? (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ textAlign: "center" }}>
|
||||
暂无数据
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// WithdrawalKpi 是两张独立口径的资金流出卡:
|
||||
// 「用户提现」只统计提现申请审核通过的 USDT(点击进入提现审核页);
|
||||
// 「工资兑换」只统计用户工资转币商的 USDT(likei 平台为银行卡余额兑换金币)。
|
||||
// 两者都不是充值渠道,不参与来源筛选联动,占比按“金额 / 充值总和”表达资金回流强度。
|
||||
function WithdrawalKpi({ loading, onOpenWithdrawals, totalUsd, withdrawal }) {
|
||||
const data = withdrawal || {
|
||||
approvedCount: 0,
|
||||
approvedUsdMinor: 0,
|
||||
totalUsdMinor: 0,
|
||||
transferCount: 0,
|
||||
transferUsdMinor: 0,
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="finance-kpi finance-kpi--withdrawal"
|
||||
title="点击进入用户提现申请"
|
||||
type="button"
|
||||
onClick={onOpenWithdrawals}
|
||||
>
|
||||
<span className="finance-kpi__label">用户提现</span>
|
||||
<strong className="finance-kpi__value">{loading ? "…" : usdtText(data.approvedUsdMinor)}</strong>
|
||||
<small className="finance-kpi__meta">
|
||||
{loading ? "统计中" : outflowMeta(data.approvedUsdMinor, data.approvedCount, totalUsd)}
|
||||
</small>
|
||||
<small className="finance-kpi__meta">提现申请审核通过</small>
|
||||
</button>
|
||||
<div className="finance-kpi finance-kpi--salary">
|
||||
<span className="finance-kpi__label">工资兑换</span>
|
||||
<strong className="finance-kpi__value">{loading ? "…" : usdtText(data.transferUsdMinor)}</strong>
|
||||
<small className="finance-kpi__meta">
|
||||
{loading ? "统计中" : outflowMeta(data.transferUsdMinor, data.transferCount, totalUsd)}
|
||||
</small>
|
||||
<small className="finance-kpi__meta">用户工资转币商</small>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function usdtText(usdMinor) {
|
||||
return `USDT ${(usdMinor / 100).toLocaleString("zh-CN", { maximumFractionDigits: 2, minimumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
function outflowMeta(usdMinor, count, totalUsd) {
|
||||
const parts = [];
|
||||
if (totalUsd > 0) {
|
||||
const share = (usdMinor / totalUsd) * 100;
|
||||
parts.push(`占充值 ${share.toLocaleString("zh-CN", { maximumFractionDigits: 1 })}%`);
|
||||
}
|
||||
parts.push(`${formatAmount(count)} 笔`);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function kpiMeta(bucket, key, totalUsd, prevSummary) {
|
||||
const parts = [`${formatAmount(bucket.billCount)} 笔`];
|
||||
if (key !== "total" && totalUsd > 0) {
|
||||
const share = ((Number(bucket.usdMinorAmount) || 0) / totalUsd) * 100;
|
||||
parts.push(`占比 ${share.toLocaleString("zh-CN", { maximumFractionDigits: 1 })}%`);
|
||||
}
|
||||
const prev = prevSummary?.[key];
|
||||
if (prev && Number(prev.usdMinorAmount) > 0) {
|
||||
const delta =
|
||||
((Number(bucket.usdMinorAmount) - Number(prev.usdMinorAmount)) / Number(prev.usdMinorAmount)) * 100;
|
||||
const arrow = delta >= 0 ? "▲" : "▼";
|
||||
parts.push(`${arrow} ${Math.abs(delta).toLocaleString("zh-CN", { maximumFractionDigits: 1 })}% vs 上期`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function Sparkline({ daily, field, showCoins, tone }) {
|
||||
const values = (daily || []).map((bucket) => {
|
||||
if (field === "total") {
|
||||
return showCoins
|
||||
? bucket.googleCoinAmount + bucket.thirdPartyCoinAmount + bucket.coinSellerCoinAmount
|
||||
: bucket.googleUsdMinor + bucket.thirdPartyUsdMinor + bucket.coinSellerUsdMinor;
|
||||
}
|
||||
const fields = DAILY_FIELDS[field];
|
||||
return fields ? bucket[showCoins ? fields.coin : fields.usd] : 0;
|
||||
});
|
||||
if (values.length < 2) {
|
||||
return <span className="finance-sparkline" />;
|
||||
}
|
||||
const width = 100;
|
||||
const height = 28;
|
||||
const max = Math.max(...values);
|
||||
const min = Math.min(...values);
|
||||
const points = values.map((value, index) => {
|
||||
const x = (index * width) / (values.length - 1);
|
||||
const y = height - 3 - ((value - min) / (max - min || 1)) * (height - 8);
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
});
|
||||
const colors = {
|
||||
coinseller: "#B45309",
|
||||
google: "#1B873F",
|
||||
thirdparty: "#0E7490",
|
||||
total: "#2557D6",
|
||||
};
|
||||
const color = colors[tone] || colors.total;
|
||||
return (
|
||||
<svg className="finance-sparkline" preserveAspectRatio="none" viewBox={`0 0 ${width} ${height}`}>
|
||||
<polyline fill={`${color}22`} points={`${points.join(" ")} ${width},${height} 0,${height}`} stroke="none" />
|
||||
<polyline
|
||||
fill="none"
|
||||
points={points.join(" ")}
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.8"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendChart({ daily, showCoins }) {
|
||||
const buckets = daily || [];
|
||||
if (!buckets.length) {
|
||||
return <p className="finance-hint">当前时间范围内没有充值数据</p>;
|
||||
}
|
||||
const totals = buckets.map((bucket) =>
|
||||
showCoins
|
||||
? bucket.googleCoinAmount + bucket.thirdPartyCoinAmount + bucket.coinSellerCoinAmount
|
||||
: bucket.googleUsdMinor + bucket.thirdPartyUsdMinor + bucket.coinSellerUsdMinor,
|
||||
);
|
||||
const max = Math.max(1, ...totals);
|
||||
const value = (bucket, channel) => {
|
||||
const fields = DAILY_FIELDS[channel];
|
||||
return bucket[showCoins ? fields.coin : fields.usd];
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<div className="finance-trend" role="img" aria-label="按渠道堆叠的每日充值趋势">
|
||||
{buckets.map((bucket) => (
|
||||
<div
|
||||
className="finance-trend__col"
|
||||
key={bucket.date}
|
||||
title={`${bucket.date} · 谷歌 ${trendTip(bucket, "googlePlay", showCoins)} · 三方 ${trendTip(bucket, "thirdParty", showCoins)} · 币商 ${trendTip(bucket, "coinSeller", showCoins)}`}
|
||||
>
|
||||
{["googlePlay", "thirdParty", "coinSeller"].map((channel) => {
|
||||
const amount = value(bucket, channel);
|
||||
if (amount <= 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={`finance-trend__seg finance-trend__seg--${channel}`}
|
||||
key={channel}
|
||||
style={{ height: `${(amount / max) * 100}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="finance-trend__axis">
|
||||
<span>{buckets[0].date.slice(5)}</span>
|
||||
{buckets.length > 2 ? <span>{buckets[Math.floor(buckets.length / 2)].date.slice(5)}</span> : null}
|
||||
<span>{buckets[buckets.length - 1].date.slice(5)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function trendTip(bucket, channel, showCoins) {
|
||||
const fields = DAILY_FIELDS[channel];
|
||||
const amount = bucket[showCoins ? fields.coin : fields.usd];
|
||||
return showCoins ? formatAmount(amount) : formatUsdMinor(amount, "USD");
|
||||
}
|
||||
|
||||
function DeductionBreakdown({ paid }) {
|
||||
const covered = paid.coveredUsdMinor;
|
||||
if (covered <= 0) {
|
||||
return (
|
||||
<p className="finance-hint">
|
||||
暂无已同步的谷歌实付明细;点「查询谷歌实付」同步后这里会展示手续费与税费拆解。
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const rows = [
|
||||
{ amount: paid.estNetUsdMinor, color: "var(--fin-pos)", label: "净入账(估)" },
|
||||
{ amount: paid.estFeeUsdMinor, color: "#E19B3C", label: "谷歌服务费(估)" },
|
||||
{ amount: paid.estTaxUsdMinor, color: "#C86A6A", label: "代缴税费(估)" },
|
||||
];
|
||||
return (
|
||||
<div>
|
||||
<div className="finance-mixbar">
|
||||
{rows.map((row) => (
|
||||
<span key={row.label} style={{ background: row.color, flex: Math.max(row.amount, 1) }} />
|
||||
))}
|
||||
</div>
|
||||
{rows.map((row) => (
|
||||
<div className="finance-mixrow" key={row.label}>
|
||||
<i style={{ background: row.color }} />
|
||||
<span className="finance-mixrow__name">{row.label}</span>
|
||||
<span className="finance-mixrow__amt num">{formatUsdMinor(row.amount, "USD")}</span>
|
||||
<span className="finance-mixrow__pct num">
|
||||
{((row.amount / covered) * 100).toLocaleString("zh-CN", { maximumFractionDigits: 1 })}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<p className="finance-hint" style={{ marginTop: 8 }}>
|
||||
口径:已同步 {formatAmount(paid.syncedCount)} / {formatAmount(paid.googleBillCount)}{" "}
|
||||
笔谷歌订单,覆盖流水 {formatUsdMinor(covered, "USD")};按各订单实付占比折算成 USD
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionRanks({ regions }) {
|
||||
const top = (regions || []).slice(0, 5);
|
||||
if (!top.length) {
|
||||
return <p className="finance-hint">当前筛选下暂无区域数据</p>;
|
||||
}
|
||||
const max = Math.max(1, ...top.map((region) => region.usdMinorAmount));
|
||||
return (
|
||||
<div>
|
||||
{top.map((region, index) => (
|
||||
<div className="finance-rankrow" key={region.regionId || region.name || index}>
|
||||
<span className="finance-rankrow__idx num">{index + 1}</span>
|
||||
<span className="finance-rankrow__name">{region.name || `区域 ${region.regionId}`}</span>
|
||||
<span className="finance-rankbar">
|
||||
<i style={{ width: `${(region.usdMinorAmount / max) * 100}%` }} />
|
||||
</span>
|
||||
<span className="finance-rankrow__amt num">{formatUsdMinor(region.usdMinorAmount, "USD")}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
529
finance/src/components/FinanceRechargeDetailList.jsx
Normal file
529
finance/src/components/FinanceRechargeDetailList.jsx
Normal file
@ -0,0 +1,529 @@
|
||||
import CloudSyncOutlined from "@mui/icons-material/CloudSyncOutlined";
|
||||
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
|
||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||
import Button from "@mui/material/Button";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { Fragment, useState } from "react";
|
||||
import {
|
||||
formatAmount,
|
||||
formatMicroMoney,
|
||||
formatTime,
|
||||
formatUsdMinor,
|
||||
rechargeSourceTone,
|
||||
rechargeTypeLabel,
|
||||
} from "../format.js";
|
||||
|
||||
// 系统保存视图:财务高频筛选组合一键切换;与全局 rechargeType/paidState 联动。
|
||||
const SAVED_VIEWS = [
|
||||
{ key: "all", label: "全部", paidState: "", rechargeType: "" },
|
||||
{ key: "google", label: "谷歌充值", paidState: "", rechargeType: "google_play_recharge" },
|
||||
{ key: "third", label: "三方充值", paidState: "", rechargeType: "third_party" },
|
||||
{ key: "seller", label: "币商充值", paidState: "", rechargeType: "coin_seller" },
|
||||
{ key: "unsynced", label: "实付未同步", paidState: "unsynced", rechargeType: "" },
|
||||
];
|
||||
|
||||
export function FinanceRechargeDetailList({
|
||||
apps,
|
||||
bills,
|
||||
currency,
|
||||
error,
|
||||
exporting,
|
||||
filters,
|
||||
googlePaidRefreshing,
|
||||
loading,
|
||||
onExport,
|
||||
onFiltersChange,
|
||||
onRefreshGooglePaid,
|
||||
onReload,
|
||||
regions,
|
||||
regionsByApp,
|
||||
summary,
|
||||
}) {
|
||||
const [expandedId, setExpandedId] = useState("");
|
||||
const items = bills.items || [];
|
||||
const page = Number(bills.page || filters.page || 1);
|
||||
const pageSize = Number(bills.pageSize || 50);
|
||||
const total = Number(bills.total || 0);
|
||||
const hasNextPage = page * pageSize < total;
|
||||
const showCoins = currency === "coin";
|
||||
const regionNames = new Map(
|
||||
(regions || []).map((region) => [String(region.regionId), region.name || region.regionCode]),
|
||||
);
|
||||
const activeView =
|
||||
SAVED_VIEWS.find(
|
||||
(view) =>
|
||||
view.rechargeType === (filters.rechargeType || "") && view.paidState === (filters.paidState || ""),
|
||||
)?.key || "all";
|
||||
const pendingGoogleTxIds = items
|
||||
.filter((item) => item.rechargeType === "google_play_recharge" && !item.paidSyncedAtMs)
|
||||
.map((item) => item.transactionId)
|
||||
.filter(Boolean);
|
||||
const columnCount = showCoins ? 9 : 8;
|
||||
|
||||
const chips = [
|
||||
filters.appCode
|
||||
? { key: "appCode", label: `App:${appName(filters.appCode, apps)}`, reset: { appCode: "", regionId: "" } }
|
||||
: null,
|
||||
filters.regionId
|
||||
? {
|
||||
key: "regionId",
|
||||
label: `区域:${regionNames.get(String(filters.regionId)) || filters.regionId}`,
|
||||
reset: { regionId: "" },
|
||||
}
|
||||
: null,
|
||||
filters.timeRange?.startMs || filters.timeRange?.endMs
|
||||
? {
|
||||
key: "time",
|
||||
label: `时间:${rangeLabel(filters.timeRange)}`,
|
||||
reset: { timeRange: { endMs: "", startMs: "" } },
|
||||
}
|
||||
: null,
|
||||
filters.keyword ? { key: "keyword", label: `搜索:${filters.keyword}`, reset: { keyword: "" } } : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<section className="finance-card finance-list">
|
||||
<div className="finance-views" role="tablist" aria-label="流水视图">
|
||||
{SAVED_VIEWS.map((view) => (
|
||||
<button
|
||||
aria-selected={activeView === view.key}
|
||||
className={activeView === view.key ? "is-on" : ""}
|
||||
key={view.key}
|
||||
role="tab"
|
||||
type="button"
|
||||
onClick={() => onFiltersChange({ paidState: view.paidState, rechargeType: view.rechargeType })}
|
||||
>
|
||||
{view.label}
|
||||
{activeView === view.key && !loading ? (
|
||||
<span className="finance-views__cnt num">{formatAmount(total)}</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="finance-filterrow">
|
||||
{chips.map((chip) => (
|
||||
<button
|
||||
className="finance-fchip"
|
||||
key={chip.key}
|
||||
type="button"
|
||||
onClick={() => onFiltersChange(chip.reset)}
|
||||
>
|
||||
{chip.label}
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
))}
|
||||
<TextField
|
||||
className="finance-filterrow__region"
|
||||
disabled={!filters.appCode}
|
||||
label="区域"
|
||||
select
|
||||
size="small"
|
||||
title={filters.appCode ? "" : "先选择 App"}
|
||||
value={filters.regionId}
|
||||
onChange={(event) => onFiltersChange({ regionId: event.target.value })}
|
||||
>
|
||||
<MenuItem value="">全部区域</MenuItem>
|
||||
{(regions || []).map((region) => (
|
||||
<MenuItem key={region.regionId} value={String(region.regionId)}>
|
||||
{region.name || region.regionCode || region.regionId}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
className="finance-filterrow__search"
|
||||
label="订单搜索"
|
||||
placeholder="交易号 / GPA单号 / 凭证号"
|
||||
size="small"
|
||||
value={filters.keyword}
|
||||
onChange={(event) => onFiltersChange({ keyword: event.target.value })}
|
||||
/>
|
||||
<span className="finance-filterrow__spacer" />
|
||||
<Button
|
||||
disabled={googlePaidRefreshing || !filters.appCode || !pendingGoogleTxIds.length}
|
||||
size="small"
|
||||
startIcon={<CloudSyncOutlined fontSize="small" />}
|
||||
title={filters.appCode ? "查询本页谷歌订单的用户实付币种和金额" : "先选择 App 后可查询"}
|
||||
variant="outlined"
|
||||
onClick={() => onRefreshGooglePaid(pendingGoogleTxIds)}
|
||||
>
|
||||
{googlePaidRefreshing ? "查询中…" : "查询谷歌实付"}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={exporting || !filters.appCode}
|
||||
size="small"
|
||||
startIcon={<FileDownloadOutlined fontSize="small" />}
|
||||
title={filters.appCode ? "按当前筛选导出全部明细(CSV)" : "先选择 App 后可导出"}
|
||||
variant="contained"
|
||||
onClick={onExport}
|
||||
>
|
||||
{exporting ? "导出中…" : "导出报表"}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<RefreshOutlined fontSize="small" />}
|
||||
variant="outlined"
|
||||
onClick={onReload}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error ? <div className="finance-error">{error}</div> : null}
|
||||
{loading && !items.length ? <div className="finance-skeleton" /> : null}
|
||||
{!loading || items.length ? (
|
||||
<div className="finance-tablewrap">
|
||||
<table className="finance-flat-table finance-flat-table--bills">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>充值时间</th>
|
||||
<th>APP</th>
|
||||
<th>充值来源</th>
|
||||
<th>充值订单号</th>
|
||||
{showCoins ? <th className="r">充值金币</th> : null}
|
||||
<th className="r">账单金额</th>
|
||||
<th className="r">用户实付</th>
|
||||
<th className="r">三方税率扣款</th>
|
||||
<th>区域</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length ? (
|
||||
items.map((item) => {
|
||||
const rowId = item.id || item.transactionId;
|
||||
const expanded = expandedId === rowId;
|
||||
return (
|
||||
<Fragment key={rowId}>
|
||||
<tr
|
||||
className="finance-flat-table__row"
|
||||
onClick={() => setExpandedId(expanded ? "" : rowId)}
|
||||
>
|
||||
<td className="num">{formatTime(item.createdAtMs)}</td>
|
||||
<td>{appName(item.appCode, apps)}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`finance-badge finance-badge--${badgeTone(item.rechargeType)}`}
|
||||
>
|
||||
{rechargeTypeLabel(item.rechargeType)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="finance-order-cell">
|
||||
<span className="finance-stack">
|
||||
<span>{item.transactionId || "-"}</span>
|
||||
<small>{secondaryOrderNo(item)}</small>
|
||||
</span>
|
||||
</td>
|
||||
{showCoins ? (
|
||||
<td className="r num">
|
||||
<AmountText value={item.coinAmount}>
|
||||
{coinText(item.coinAmount)}
|
||||
</AmountText>
|
||||
</td>
|
||||
) : null}
|
||||
<td className="r num">
|
||||
<AmountText value={item.usdMinorAmount}>
|
||||
{billAmountText(item)}
|
||||
</AmountText>
|
||||
</td>
|
||||
<td className="r num">
|
||||
<AmountText value={item.userPaidAmountMicro}>
|
||||
{userPaidText(item)}
|
||||
</AmountText>
|
||||
</td>
|
||||
<td className="r num">{taxDeductionText(item)}</td>
|
||||
<td>{regionText(item, regionsByApp, apps, !filters.appCode)}</td>
|
||||
</tr>
|
||||
{expanded ? (
|
||||
<tr className="finance-detailrow">
|
||||
<td colSpan={columnCount}>
|
||||
<BillDetail
|
||||
googlePaidRefreshing={googlePaidRefreshing}
|
||||
item={item}
|
||||
onRefreshGooglePaid={onRefreshGooglePaid}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={columnCount} style={{ textAlign: "center" }}>
|
||||
当前筛选条件下没有账单
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="finance-pagination">
|
||||
<span className="finance-pagination__meta num">
|
||||
共 {formatAmount(total)} 笔
|
||||
{summary ? ` · 筛选合计 ${formatUsdMinor(summary.total?.usdMinorAmount || 0, "USD")}` : ""} · 第{" "}
|
||||
{page} / {Math.max(1, Math.ceil(total / pageSize))} 页
|
||||
</span>
|
||||
<div className="finance-pagination__buttons">
|
||||
<Button
|
||||
disabled={page <= 1 || loading}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => onFiltersChange({ page: page - 1 })}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!hasNextPage || loading}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => onFiltersChange({ page: page + 1 })}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// BillDetail 是行内展开的单笔明细:金额瀑布 + 单号集合 + 谷歌实付操作。
|
||||
function BillDetail({ googlePaidRefreshing, item, onRefreshGooglePaid }) {
|
||||
const currencyCode = item.userPaidCurrencyCode || item.currencyCode || "USD";
|
||||
const paid = Number(item.userPaidAmountMicro) || 0;
|
||||
const fee = Number(item.providerFeeMicro) || 0;
|
||||
const tax = Number(item.providerTaxMicro) || 0;
|
||||
const net = Number(item.providerNetMicro) || 0;
|
||||
const isGoogle = item.rechargeType === "google_play_recharge";
|
||||
const unsynced = isGoogle && !item.paidSyncedAtMs;
|
||||
return (
|
||||
<div className="finance-detail">
|
||||
<div>
|
||||
<div className="finance-card__title" style={{ marginBottom: 10 }}>
|
||||
金额拆解{paid ? ` · 实付币种 ${currencyCode}` : ""}
|
||||
</div>
|
||||
{paid && (fee || tax || net) ? (
|
||||
<div className="finance-wfall">
|
||||
<DetailWfallRow
|
||||
amount={paid}
|
||||
color="var(--fin-ink)"
|
||||
currency={currencyCode}
|
||||
label="用户实付"
|
||||
max={paid}
|
||||
/>
|
||||
{tax ? (
|
||||
<DetailWfallRow
|
||||
amount={-tax}
|
||||
color="#C86A6A"
|
||||
currency={currencyCode}
|
||||
label="代缴税费"
|
||||
max={paid}
|
||||
/>
|
||||
) : null}
|
||||
{fee ? (
|
||||
<DetailWfallRow
|
||||
amount={-fee}
|
||||
color="#E19B3C"
|
||||
currency={currencyCode}
|
||||
label="渠道服务费"
|
||||
max={paid}
|
||||
/>
|
||||
) : null}
|
||||
{net ? (
|
||||
<DetailWfallRow
|
||||
amount={net}
|
||||
color="var(--fin-pos)"
|
||||
currency={currencyCode}
|
||||
label="开发者净收"
|
||||
max={paid}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : unsynced ? (
|
||||
<p className="finance-hint">
|
||||
实付明细未同步。
|
||||
<Button
|
||||
disabled={googlePaidRefreshing}
|
||||
size="small"
|
||||
style={{ marginLeft: 8 }}
|
||||
variant="outlined"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRefreshGooglePaid([item.transactionId]);
|
||||
}}
|
||||
>
|
||||
同步这一笔
|
||||
</Button>
|
||||
</p>
|
||||
) : (
|
||||
<p className="finance-hint">
|
||||
{paid ? `用户实付 ${formatMicroMoney(paid, currencyCode)} · ` : ""}
|
||||
该渠道未提供扣费明细
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<dl className="finance-kv">
|
||||
<dt>交易号</dt>
|
||||
<dd className="finance-order-cell">{item.transactionId || "-"}</dd>
|
||||
{item.commandId ? (
|
||||
<>
|
||||
<dt>业务单号</dt>
|
||||
<dd className="finance-order-cell">{item.commandId}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{item.externalRef ? (
|
||||
<>
|
||||
<dt>外部订单</dt>
|
||||
<dd className="finance-order-cell">{item.externalRef}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{item.user?.username || item.user?.userId ? (
|
||||
<>
|
||||
<dt>充值用户</dt>
|
||||
<dd>
|
||||
{item.user.username || "-"}
|
||||
{item.user.userId ? ` · ID ${item.user.userId}` : ""}
|
||||
</dd>
|
||||
</>
|
||||
) : null}
|
||||
{item.sellerUserId ? (
|
||||
<>
|
||||
<dt>币商 ID</dt>
|
||||
<dd className="num">{item.sellerUserId}</dd>
|
||||
</>
|
||||
) : null}
|
||||
<dt>美金换算</dt>
|
||||
<dd className="num">{formatUsdMinor(item.usdMinorAmount, "USD")}</dd>
|
||||
{item.paidSyncedAtMs ? (
|
||||
<>
|
||||
<dt>实付同步于</dt>
|
||||
<dd className="num">{formatTime(item.paidSyncedAtMs)}</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailWfallRow({ amount, color, currency, label, max }) {
|
||||
const negative = amount < 0;
|
||||
const width = max > 0 ? (Math.abs(amount) / max) * 100 : 0;
|
||||
return (
|
||||
<div className="finance-wfall__row">
|
||||
<span className="finance-wfall__label">{label}</span>
|
||||
<span className="finance-wfall__track">
|
||||
<i style={{ background: color, width: `${Math.min(100, width)}%` }} />
|
||||
</span>
|
||||
<span className={negative ? "finance-wfall__val num finance-amount--negative" : "finance-wfall__val num"}>
|
||||
{negative ? `− ${formatMicroMoney(-amount, currency)}` : formatMicroMoney(amount, currency)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AmountText({ children, value }) {
|
||||
const negative = hasNumber(value) && Number(value) < 0;
|
||||
return <span className={negative ? "finance-amount finance-amount--negative" : "finance-amount"}>{children}</span>;
|
||||
}
|
||||
|
||||
function badgeTone(rechargeType) {
|
||||
return rechargeSourceTone(rechargeType) === "google"
|
||||
? "google"
|
||||
: rechargeSourceTone(rechargeType) === "coinseller"
|
||||
? "coinseller"
|
||||
: "thirdparty";
|
||||
}
|
||||
|
||||
function rangeLabel(timeRange) {
|
||||
const start = timeRange?.startMs ? formatTime(timeRange.startMs) : "…";
|
||||
const end = timeRange?.endMs ? formatTime(timeRange.endMs) : "…";
|
||||
return `${start} ~ ${end}`;
|
||||
}
|
||||
|
||||
function appName(appCode, apps) {
|
||||
return (apps || []).find((app) => app.appCode === appCode)?.appName || appCode || "-";
|
||||
}
|
||||
|
||||
// regionText 解析区域列:全部 App 视图下带上 App 前缀(如 “Lalu-南亚区”),单 App 视图只显示区域名。
|
||||
function regionText(item, regionsByApp, apps, withAppPrefix) {
|
||||
const regionId = item.targetRegionId || item.sellerRegionId;
|
||||
if (!regionId) {
|
||||
return "—";
|
||||
}
|
||||
const appRegions = (regionsByApp || {})[item.appCode] || [];
|
||||
const name = appRegions.find((region) => String(region.regionId) === String(regionId))?.name || `区域 ${regionId}`;
|
||||
return withAppPrefix ? `${appName(item.appCode, apps)}-${name}` : name;
|
||||
}
|
||||
|
||||
function secondaryOrderNo(item) {
|
||||
return [item.commandId, item.externalRef].filter(Boolean).join(" / ");
|
||||
}
|
||||
|
||||
function coinText(value) {
|
||||
const text = formatAmount(value);
|
||||
return text === "-" ? "-" : `${text} 金币`;
|
||||
}
|
||||
|
||||
function billAmountText(item) {
|
||||
// 账单金额是平台统一核算的 USDT 口径;currencyCode/providerAmountMinor 属于用户向三方渠道实付的本地币种,不能混到本列。
|
||||
return formatUsdMinor(item.usdMinorAmount, "USDT");
|
||||
}
|
||||
|
||||
function userPaidText(item) {
|
||||
if (item.userPaidText) {
|
||||
return item.userPaidText;
|
||||
}
|
||||
if (hasNonZeroNumber(item.userPaidAmountMicro) && item.userPaidCurrencyCode) {
|
||||
return formatMicroMoney(item.userPaidAmountMicro, item.userPaidCurrencyCode);
|
||||
}
|
||||
if (isGoogleBill(item) && !item.paidSyncedAtMs) {
|
||||
return "未同步";
|
||||
}
|
||||
if (hasPositiveNumber(item.userPaidAmountMinor)) {
|
||||
return formatUsdMinor(item.userPaidAmountMinor, item.userPaidCurrencyCode || item.currencyCode || "USD");
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
function taxDeductionText(item) {
|
||||
const currency = item.userPaidCurrencyCode || item.currencyCode || "USD";
|
||||
const fee = hasPositiveNumber(item.providerFeeMicro) ? item.providerFeeMicro : 0;
|
||||
const tax = hasPositiveNumber(item.providerTaxMicro) ? item.providerTaxMicro : 0;
|
||||
const deduction = fee + tax;
|
||||
if (deduction > 0) {
|
||||
const parts = [formatMicroMoney(deduction, currency)];
|
||||
if (hasPositiveNumber(item.userPaidAmountMicro)) {
|
||||
const percent = (deduction / item.userPaidAmountMicro) * 100;
|
||||
parts.push(`${percent.toLocaleString("zh-CN", { maximumFractionDigits: 2 })}%`);
|
||||
}
|
||||
return parts.join(" / ");
|
||||
}
|
||||
if (item.taxDeductionText) {
|
||||
return item.taxDeductionText;
|
||||
}
|
||||
if (isGoogleBill(item) && !item.paidSyncedAtMs) {
|
||||
return "未同步";
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
function isGoogleBill(item) {
|
||||
return item.rechargeType === "google_play_recharge" || item.providerCode === "google_play";
|
||||
}
|
||||
|
||||
function hasNumber(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return false;
|
||||
}
|
||||
return Number.isFinite(Number(value));
|
||||
}
|
||||
|
||||
function hasPositiveNumber(value) {
|
||||
return hasNumber(value) && Number(value) > 0;
|
||||
}
|
||||
|
||||
function hasNonZeroNumber(value) {
|
||||
return hasNumber(value) && Number(value) !== 0;
|
||||
}
|
||||
145
finance/src/components/FinanceRechargeDetailList.test.jsx
Normal file
145
finance/src/components/FinanceRechargeDetailList.test.jsx
Normal file
@ -0,0 +1,145 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { FinanceRechargeDetailList } from "./FinanceRechargeDetailList.jsx";
|
||||
|
||||
const baseProps = {
|
||||
apps: [
|
||||
{ appCode: "lalu", appName: "lalu" },
|
||||
{ appCode: "aslan", appName: "Aslan" },
|
||||
],
|
||||
bills: {
|
||||
items: [],
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
total: 0,
|
||||
},
|
||||
error: "",
|
||||
filters: {
|
||||
appCode: "",
|
||||
keyword: "",
|
||||
page: 1,
|
||||
regionId: "",
|
||||
timeRange: { endMs: "", startMs: "" },
|
||||
},
|
||||
loading: false,
|
||||
onFiltersChange: vi.fn(),
|
||||
onReload: vi.fn(),
|
||||
summary: {
|
||||
googlePlay: { billCount: 1, coinAmount: 90000, usdMinorAmount: 999 },
|
||||
thirdParty: { billCount: 0, coinAmount: 0, usdMinorAmount: 0 },
|
||||
total: { billCount: 1, coinAmount: 90000, usdMinorAmount: 999 },
|
||||
},
|
||||
summaryLoading: false,
|
||||
};
|
||||
|
||||
test("hides coin recharge columns by default", () => {
|
||||
render(
|
||||
<FinanceRechargeDetailList
|
||||
{...baseProps}
|
||||
bills={{
|
||||
...baseProps.bills,
|
||||
items: [rechargeBillFixture()],
|
||||
total: 1,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("columnheader", { name: "充值金币" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("90,000 金币")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("1 笔 · 90,000 金币")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "账单金额" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "用户实付" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders app recharge coin columns and values when coin currency is selected", () => {
|
||||
render(
|
||||
<FinanceRechargeDetailList
|
||||
{...baseProps}
|
||||
bills={{
|
||||
...baseProps.bills,
|
||||
items: [rechargeBillFixture()],
|
||||
total: 1,
|
||||
}}
|
||||
currency="coin"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("columnheader", { name: "APP" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "充值金币" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "充值来源" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "充值订单号" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "账单金额" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "用户实付" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: "三方税率扣款" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Aslan")).toBeInTheDocument();
|
||||
expect(screen.getByText("90,000 金币")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("谷歌充值").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("tx-google")).toBeInTheDocument();
|
||||
expect(screen.getByText("cmd-1 / GPA.1")).toBeInTheDocument();
|
||||
expect(screen.getByText("USDT 9.99")).toBeInTheDocument();
|
||||
expect(screen.getByText("SAR 12.99")).toBeInTheDocument();
|
||||
expect(screen.getByText("SAR 1.95 / 15%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps bill amount in USDT while rendering third-party paid amount in local currency", () => {
|
||||
render(
|
||||
<FinanceRechargeDetailList
|
||||
{...baseProps}
|
||||
bills={{
|
||||
...baseProps.bills,
|
||||
items: [
|
||||
{
|
||||
...rechargeBillFixture(),
|
||||
currencyCode: "EGP",
|
||||
id: "tx-mifapay",
|
||||
providerAmountMinor: 472600,
|
||||
rechargeType: "third_party_recharge",
|
||||
transactionId: "tx-mifapay",
|
||||
usdMinorAmount: 10000,
|
||||
userPaidAmountMicro: 4726000000,
|
||||
userPaidCurrencyCode: "EGP",
|
||||
userPaidText: "",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("USDT 100.00")).toBeInTheDocument();
|
||||
expect(screen.getByText("EGP 4,726.00")).toBeInTheDocument();
|
||||
expect(screen.queryByText("EGP 100.00")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("keeps empty recharge detail table colspan aligned with visible columns", () => {
|
||||
const { container } = render(<FinanceRechargeDetailList {...baseProps} />);
|
||||
|
||||
expect(container.querySelector("tbody td")?.colSpan).toBe(8);
|
||||
expect(screen.getByText("当前筛选条件下没有账单")).toBeInTheDocument();
|
||||
|
||||
const { container: coinContainer } = render(<FinanceRechargeDetailList {...baseProps} currency="coin" />);
|
||||
expect(coinContainer.querySelector("tbody td")?.colSpan).toBe(9);
|
||||
});
|
||||
|
||||
function rechargeBillFixture() {
|
||||
return {
|
||||
appCode: "aslan",
|
||||
coinAmount: 90000,
|
||||
commandId: "cmd-1",
|
||||
createdAtMs: 1760000000000,
|
||||
currencyCode: "SAR",
|
||||
exchangeCoinAmount: 90000,
|
||||
exchangeUsdMinorAmount: 999,
|
||||
externalRef: "GPA.1",
|
||||
id: "tx-google",
|
||||
paidSyncedAtMs: 1760000100000,
|
||||
providerAmountMinor: 1299,
|
||||
providerTaxMicro: 1950000,
|
||||
rechargeType: "google_play_recharge",
|
||||
transactionId: "tx-google",
|
||||
usdMinorAmount: 999,
|
||||
userPaidAmountMicro: 13000000,
|
||||
userPaidCurrencyCode: "SAR",
|
||||
userPaidText: "SAR 12.99",
|
||||
};
|
||||
}
|
||||
152
finance/src/components/FinanceReconciliation.jsx
Normal file
152
finance/src/components/FinanceReconciliation.jsx
Normal file
@ -0,0 +1,152 @@
|
||||
import Button from "@mui/material/Button";
|
||||
import { EMPTY_RECHARGE_OVERVIEW, EMPTY_RECHARGE_SUMMARY, formatAmount, formatUsdMinor } from "../format.js";
|
||||
|
||||
// 渠道对账页:按结算月核对各渠道的流水与扣费。谷歌口径来自 Orders API 同步的实付明细;
|
||||
// 三方与币商没有渠道侧扣费数据时如实标注,不做拍脑袋估算。
|
||||
export function FinanceReconciliation({ loading, month, onMonthChange, overview, summary }) {
|
||||
const data = summary || EMPTY_RECHARGE_SUMMARY;
|
||||
const paid = (overview || EMPTY_RECHARGE_OVERVIEW).googlePaid;
|
||||
const totalUsd = Number(data.total?.usdMinorAmount) || 0;
|
||||
const feeUsd = paid.estFeeUsdMinor;
|
||||
const taxUsd = paid.estTaxUsdMinor;
|
||||
const netUsd = totalUsd - feeUsd - taxUsd;
|
||||
|
||||
return (
|
||||
<div className="finance-overview">
|
||||
<div className="finance-recon-toolbar">
|
||||
<Button size="small" variant="outlined" onClick={() => onMonthChange(-1)}>
|
||||
‹ 上月
|
||||
</Button>
|
||||
<span className="finance-recon-toolbar__month">{month.label} 结算期</span>
|
||||
<Button disabled={month.isCurrent} size="small" variant="outlined" onClick={() => onMonthChange(1)}>
|
||||
下月 ›
|
||||
</Button>
|
||||
<span className="finance-hint" style={{ marginLeft: "auto" }}>
|
||||
对账口径:平台流水 × 谷歌 Orders API 实付 · 中国时区
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<section className="finance-card">
|
||||
<div className="finance-card__head">
|
||||
<span className="finance-card__title">
|
||||
结算瀑布<small>全渠道 · USD{paid.unsyncedCount > 0 ? " · 谷歌扣费为已同步部分估算" : ""}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="finance-card__body">
|
||||
{loading ? (
|
||||
<p className="finance-hint">统计中…</p>
|
||||
) : (
|
||||
<div className="finance-wfall">
|
||||
<WaterfallRow amount={totalUsd} color="var(--fin-ink)" label="充值流水" max={totalUsd} />
|
||||
<WaterfallRow amount={-feeUsd} color="#E19B3C" label="渠道手续费(估)" max={totalUsd} />
|
||||
<WaterfallRow amount={-taxUsd} color="#C86A6A" label="代缴税费(估)" max={totalUsd} />
|
||||
<WaterfallRow amount={netUsd} color="var(--fin-pos)" label="净入账(估)" max={totalUsd} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="finance-recon-grid">
|
||||
<section className="finance-card">
|
||||
<div className="finance-card__head">
|
||||
<span className="finance-badge finance-badge--google">谷歌</span>
|
||||
{paid.unsyncedCount > 0 ? (
|
||||
<span className="finance-pill finance-pill--warn">
|
||||
<i />
|
||||
{formatAmount(paid.unsyncedCount)} 笔待同步
|
||||
</span>
|
||||
) : (
|
||||
<span className="finance-pill finance-pill--ok">
|
||||
<i />
|
||||
实付已全量同步
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="finance-card__body">
|
||||
<ReconLine
|
||||
label="平台流水"
|
||||
value={formatUsdMinor(data.googlePlay?.usdMinorAmount || 0, "USD")}
|
||||
/>
|
||||
<ReconLine
|
||||
label={`已同步实付覆盖(${formatAmount(paid.syncedCount)}/${formatAmount(paid.googleBillCount)} 笔)`}
|
||||
value={formatUsdMinor(paid.coveredUsdMinor, "USD")}
|
||||
/>
|
||||
<ReconLine
|
||||
label="谷歌服务费(估)"
|
||||
negative
|
||||
value={`− ${formatUsdMinor(paid.estFeeUsdMinor, "USD")}`}
|
||||
/>
|
||||
<ReconLine
|
||||
label="代缴税费(估)"
|
||||
negative
|
||||
value={`− ${formatUsdMinor(paid.estTaxUsdMinor, "USD")}`}
|
||||
/>
|
||||
<ReconLine label="预计净入账" total value={formatUsdMinor(paid.estNetUsdMinor, "USD")} />
|
||||
<p className="finance-hint" style={{ marginTop: 10 }}>
|
||||
与 Play Console 财务报告按月核对;未同步订单可在流水页批量补数
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="finance-card">
|
||||
<div className="finance-card__head">
|
||||
<span className="finance-badge finance-badge--thirdparty">三方</span>
|
||||
</div>
|
||||
<div className="finance-card__body">
|
||||
<ReconLine
|
||||
label="三方流水"
|
||||
value={formatUsdMinor(data.thirdParty?.usdMinorAmount || 0, "USD")}
|
||||
/>
|
||||
<ReconLine label="笔数" value={`${formatAmount(data.thirdParty?.billCount || 0)} 笔`} />
|
||||
<ReconLine label="渠道扣费" value="V5Pay 逐笔回传 / MiFaPay 未提供" />
|
||||
<p className="finance-hint" style={{ marginTop: 10 }}>
|
||||
V5Pay 的 fee/tax 已逐笔展示在流水明细;MiFaPay 仅提供毛额,需用渠道后台月账单人工核对
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="finance-card">
|
||||
<div className="finance-card__head">
|
||||
<span className="finance-badge finance-badge--coinseller">币商</span>
|
||||
</div>
|
||||
<div className="finance-card__body">
|
||||
<ReconLine
|
||||
label="净进货(进货 − 冲回)"
|
||||
value={formatUsdMinor(data.coinSeller?.usdMinorAmount || 0, "USD")}
|
||||
/>
|
||||
<ReconLine label="记录数" value={`${formatAmount(data.coinSeller?.billCount || 0)} 笔`} />
|
||||
<ReconLine label="金币入库" value={`${formatAmount(data.coinSeller?.coinAmount || 0)} 金币`} />
|
||||
<p className="finance-hint" style={{ marginTop: 10 }}>
|
||||
打款凭证与 TRC20 交易号逐笔登记在流水明细,可按凭证号搜索核对
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WaterfallRow({ amount, color, label, max }) {
|
||||
const width = max > 0 ? (Math.abs(amount) / max) * 100 : 0;
|
||||
const negative = amount < 0;
|
||||
return (
|
||||
<div className="finance-wfall__row">
|
||||
<span className="finance-wfall__label">{label}</span>
|
||||
<span className="finance-wfall__track">
|
||||
<i style={{ background: color, width: `${Math.min(100, width)}%` }} />
|
||||
</span>
|
||||
<span className={negative ? "finance-wfall__val num finance-amount--negative" : "finance-wfall__val num"}>
|
||||
{negative ? `− ${formatUsdMinor(-amount, "USD")}` : formatUsdMinor(amount, "USD")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReconLine({ label, negative, total, value }) {
|
||||
return (
|
||||
<div className={["finance-recon-line", total ? "finance-recon-line--total" : ""].filter(Boolean).join(" ")}>
|
||||
<span>{label}</span>
|
||||
<b className={negative ? "num finance-amount--negative" : "num"}>{value}</b>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
finance/src/components/FinanceShell.jsx
Normal file
129
finance/src/components/FinanceShell.jsx
Normal file
@ -0,0 +1,129 @@
|
||||
import AssignmentOutlined from "@mui/icons-material/AssignmentOutlined";
|
||||
import FactCheckOutlined from "@mui/icons-material/FactCheckOutlined";
|
||||
import InsightsOutlined from "@mui/icons-material/InsightsOutlined";
|
||||
import PaymentsOutlined from "@mui/icons-material/PaymentsOutlined";
|
||||
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
|
||||
import RuleFolderOutlined from "@mui/icons-material/RuleFolderOutlined";
|
||||
import LinearProgress from "@mui/material/LinearProgress";
|
||||
|
||||
const NAV_GROUPS = [
|
||||
{
|
||||
label: "订单",
|
||||
views: [{ icon: ReceiptLongOutlined, id: "coinSellerRechargeOrders", label: "币商充值" }],
|
||||
},
|
||||
{
|
||||
label: "经营",
|
||||
views: [
|
||||
{ icon: InsightsOutlined, id: "overview", label: "财务概览" },
|
||||
{ icon: ReceiptLongOutlined, id: "rechargeDetails", label: "充值流水" },
|
||||
{ icon: RuleFolderOutlined, id: "recon", label: "渠道对账" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "审批",
|
||||
views: [
|
||||
{ icon: AssignmentOutlined, id: "create", label: "发起申请" },
|
||||
{ icon: FactCheckOutlined, id: "applications", label: "申请审核" },
|
||||
{ icon: PaymentsOutlined, id: "withdrawals", label: "用户提现申请" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const VIEW_TITLES = {
|
||||
applications: "申请审核",
|
||||
coinSellerRechargeOrders: "币商充值",
|
||||
create: "发起申请",
|
||||
overview: "财务概览",
|
||||
recon: "渠道对账",
|
||||
rechargeDetails: "充值流水",
|
||||
withdrawals: "用户提现申请",
|
||||
};
|
||||
|
||||
export function FinanceShell({
|
||||
activeView,
|
||||
appBar,
|
||||
canAudit,
|
||||
canCreate,
|
||||
canViewCoinSellerRechargeOrders,
|
||||
canViewRechargeDetails,
|
||||
canViewWithdrawals,
|
||||
children,
|
||||
loading,
|
||||
onViewChange,
|
||||
session,
|
||||
subtitle,
|
||||
toolbar,
|
||||
}) {
|
||||
const allows = (viewId) => {
|
||||
if (viewId === "overview" || viewId === "rechargeDetails" || viewId === "recon") {
|
||||
return canViewRechargeDetails;
|
||||
}
|
||||
if (viewId === "coinSellerRechargeOrders") {
|
||||
return canViewCoinSellerRechargeOrders;
|
||||
}
|
||||
if (viewId === "applications") {
|
||||
return canAudit;
|
||||
}
|
||||
if (viewId === "withdrawals") {
|
||||
return canViewWithdrawals;
|
||||
}
|
||||
return canCreate;
|
||||
};
|
||||
return (
|
||||
<main className="finance-shell">
|
||||
<aside className="finance-sidebar">
|
||||
<div className="finance-brand">
|
||||
<span>HY</span>
|
||||
<div>
|
||||
<strong>财务工作台</strong>
|
||||
<small>Finance Console</small>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="finance-nav" aria-label="财务系统菜单">
|
||||
{NAV_GROUPS.map((group) => {
|
||||
const visible = group.views.filter((view) => allows(view.id));
|
||||
if (!visible.length) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="finance-nav__group" key={group.label}>
|
||||
<span className="finance-nav__label">{group.label}</span>
|
||||
{visible.map((view) => {
|
||||
const Icon = view.icon;
|
||||
return (
|
||||
<button
|
||||
className={activeView === view.id ? "is-active" : ""}
|
||||
key={view.id}
|
||||
type="button"
|
||||
onClick={() => onViewChange(view.id)}
|
||||
>
|
||||
<Icon fontSize="small" />
|
||||
<span>{view.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="finance-user">
|
||||
<span>{session?.user?.name || session?.user?.account || "当前用户"}</span>
|
||||
<small>财务系统</small>
|
||||
</div>
|
||||
</aside>
|
||||
<section className="finance-main">
|
||||
{loading ? <LinearProgress className="finance-progress" /> : null}
|
||||
<header className="finance-topbar">
|
||||
{appBar || (
|
||||
<div className="finance-topbar__title">
|
||||
<h1>{VIEW_TITLES[activeView] || "财务系统"}</h1>
|
||||
{subtitle ? <small>{subtitle}</small> : null}
|
||||
</div>
|
||||
)}
|
||||
{toolbar ? <div className="finance-topbar__tools">{toolbar}</div> : null}
|
||||
</header>
|
||||
<div className="finance-content">{children}</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
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