Compare commits

...

204 Commits

Author SHA1 Message Date
ZuoZuo
a93370d434 修复币商充值币种列表 2026-07-15 22:10:53 +08:00
zhx
56b50cc87e fix(external-admin): preserve black-gold tokens in production 2026-07-15 20:01:37 +08:00
zhx
c67d2e2d4b fix(external-admin): refine authenticated branding 2026-07-15 19:57:01 +08:00
zhx
7bc1b6a423 fix(resources): block form submit during uploads 2026-07-15 19:38:13 +08:00
zhx
d6cafb98b1 feat(admin): enhance resource and management workflows 2026-07-15 19:38:09 +08:00
zhx
4d872de302 feat(ops-center): support lucky gift dynamic v3 2026-07-15 19:35:17 +08:00
zhx
6ff2feb052 fix(external-admin): resolve branding after login 2026-07-15 19:14:50 +08:00
zhx
e9c4595f02 feat(external-admin): configure account permissions 2026-07-15 18:45:36 +08:00
zhx
490e36b2cf feat(external-admin): use database app branding 2026-07-15 18:05:16 +08:00
zhx
c99c94db8e fix(external-admin): keep RTL styles mounted 2026-07-15 17:01:47 +08:00
zhx
d302d8a360 feat(external-admin): add account login and i18n 2026-07-15 16:51:25 +08:00
zhx
bcc3854114 feat(admin): add external management system 2026-07-15 16:05:33 +08:00
zhx
9045de6ad2 feat(finance): edit app USDT receive addresses 2026-07-15 13:09:53 +08:00
zhx
ee900d0127 feat(finance): add USDT address lookup 2026-07-15 10:39:13 +08:00
ZuoZuo
2eb18502a2 活动模版 2026-07-15 09:44:04 +08:00
zhx
da6d2dfdb9 送礼记录 2026-07-14 20:23:03 +08:00
zhx
59901a3cab 添加多次确认机制 2026-07-14 18:48:02 +08:00
zhx
4abab5c164 修改相关展示 2026-07-14 17:58:09 +08:00
zhx
abb5ba9c52 支持游戏白名单按短号添加用户 2026-07-14 17:05:08 +08:00
zhx
26f8d0767f 币商充值增加C2C选项 2026-07-14 16:34:08 +08:00
zhx
e021c51500 游戏用户白名单 2026-07-14 15:09:35 +08:00
zhx
0299d57aa4 权限展示 2026-07-14 14:34:46 +08:00
zhx
54b584c096 Show app logos in social BI 2026-07-14 14:22:24 +08:00
zhx
85e2e76878 去掉财务 2026-07-14 14:07:17 +08:00
zhx
0051ca4324 添加高亮 2026-07-14 11:37:57 +08:00
zhx
cada5d8f5e Show app logos across admin 2026-07-14 11:29:05 +08:00
zhx
6d237766f9 时间选择器 2026-07-14 11:05:48 +08:00
zhx
2a5238563b 细节优化 2026-07-14 10:55:10 +08:00
zhx
01989ee16e app区域限制 2026-07-13 15:23:55 +08:00
zhx
1b9982e52c 增加创建时间 2026-07-13 11:10:08 +08:00
zhx
2f384687b5 积分收益政策h5 2026-07-12 17:38:51 +08:00
zhx
a4dd49c33d 增加用户信息 2026-07-12 14:17:50 +08:00
zhx
2bdc48e63c 增加首页的展示 2026-07-12 00:47:34 +08:00
zhx
f597cb6110 feat: rename manager cross-region permission 2026-07-11 20:36:42 +08:00
zhx
8ef5b0c08d feat: add manager region invite switch 2026-07-11 18:27:19 +08:00
zhx
c206c72612 完善后台管理功能 2026-07-11 02:26:23 +08:00
zhx
9065382192 fix: scope H5 config by app code 2026-07-10 20:20:58 +08:00
zhx
ef260df749 ui相关修复 2026-07-10 14:31:56 +08:00
zhx
cf98d5bd9a 子币商以及 充值流水相关 2026-07-09 18:15:25 +08:00
zhx
c7ca4f23ec 数据改动 2026-07-09 16:16:31 +08:00
zhx
50da0a569b 后台子币商权限相关 2026-07-09 13:02:40 +08:00
zhx
5e9ed4c0c5 Add ops center lucky gift platform 2026-07-08 22:30:46 +08:00
zhx
7711f2a9ee 自动充值系统 2026-07-07 18:05:08 +08:00
zhx
ee023df614 vip赠送 2026-07-06 15:14:05 +08:00
zhx
24e9bfc439 资源撤回,系统消息 2026-07-06 12:43:25 +08:00
zhx
46654ac23f 去掉kpi相关 2026-07-06 11:47:04 +08:00
zhx
4874bba9ac 批量删除 2026-07-05 14:55:43 +08:00
zhx
f12b2767c0 资源删除 2026-07-05 14:08:06 +08:00
zhx
53a178f11c 金币操作限制 2026-07-05 13:45:04 +08:00
zhx
feefcba3c8 相关修复 2026-07-05 01:56:01 +08:00
zhx
08872009e1 礼物资源 2026-07-05 01:31:45 +08:00
zhx
216031f776 mp4布局 2026-07-05 00:48:26 +08:00
zhx
46bd69cb01 漏斗数据 2026-07-05 00:08:35 +08:00
zhx
ef90b9ed27 取消vip门槛 2026-07-04 02:17:51 +08:00
zhx
cf60f9ce2c 时间选择器 2026-07-04 00:26:36 +08:00
zhx
fb168822cc 修改文案 2026-07-04 00:09:12 +08:00
zhx
68ef0dfee3 修改bi后台数据 2026-07-03 22:42:42 +08:00
zhx
a83907fc05 优化链路 2026-07-03 21:03:28 +08:00
zhx
cb4dcd87cf 新数据指标 2026-07-03 19:52:18 +08:00
zhx
6ed13c690c yumi aslan数据打通 2026-07-03 18:49:51 +08:00
zhx
ea78619f5f 地区 2026-07-03 18:07:52 +08:00
zhx
f6822d2411 新财务系统 2026-07-03 17:34:57 +08:00
zhx
9fe67dc8d1 阿斯兰数据 2026-07-03 15:53:40 +08:00
zhx
9dd69b2374 Yumi数据 2026-07-03 14:43:49 +08:00
zhx
b8b5182c67 ui修改 2026-07-03 13:27:03 +08:00
zhx
88eae83dbe 财务模块 2026-07-03 13:08:57 +08:00
zhx
7025ae9e30 币种汇率改变 2026-07-03 11:34:34 +08:00
zhx
de910547f1 增加成就排序 2026-07-02 19:23:47 +08:00
zhx
da85f07340 Retry transient admin read requests 2026-07-02 15:46:41 +08:00
zhx
6726a21e9c Reduce admin shell chunk fanout 2026-07-02 15:17:30 +08:00
zhx
f44097570d Disable admin module preloads 2026-07-02 15:11:54 +08:00
zhx
c836d0f702 Handle asset preload failures with auto reload 2026-07-02 14:49:34 +08:00
zhx
18ad13563b 主播提现列表 2026-07-02 14:21:32 +08:00
zhx
8fe2efb344 更改相关接口 2026-07-02 11:45:48 +08:00
zhx
91a7c784c9 新BI系统 2026-07-02 10:54:00 +08:00
zhx
b0b1a26ae8 修复靓号 2026-06-30 20:50:34 +08:00
zhx
810ee6721c 用户靓号收回 2026-06-30 16:01:15 +08:00
zhx
b81c2edf43 数据大屏 2026-06-30 15:17:09 +08:00
zhx
a952b4262e 更新数据大屏金币统计列 2026-06-30 14:41:06 +08:00
zhx
f5e1a20dd9 数据大屏口径修正 2026-06-30 14:11:55 +08:00
zhx
00b8020599 小优化 2026-06-30 11:38:58 +08:00
zhx
7080538a00 用户靓号白名单 2026-06-30 10:52:45 +08:00
zhx
7d08e7cfca 白名单userid 2026-06-30 10:47:27 +08:00
zhx
9e72ff52c7 强制用户输入联系方式 2026-06-29 18:27:34 +08:00
zhx
c08f9e7a83 即构游戏以及bd相关修复 2026-06-29 17:47:33 +08:00
zhx
a95330857c id搜索修改 2026-06-29 14:26:00 +08:00
zhx
653fc664dc 样式修改 2026-06-29 12:22:04 +08:00
zhx
0e87bb24af 数据报表子结构查看 2026-06-29 11:27:16 +08:00
local
3e8d4b4aea bi报表和财务系统 2026-06-28 23:25:01 +08:00
local
f059b0c60c 财务系统和新BI 2026-06-28 15:11:48 +08:00
local
7858530fc8 靓号查询 2026-06-27 22:46:48 +08:00
zhx
63d4ffbacd 优化靓号页面展示 2026-06-27 01:09:55 +08:00
zhx
8c9f5d35b2 成就,等级配置 2026-06-26 16:10:59 +08:00
zhx
0112303b88 资源发放 2026-06-26 12:58:27 +08:00
zhx
c33fb469d9 三方 2026-06-25 22:09:45 +08:00
zhx
2125f81baf 三方汇率 2026-06-25 17:00:05 +08:00
zhx
9405e2b7d6 feat(databi): query china stat timezone 2026-06-25 14:31:16 +08:00
zhx
88ef541b95 cp和充值 2026-06-25 13:48:35 +08:00
zhx
e51a4401ef 三方支付临时链接 2026-06-25 11:20:49 +08:00
zhx
bc9484c60d 撤销资源组 2026-06-24 19:49:05 +08:00
zhx
b6d8af0fa3 经理 2026-06-24 15:50:08 +08:00
zhx
dae91e892e 身份自动赠送 2026-06-24 14:45:55 +08:00
zhx
4166fe0786 白名单加白 2026-06-24 12:11:14 +08:00
local
2215b01743 fix coin seller stock actions for disabled sellers 2026-06-23 23:54:29 +08:00
local
6ab21a8baa 修复无法扣除币商余额 2026-06-23 23:29:21 +08:00
zhx
81fa3a25bb 房间置顶 2026-06-23 20:47:41 +08:00
zhx
889e75f6a1 fix databi registration country metric 2026-06-23 19:11:00 +08:00
zhx
05412e477a 机器人进真人房配置 2026-06-23 14:05:17 +08:00
zhx
17251552d9 ip白名单 2026-06-23 12:11:00 +08:00
zhx
8c23d7e6a7 优化表单 2026-06-23 11:53:05 +08:00
zhx
57231bc95a 可配置 2026-06-22 20:59:41 +08:00
zhx
0789e02c6a 抽奖相关 2026-06-18 22:41:59 +08:00
zhx
3b21bd1133 增加概率 2026-06-18 20:26:00 +08:00
zhx
d876ff6985 转盘 2026-06-18 19:38:25 +08:00
zhx
743e4dc1c8 礼物显示 2026-06-18 18:48:29 +08:00
zhx
28051f2acd 经理相关 2026-06-18 18:04:28 +08:00
zhx
ec252cec2c 修复机器人创建和转盘 2026-06-18 17:13:59 +08:00
zhx
ee0a14d5b7 代理活动 2026-06-18 14:19:51 +08:00
zhx
9d930da859 机器人 2026-06-17 23:13:03 +08:00
zhx
4af2c311bf 机器人房 2026-06-17 12:52:33 +08:00
zhx
4d831bbdd6 修复多选 2026-06-17 12:24:05 +08:00
zhx
a9b0c93c1b 修复 2026-06-17 11:55:07 +08:00
zhx
723258a3aa 修复房间奖励 2026-06-17 10:41:50 +08:00
zhx
0be921409d 游戏机器人 2026-06-16 20:47:19 +08:00
zhx
97915b5980 充值弹窗 2026-06-16 17:58:35 +08:00
zhx
f5d06e1e52 相关活动 2026-06-16 16:59:40 +08:00
ZuoZuo
7c4bdf8d77 机器人房 2026-06-15 23:53:38 +08:00
zhx
e25258f233 支付和新app 2026-06-15 19:17:13 +08:00
zhx
64cfa472b8 工资相关 2026-06-15 10:43:53 +08:00
zhx
1447de8563 币商转账 2026-06-15 01:28:58 +08:00
zhx
aa7ce50e4f 表格工资相关 2026-06-15 00:37:46 +08:00
zhx
79da033dc9 主播工资 2026-06-14 23:41:21 +08:00
zhx
001eb0d4dc 用户列表流水 2026-06-13 15:14:46 +08:00
zhx
1beca72cb1 fix: remove lucky gift tier id copy 2026-06-12 23:33:57 +08:00
zhx
3900866df0 fix: auto generate lucky gift tier ids 2026-06-12 23:28:04 +08:00
zhx
282c9f1734 fix: prevent duplicate lucky gift tier ids 2026-06-12 23:22:45 +08:00
zhx
3983e355fb 1 2026-06-12 20:25:54 +08:00
zhx
ee4da04c61 黑色必胜 2026-06-12 20:12:52 +08:00
zhx
a8614398a4 邀请相关 2026-06-12 19:11:00 +08:00
zhx
fd8c5bbd78 fix(app-users): allow short id for pretty id grant 2026-06-12 15:50:22 +08:00
zhx
480b6e0825 口径统计更新 2026-06-12 12:07:15 +08:00
zhx
2ebacae7c2 修复报错 2026-06-12 11:20:02 +08:00
zhx
ac5d3a53d9 样式 2026-06-12 03:43:55 +08:00
zhx
5400f1dd57 修复databi 2026-06-12 03:39:36 +08:00
zhx
300d53a0cd databi 2026-06-12 03:14:57 +08:00
zhx
764da8affc Clarify robot account language label 2026-06-12 01:27:33 +08:00
zhx
087e379910 1 2026-06-12 00:34:45 +08:00
zhx
91498b7557 修改接口地址 2026-06-12 00:03:58 +08:00
zhx
4b16f93930 不写死 2026-06-11 21:29:37 +08:00
zhx
103b70f5e5 任务 2026-06-11 18:40:23 +08:00
zhx
7a6f0db859 邀请 2026-06-11 18:17:16 +08:00
zhx
53b74f9dc2 feat room rocket reward quantity 2026-06-11 18:07:57 +08:00
zhx
ebb4545ad6 礼物选择 2026-06-11 17:20:54 +08:00
zhx
7d6418e43c 新增功能 2026-06-11 17:11:04 +08:00
zhx
54fdc867fe 任务,用户头像显示相关 2026-06-11 13:33:30 +08:00
zhx
7b094f2b6f admin相关 2026-06-11 01:01:53 +08:00
zhx
e2d8d03260 修复游戏桥接bug 2026-06-10 10:57:22 +08:00
zhx
0b3589a701 开屏配置 2026-06-09 22:08:57 +08:00
zhx
e423adc738 修复mp4解析问题 2026-06-09 15:58:55 +08:00
zhx
7740d611b6 去掉游戏sha256 2026-06-09 15:13:03 +08:00
zhx
4e56597adb 批量重算 2026-06-09 13:53:21 +08:00
zhx
fcd44f8c13 批量重算 2026-06-09 13:41:26 +08:00
zhx
8a6e9dc18d 礼物布局 2026-06-09 13:14:11 +08:00
zhx
8175726223 增加新的游戏厂商 2026-06-09 11:03:11 +08:00
zhx
4ad96f07be CP增加解除费用 2026-06-08 19:40:13 +08:00
zhx
ae8ae7db55 fix: keep cp gift relation type 2026-06-08 17:35:25 +08:00
zhx
4520e16470 游戏jsbrdge 2026-06-08 17:15:58 +08:00
zhx
5bdbe33f8f cp相关 2026-06-08 14:27:25 +08:00
zhx
2f7e3bb86a cp相关 2026-06-08 14:12:05 +08:00
zhx
a7509e1f9a cp相关 2026-06-08 14:08:14 +08:00
zhx
4b4054a4d0 cp功能 2026-06-08 13:15:35 +08:00
zhx
66ffb3647c 比例 2026-06-08 01:20:00 +08:00
zhx
62d88d178f 完善数据大屏 2026-06-08 00:53:55 +08:00
zhx
e2c0565dbe 时区问题 2026-06-07 14:51:01 +08:00
zhx
2118a2ee4d 交互优化 2026-06-07 14:45:51 +08:00
zhx
fd80e18aff 活动相关配置 2026-06-06 13:42:26 +08:00
zhx
945e2e7047 火箭,bi,币商相关 2026-06-06 00:24:58 +08:00
zhx
cf062aab5d Merge branch 'test' 2026-06-05 20:31:02 +08:00
zhx
825fe47367 火箭重新修改 2026-06-05 19:53:39 +08:00
zhx
af9da06615 币商流水增加 2026-06-05 19:09:16 +08:00
zhx
cf83d334e7 bd leader 别名 2026-06-05 18:24:47 +08:00
zhx
dcf5ea2045 修改一下礼物相关列显示 2026-06-05 14:57:44 +08:00
zhx
39d7e527cb feat: update admin platform resource and user tables 2026-06-05 13:58:54 +08:00
zhx
a33474ec5f fix: remove gift heat from admin resources 2026-06-05 13:57:36 +08:00
zhx
58be420ae9 主播相关页面改动 2026-06-05 13:19:29 +08:00
zhx
0f92efea87 公会相关 2026-06-04 23:05:10 +08:00
zhx
ca194e46a3 数据大屏 2026-06-04 19:29:35 +08:00
zhx
a1c7803af8 过期跳转 2026-06-04 15:31:20 +08:00
zhx
e3ab3cba1e 批量上传和databi 2026-06-04 14:56:02 +08:00
zhx
d4eaf20c8e 添加相关功能 2026-06-04 11:04:48 +08:00
zhx
a0b141c31b 修改路由 2026-06-02 21:07:29 +08:00
zhx
0c65edfa6f 修改礼物不支持 2026-06-02 18:22:36 +08:00
zhx
c0c81cd97b 去掉礼物相关 2026-06-02 18:00:12 +08:00
zhx
333659f80c feat: expose game safe height 2026-06-02 17:43:03 +08:00
zhx
f7a401a183 feat: expose game safe height 2026-06-02 17:42:50 +08:00
zhx
79ed201975 去掉普通用户的钻石钱包 2026-06-02 17:24:22 +08:00
zhx
38a417a8bd 增加翻译 2026-06-02 16:57:02 +08:00
zhx
1a1c6ecc88 Merge branch 'main' into test 2026-06-02 15:54:57 +08:00
zhx
b8983e7b4e admin 增加批量上传 2026-06-02 15:54:00 +08:00
ZuoZuo
6ddf5ee211 feat: add databi dashboard 2026-06-01 01:51:39 +08:00
zhx
49f54e0463 政策相关 2026-05-29 19:56:45 +08:00
zhx
efaaef7e86 feat: update voice room red packet admin UI 2026-05-29 17:36:33 +08:00
zhx
c22b23cb8c feat: add report list page 2026-05-29 16:48:00 +08:00
zhx
b96fdb4f6a 修改配置接口 2026-05-29 13:08:11 +08:00
709 changed files with 140487 additions and 6324 deletions

11
.claude/launch.json Normal file
View 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
View File

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

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ node_modules/
dist/
coverage/
*.log
.waylog/

View File

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

View File

@ -65,7 +65,7 @@ pnpm build
- `FilterChip`
- `KpiCard`
- `SegmentControl`
- 当前共享后台组件统一放在 `src/shared/ui/`;资源组字段使用 `ResourceGroupSelectDrawer.jsx`,点击输入框打开右侧资源组图标抽屉,选择后自动回填。
- 当前共享后台组件统一放在 `src/shared/ui/`;资源组字段统一使用 `ResourceGroupSelectDrawer.jsx` 导出的 `ResourceGroupSelectField`,点击输入框打开右侧资源组图标抽屉,选择后自动回填,不在业务页面里手写资源组下拉或资源组 ID 输入框
- 布局组件放在 `src/components/layout/`
- `Header`
- `Sidebar`

File diff suppressed because it is too large Load Diff

12
databi/index.html Normal file
View 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
View 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
View 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";
}

View File

@ -0,0 +1,751 @@
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", logo_url: "https://media.example.com/lalu.png" },
{ app_code: "aslan", app_name: "Aslan", kind: "legacy", logo_url: "https://media.example.com/aslan.png" }
],
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).not.toHaveBeenCalled();
// App Hero 12,300 + 5,000 USD = $173
// App Lalu App $123 Top5
expect(screen.getAllByText("Lalu").length).toBeGreaterThan(0);
expect(document.querySelector('img[src="https://media.example.com/lalu.png"]')).toBeTruthy();
expect(document.querySelector('img[src="https://media.example.com/aslan.png"]')).toBeTruthy();
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();
});
await flushEffects();
expect(within(viewTabs).getByRole("tab", { name: "运营中心" })).toHaveAttribute("aria-selected", "true");
expect(fetchSocialBiKpi).toHaveBeenCalledTimes(1);
expect(fetchSocialBiKpi).toHaveBeenCalledWith(expect.objectContaining({ statTz: "Asia/Shanghai" }));
expect(screen.getAllByText("Omar").length).toBeGreaterThan(0);
expect(screen.getAllByText("$900").length).toBeGreaterThan(0);
});
test("filters operation KPI by person and shows totals with App and region amounts", async () => {
window.history.pushState(null, "", "/databi/social/?view=team&operator=9");
fetchSocialBiMaster.mockResolvedValue({
access: { all: true, scopes: [] },
apps: [
{ app_code: "aslan", app_name: "Aslan", kind: "legacy" },
{ app_code: "yumi", app_name: "Yumi", kind: "legacy" }
],
operators: [
{
account: "omar",
name: "Omar",
scopes: [{ app_code: "aslan", region_id: 11 }, { app_code: "yumi", region_id: 20 }],
team: "中东运营部",
team_id: 2,
user_id: 9
}
],
permissions: {},
regions: [
{ app_code: "aslan", region_id: 11, region_name: "中东大区" },
{ app_code: "yumi", region_id: 20, region_name: "南亚大区" }
]
});
fetchSocialBiOverview.mockResolvedValue({ access: { all: true, scopes: [] }, apps: [] });
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_recharge_usd_minor: 5000,
region_id: 11,
region_name: "中东大区",
team: "中东运营部"
},
{
app_code: "yumi",
app_name: "Yumi",
month_recharge_usd_minor: 50_000,
operator_account: "omar",
operator_name: "Omar",
operator_user_id: 9,
range_recharge_usd_minor: 3000,
region_id: 20,
region_name: "南亚大区",
team: "中东运营部"
}
],
operator_app_rows: [
{
app_code: "aslan",
app_name: "Aslan",
month_recharge_usd_minor: 90_000,
operator_account: "omar",
operator_name: "Omar",
operator_user_id: 9,
range_recharge_usd_minor: 5000,
region_count: 1,
region_names: "中东大区",
team: "中东运营部"
},
{
app_code: "yumi",
app_name: "Yumi",
month_recharge_usd_minor: 50_000,
operator_account: "omar",
operator_name: "Omar",
operator_user_id: 9,
range_recharge_usd_minor: 3000,
region_count: 1,
region_names: "南亚大区",
team: "中东运营部"
}
],
period_month: "2026-06",
summary: { month_recharge_usd_minor: 140_000, operator_count: 1, range_recharge_usd_minor: 8000 }
});
render(<DatabiApp />);
await flushEffects();
expect(fetchSocialBiKpi).toHaveBeenCalledWith(expect.objectContaining({ operatorUserId: 9 }));
expect(screen.getByRole("combobox", { name: "运营人员筛选" })).toHaveValue("Omar");
expect(window.location.search).toContain("operator=9");
const summary = screen.getByLabelText("充值金额汇总");
expect(within(summary).getByText("Omar")).toBeTruthy();
expect(within(summary).getByText("$80")).toBeTruthy();
expect(within(summary).getByText("$1,400")).toBeTruthy();
expect(screen.getByRole("cell", { name: "中东大区" })).toBeTruthy();
expect(screen.getByRole("cell", { name: "南亚大区" })).toBeTruthy();
await act(async () => {
screen.getByRole("radio", { name: "按人员×App" }).click();
});
expect(screen.getByRole("cell", { name: "中东大区" })).toBeTruthy();
expect(screen.getByRole("cell", { name: "南亚大区" })).toBeTruthy();
expect(screen.getAllByText("$50").length).toBeGreaterThan(0);
expect(screen.getAllByText("$30").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&regions=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();
expect(fetchSocialBiOverview).toHaveBeenCalledWith(
expect.objectContaining({ appCodes: ["lalu"], regionIds: [9, 11], statTz: "Asia/Shanghai" })
);
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("groups social BI wide-table daily rows by app instead of interleaving by date", async () => {
window.history.pushState(null, "", "/databi/social/?view=table");
fetchSocialBiMaster.mockResolvedValue({
access: { all: true, scopes: [] },
apps: [
// logoAppIdentity textContent App
{ app_code: "lalu", app_name: "Lalu", kind: "hyapp", logo_url: "https://media.example.com/lalu.png" },
{ app_code: "aslan", app_name: "Aslan", kind: "legacy", logo_url: "https://media.example.com/aslan.png" }
],
operators: [],
permissions: {},
regions: []
});
const dailyRow = (statDay, rechargeUsdMinor) => ({ recharge_usd_minor: rechargeUsdMinor, recharge_users: 1, stat_day: statDay });
fetchSocialBiOverview.mockResolvedValue({
access: { all: true, scopes: [] },
apps: [
{
app_code: "lalu",
app_name: "Lalu",
country_breakdown: [],
daily_region_breakdown: [],
daily_series: [dailyRow("2026-06-04", 100), dailyRow("2026-06-05", 200)],
kind: "hyapp",
region_breakdown: [],
total: { recharge_usd_minor: 300, recharge_users: 2 }
},
{
// Aslan Lalu + App 穿
// master Lalu
app_code: "aslan",
app_name: "Aslan",
country_breakdown: [],
daily_region_breakdown: [],
daily_series: [dailyRow("2026-06-04", 900), dailyRow("2026-06-05", 800)],
kind: "legacy",
region_breakdown: [],
total: { recharge_usd_minor: 1700, recharge_users: 2 }
}
]
});
render(<DatabiApp />);
await flushEffects();
const table = document.querySelector(".sbi-dt-table");
expect(table).toBeTruthy();
const headerCells = [...table.querySelectorAll("thead tr:first-child th")].map((cell) => cell.textContent.trim());
expect(headerCells.slice(0, 2)).toEqual(["App", "日期"]);
// / App App 穿
const bodyRows = [...table.querySelectorAll("tbody tr")];
const dimCells = bodyRows.map((row) => {
const cells = row.querySelectorAll("td");
return [cells[0].textContent.trim(), cells[1].textContent.trim()];
});
expect(dimCells).toEqual([
["全部", "汇总"],
["平均值", "--"],
["Lalu", "2026-06-05"],
["Lalu", "2026-06-04"],
["Aslan", "2026-06-05"],
["Aslan", "2026-06-04"]
]);
// 线
expect(bodyRows[4].classList.contains("sbi-dt-group-start")).toBe(true);
expect(bodyRows[3].classList.contains("sbi-dt-group-start")).toBe(false);
});
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();
}
});
}

501
databi/src/api.js Normal file
View File

@ -0,0 +1,501 @@
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, regionIds, 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);
} else if (regionIds?.length) {
query.region_ids = regionIds.join(",");
}
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
};
}

184
databi/src/api.test.js Normal file
View File

@ -0,0 +1,184 @@
import { afterEach, expect, test, vi } from "vitest";
import {
fetchFilterOptions,
fetchSocialBiFilterOptions,
fetchSocialBiKpi,
fetchSocialBiOverview,
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("loads social BI overview with an explicit multi-region Finance scope", async () => {
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ apps: [] })));
await fetchSocialBiOverview({
appCodes: ["aslan"],
endMs: 20,
regionIds: [9, 11],
startMs: 10,
statTz: "Asia/Shanghai"
});
const requestURL = new URL(String(fetch.mock.calls[0][0]));
expect(requestURL.pathname).toBe("/api/v1/admin/databi/social/overview");
expect(requestURL.searchParams.get("app_codes")).toBe("aslan");
expect(requestURL.searchParams.get("region_ids")).toBe("9,11");
});
test("loads social BI KPI for the selected operation user", async () => {
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ items: [], summary: {} })));
await fetchSocialBiKpi({
appCodes: ["aslan", "yumi"],
endMs: 20,
operatorUserId: 9,
startMs: 10,
statTz: "Asia/Shanghai"
});
const requestURL = new URL(String(fetch.mock.calls[0][0]));
expect(requestURL.pathname).toBe("/api/v1/admin/databi/social/kpi");
expect(requestURL.searchParams.get("app_codes")).toBe("aslan,yumi");
expect(requestURL.searchParams.get("operator_user_id")).toBe("9");
});
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
});
}

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

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

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

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

View File

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

View File

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

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

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

View File

@ -0,0 +1,69 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test } from "vitest";
import { CountryPerformanceTable } from "./CountryPerformanceTable.jsx";
const countryItems = [
createCountryRow({
country: "阿富汗",
flag: "🇦🇫",
recharge_usd_minor: 50000,
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];
}

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

View File

@ -0,0 +1,438 @@
import { MetricCard } from "./MetricCard.jsx";
import { Panel } from "./Panel.jsx";
import { formatCoin, formatNumber, formatPercent } from "../utils/format.js";
import { numberValue } from "../utils/number.js";
const gameOptions = [
{ label: "全部", value: "all" },
{ label: "Dice", value: "dice" },
{ label: "Rock", value: "rock" }
];
const eventLabels = {
page_open: "页面打开",
config_load_success: "配置成功",
config_load_failed: "配置失败",
gesture_select: "选择手势",
match_api: "匹配接口",
match_cancel: "取消匹配",
stake_selected: "选择档位",
stake_select: "选择档位",
match_click: "点击匹配",
match_success: "匹配成功",
animation_start: "进入开奖",
preload_complete: "预加载完成",
reveal_enter: "进入开奖",
roll_api: "开奖接口",
settlement_show: "结算展示",
play_again: "再来一局",
play_again_click: "再来一局",
exit_click: "退出"
};
export function SelfGameScreen({ gameId = "all", loading = false, onGameChange, overview }) {
const model = buildSelfGameModel(overview);
const showSkeleton = loading && !overview;
return (
<section className="self-game-screen" aria-label="自研游戏统计">
<div className="self-game-toolbar">
<div className="self-game-title">
<strong>自研游戏</strong>
<span>Dice / Rock H5 聚合统计</span>
</div>
<div className="game-switch" role="tablist" aria-label="游戏筛选">
{gameOptions.map((item) => (
<button className={gameId === item.value ? "is-active" : ""} key={item.value} onClick={() => onGameChange?.(item.value)} type="button">
{item.label}
</button>
))}
</div>
</div>
<section className="self-game-kpi-grid" aria-label="自研游戏核心指标">
{model.kpis.map((item) => (
<MetricCard key={item.label} item={item} loading={showSkeleton} />
))}
</section>
<section className="self-game-panel-grid self-game-panel-grid--top">
<FunnelTable funnel={model.funnel} loading={showSkeleton} />
<StakeTable items={model.stakes} loading={showSkeleton} />
<PoolTable items={model.pools} loading={showSkeleton} />
</section>
<section className="self-game-panel-grid">
<ParticipantTable items={model.participants} loading={showSkeleton} />
<WinLossTable summary={model.winLoss} loading={showSkeleton} />
<RiskTable items={model.risks} loading={showSkeleton} />
</section>
<section className="self-game-panel-grid self-game-panel-grid--single">
<MetricDefinitionList items={model.definitions} loading={showSkeleton} />
</section>
</section>
);
}
function buildSelfGameModel(overview) {
const events = eventIndex(overview?.events);
const totals = overview?.match_totals || {};
const retention = overview?.retention || {};
const totalStake = numberValue(totals.user_stake_coin) + numberValue(totals.robot_stake_coin);
const participants = Array.isArray(overview?.participant_distribution) ? overview.participant_distribution : [];
const pools = Array.isArray(overview?.pool_stats) ? overview.pool_stats : [];
const userResult = realUserResultCounts(participants, totals);
const userNetWin = numberValue(totals.payout_coin) + numberValue(totals.refund_coin) - numberValue(totals.user_stake_coin);
const poolBalance = latestPoolBalance(pools);
const settlementEvent = events.get("settlement_show");
const matchClickEvent = events.get("match_click");
const matchSuccessEvent = events.get("match_success");
const pageOpenEvent = events.get("page_open");
const winLoss = {
countNote: userResult.countNote,
drawCount: userResult.draw,
loserCount: userResult.lose,
payoutCoin: numberValue(totals.payout_coin),
refundCoin: numberValue(totals.refund_coin),
userNetWin,
userStakeCoin: numberValue(totals.user_stake_coin),
winnerCount: userResult.win
};
return {
kpis: [
metric("游戏 PV / UV", `${formatNumber(pageOpenEvent.event_count)} / ${formatNumber(pageOpenEvent.user_count)}`, "进入 dice、rock H5 的次数和人数"),
metric("开局人数", `点击 ${formatNumber(matchClickEvent.user_count)} / 成功 ${formatNumber(matchSuccessEvent.user_count)}`, `${formatNumber(matchClickEvent.event_count)} 次点击`),
metric("创建局", formatNumber(totals.created_matches), "服务端创建的对局数"),
metric("匹配成功局", formatNumber(totals.matched_matches), `${formatPercent(overview?.conversion?.match_click_to_success_rate)} 点击后成功`),
metric("结算完成 UV", formatNumber(settlementEvent.user_count), `${formatPercent(overview?.conversion?.page_to_settlement_rate)} 页面到结算`),
metric("已结算局", formatNumber(totals.settled_matches), "已经完成派奖或退款的局"),
metric("取消 / 失败局", `${formatNumber(totals.canceled_matches)} / ${formatNumber(totals.failed_matches)}`, `${formatPercent(totals.cancel_rate)} / ${formatPercent(totals.fail_rate)}`),
metric("参与人次", formatNumber(numberValue(totals.human_participants) + numberValue(totals.robot_participants)), `真人 ${formatNumber(totals.human_participants)} / 机器人 ${formatNumber(totals.robot_participants)}`),
metric("总下注金币", formatCoin(totalStake), `真人 ${formatCoin(totals.user_stake_coin)} / 机器人 ${formatCoin(totals.robot_stake_coin)}`),
metric("派奖金额", formatCoin(totals.payout_coin), `退款 ${formatCoin(totals.refund_coin)}`),
metric("平台抽水", formatCoin(totals.platform_profit_coin), formatPercent(totals.platform_profit_rate)),
metric("奖池变化", formatCoin(totals.pool_delta_coin), `余额 ${formatCoin(poolBalance)} / 强制 ${formatNumber(totals.forced_player_lose_matches)}`),
metric("用户输赢", `${formatNumber(winLoss.winnerCount)} / ${formatNumber(winLoss.loserCount)} / ${formatNumber(winLoss.drawCount)}`, `真实用户净输赢 ${formatCoin(winLoss.userNetWin)}`),
metric("次日 / 7日留存", `${formatPercent(retention.day1_rate)} / ${formatPercent(retention.day7_rate)}`, `样本 ${formatNumber(retention.cohort_users)}`)
],
definitions: overview?.metric_definitions || [],
funnel: overview?.funnel || [],
participants,
pools,
risks: overview?.user_risk || [],
stakes: overview?.stake_breakdown || [],
winLoss
};
}
function eventIndex(items) {
const index = new Map();
(Array.isArray(items) ? items : []).forEach((item) => {
const name = item.event_name || "";
const current = index.get(name) || emptyEvent();
index.set(name, {
event_count: numberValue(current.event_count) + numberValue(item.event_count),
user_count: numberValue(current.user_count) + numberValue(item.user_count)
});
});
return {
get(name) {
return index.get(name) || emptyEvent();
}
};
}
function emptyEvent() {
return { event_count: 0, user_count: 0 };
}
function metric(label, value, caption) {
return { caption, label, value };
}
function FunnelTable({ funnel, loading }) {
return (
<Panel className="panel--self-game" title="转化漏斗">
<DataTable
emptyText="暂无漏斗数据"
loading={loading}
rows={funnel}
columns={[
{ key: "event_name", label: "节点", render: (row) => eventLabels[row.event_name] || row.event_name || "--" },
{ key: "user_count", label: "UV", render: (row) => formatNumber(row.user_count) },
{ key: "event_count", label: "次数", render: (row) => formatNumber(row.event_count) }
]}
/>
</Panel>
);
}
function StakeTable({ items, loading }) {
return (
<Panel className="panel--self-game" title="下注档位">
<DataTable
emptyText="暂无档位数据"
loading={loading}
rows={items}
wide
columns={[
{ key: "stake_coin", label: "档位", render: (row) => formatCoin(row.stake_coin) },
{ key: "created_matches", label: "创建局", render: (row) => formatNumber(row.created_matches) },
{ key: "matched_matches", label: "匹配局", render: (row) => formatNumber(row.matched_matches) },
{ key: "settled_matches", label: "结算局", render: (row) => formatNumber(row.settled_matches) },
{ key: "user_stake_coin", label: "真人下注", render: (row) => formatCoin(row.user_stake_coin) },
{ key: "robot_stake_coin", label: "机器人下注", render: (row) => formatCoin(row.robot_stake_coin) },
{ key: "stake", label: "总下注", render: (row) => formatCoin(numberValue(row.user_stake_coin) + numberValue(row.robot_stake_coin)) },
{ key: "payout_coin", label: "派奖", render: (row) => formatCoin(row.payout_coin) },
{ key: "profit", label: "盈利", render: (row) => formatCoin(row.platform_profit_coin) }
]}
/>
</Panel>
);
}
function PoolTable({ items, loading }) {
return (
<Panel className="panel--self-game" title="奖池流水">
<DataTable
emptyText="暂无奖池流水"
loading={loading}
rows={items}
columns={[
{ key: "direction", label: "方向", render: (row) => directionLabel(row.direction) },
{ key: "reason", label: "原因", render: (row) => row.reason || "--" },
{ key: "flow", label: "入 / 出", render: (row) => `${formatCoin(row.in_coin)} / ${formatCoin(row.out_coin)}` },
{ key: "balance_after_coin", label: "余额", render: (row) => formatCoin(row.balance_after_coin) }
]}
/>
</Panel>
);
}
function ParticipantTable({ items, loading }) {
return (
<Panel className="panel--self-game" title="玩法分布">
<DataTable
emptyText="暂无玩法数据"
loading={loading}
rows={items}
wide
columns={[
{ key: "stake_coin", label: "档位", render: (row) => formatCoin(row.stake_coin) },
{ key: "type", label: "对象", render: (row) => participantLabel(row.participant_type) },
{ key: "play", label: "手势 / 点数", render: (row) => playLabel(row) },
{ key: "result", label: "结果", render: (row) => resultLabel(row.result) },
{ key: "count", label: "人次", render: (row) => formatNumber(row.participant_count) },
{ key: "net_win_coin", label: "净输赢", render: (row) => formatCoin(row.net_win_coin) },
{ key: "win_rate", label: "胜率", render: (row) => formatPercent(row.win_rate) }
]}
/>
</Panel>
);
}
function WinLossTable({ loading, summary }) {
const rows = [
{ metric: "真实用户赢家数", value: summary.winnerCount, note: summary.countNote },
{ metric: "真实用户输家数", value: summary.loserCount, note: summary.countNote },
{ metric: "真实用户平局数", value: summary.drawCount, note: summary.countNote },
{ metric: "真实用户下注", value: summary.userStakeCoin, note: "match_totals.user_stake_coin", type: "coin" },
{ metric: "真实用户派奖", value: summary.payoutCoin, note: "match_totals.payout_coin", type: "coin" },
{ metric: "真实用户退款", value: summary.refundCoin, note: "match_totals.refund_coin", type: "coin" },
{ metric: "真实用户净输赢", value: summary.userNetWin, note: "派奖 + 退款 - 真实用户下注", type: "coin" }
];
return (
<Panel className="panel--self-game" title="用户输赢">
<DataTable
emptyText="暂无输赢数据"
loading={loading}
rows={rows}
columns={[
{ key: "metric", label: "指标", render: (row) => row.metric },
{ key: "value", label: "数值", render: (row) => (row.type === "coin" ? formatCoin(row.value) : formatNumber(row.value)) },
{ key: "note", label: "口径", render: (row) => row.note }
]}
/>
</Panel>
);
}
function RiskTable({ items, loading }) {
return (
<Panel className="panel--self-game" title="高风险用户">
<DataTable
emptyText="暂无风险用户"
loading={loading}
rows={items}
columns={[
{ key: "user_id", label: "用户", render: (row) => <RiskUserCell row={row} /> },
{ key: "match_count", label: "局数", render: (row) => formatNumber(row.match_count) },
{ key: "win_rate", label: "胜率", render: (row) => formatPercent(row.win_rate) },
{ key: "net_win_coin", label: "净赢", render: (row) => formatCoin(row.net_win_coin) },
{ key: "cancel_rate", label: "取消率", render: (row) => formatPercent(row.cancel_rate) }
]}
/>
</Panel>
);
}
function RiskUserCell({ row }) {
const nickname = row.nickname || row.username || "";
const shortID = row.short_id || row.display_user_id || "";
const userID = row.user_id ? String(row.user_id) : "";
const avatar = row.avatar || "";
const primary = nickname || shortID || userID || "--";
const secondary = shortID ? `ID ${shortID}` : userID ? `UID ${userID}` : "";
return (
<div className="risk-user-cell">
{avatar ? <img alt="" className="risk-user-avatar" src={avatar} /> : <span className="risk-user-avatar risk-user-avatar--empty">{primary.slice(0, 1).toUpperCase()}</span>}
<span className="risk-user-copy">
<strong>{primary}</strong>
{secondary ? <small>{secondary}</small> : null}
</span>
</div>
);
}
function MetricDefinitionList({ items, loading }) {
return (
<Panel className="panel--self-game" title="口径">
{loading ? <div className="panel-empty">加载中...</div> : null}
{!loading && !items.length ? <div className="panel-empty">暂无口径说明</div> : null}
{!loading && items.length ? (
<div className="definition-list">
{items.map((item) => (
<div className="definition-row" key={item.metric}>
<strong>{item.metric}</strong>
<span>{item.definition}</span>
</div>
))}
</div>
) : null}
</Panel>
);
}
function DataTable({ columns, emptyText, loading, rows, wide = false }) {
if (loading) {
return <div className="panel-empty">加载中...</div>;
}
if (!rows.length) {
return <div className="panel-empty">{emptyText}</div>;
}
return (
<div className="self-game-table-wrap">
<table className={["self-game-table", wide ? "self-game-table--wide" : ""].filter(Boolean).join(" ")}>
<thead>
<tr>
{columns.map((column) => (
<th key={column.key}>{column.label}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => (
<tr key={rowKey(row, rowIndex)}>
{columns.map((column) => (
<td key={column.key}>{column.render(row)}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
function rowKey(row, rowIndex) {
return [row.game_id, row.user_id, row.metric, row.stake_coin, row.event_name, row.reason, row.participant_type, row.result, row.rps_gesture, row.dice_point, rowIndex]
.filter((item) => item !== undefined && item !== null && item !== "")
.join(":");
}
function realUserResultCounts(items, totals) {
const result = { countNote: "按 participant_distribution 中真实用户结果统计", draw: 0, lose: 0, win: 0 };
let userRows = 0;
items.forEach((item) => {
if (!isRealUserParticipant(item.participant_type)) {
return;
}
userRows += 1;
const count = numberValue(item.participant_count);
if (item.result === "win") {
result.win += count;
} else if (item.result === "lose") {
result.lose += count;
} else if (item.result === "draw") {
result.draw += count;
}
});
if (userRows > 0) {
return result;
}
return {
countNote: "缺少真实用户分布时回退 match_totals 参与人结果",
draw: numberValue(totals.draw_count),
lose: numberValue(totals.loser_count),
win: numberValue(totals.winner_count)
};
}
function isRealUserParticipant(value) {
return value === "user" || value === "human";
}
function latestPoolBalance(items) {
if (!items.length) {
return 0;
}
return items.reduce((balance, item) => {
const next = numberValue(item.balance_after_coin);
return next !== 0 ? next : balance;
}, 0);
}
function directionLabel(value) {
if (value === "in") {
return "入池";
}
if (value === "out") {
return "出池";
}
return value || "--";
}
function participantLabel(value) {
if (value === "robot") {
return "机器人";
}
if (isRealUserParticipant(value)) {
return "真人";
}
return value || "--";
}
function resultLabel(value) {
if (value === "win") {
return "胜";
}
if (value === "lose") {
return "负";
}
if (value === "draw") {
return "平";
}
return value || "--";
}
function playLabel(row) {
if (row.rps_gesture) {
return row.rps_gesture;
}
if (numberValue(row.dice_point) > 0) {
return `${row.dice_point}`;
}
return "--";
}

View File

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

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

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

File diff suppressed because one or more lines are too long

12
databi/src/main.jsx Normal file
View 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>
);

View File

@ -0,0 +1,429 @@
// 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 Autocomplete from "@mui/material/Autocomplete";
import TextField from "@mui/material/TextField";
import {
ALL,
DATE_PRESETS,
GRANULARITIES,
createDefaultFilters,
rangeLabel,
readStateFromURL,
toggleMultiValue,
writeStateToURL
} from "./state.js";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { AppIdentity } from "@/shared/ui/AppIdentity.jsx";
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);
const EMPTY_OPERATORS = [];
export function useSocialBi() {
const context = useContext(SocialBiContext);
if (!context) {
throw new Error("useSocialBi must be used inside SocialBiApp");
}
return context;
}
// Social BI app_code/app_nameLogo master app_code 使
export function SocialAppIdentity({ app, appCode, appName, className = "", size = "small" }) {
const context = useSocialBi();
const resolvedCode = String(app?.app_code || app?.appCode || appCode || "").trim().toLowerCase();
const catalogApp = (context.master?.apps || []).find((item) => String(item.app_code || item.appCode || "").trim().toLowerCase() === resolvedCode);
return <AppIdentity app={catalogApp || app} appCode={resolvedCode} appName={appName} className={className} size={size} />;
}
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",
includeKpi: viewKey === "team"
});
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} />
{viewKey === "team" ? <OperatorSelect data={data} filters={filters} updateFilter={updateFilter} /> : null}
<span className="sbi-range-label">{rangeLabel(data.range)}</span>
{data.isLoading ? <span className="sbi-loading-dot" aria-label="加载中" /> : null}
</div>
</header>
);
}
function operatorIdentityLabel(operator) {
if (String(operator?.user_id) === ALL) {
return "全部运营人员";
}
return operator?.name || operator?.account || `#${operator?.user_id}`;
}
function filterOperatorOptions(options, { inputValue }) {
const keyword = String(inputValue || "").trim().toLowerCase();
if (!keyword) {
return options;
}
return options.filter((operator) =>
[operatorIdentityLabel(operator), operator.account, operator.team, operator.user_id]
.filter(Boolean)
.join(" ")
.toLowerCase()
.includes(keyword)
);
}
function OperatorSelect({ data, filters, updateFilter }) {
const operators = data.master?.operators || EMPTY_OPERATORS;
const options = useMemo(() => [{ name: "全部运营人员", user_id: ALL }, ...operators], [operators]);
const selected = options.find((operator) => String(operator.user_id) === String(filters.operator)) || options[0];
useEffect(() => {
if (!data.master || filters.operator === ALL) {
return;
}
// ID 退
if (!operators.some((operator) => String(operator.user_id) === String(filters.operator))) {
updateFilter("operator", ALL);
}
}, [data.master, filters.operator, operators, updateFilter]);
return (
<Autocomplete
className="sbi-operator-filter"
disableClearable
disablePortal
filterOptions={filterOperatorOptions}
getOptionLabel={operatorIdentityLabel}
isOptionEqualToValue={(option, value) => String(option.user_id) === String(value.user_id)}
noOptionsText="无匹配运营人员"
options={options}
size="small"
value={selected}
onChange={(_, operator) => updateFilter("operator", String(operator?.user_id || ALL))}
renderOption={(props, operator) => {
const { key, ...optionProps } = props;
const details = [operator.account, operator.team].filter(Boolean).join(" · ");
return (
<li {...optionProps} className={`${optionProps.className || ""} sbi-operator-option`} key={key || operator.user_id}>
<strong>{operatorIdentityLabel(operator)}</strong>
{details ? <small>{details}</small> : null}
</li>
);
}}
renderInput={(params) => (
<TextField
{...params}
label="运营人员"
slotProps={{
...params.slotProps,
htmlInput: {
...params.slotProps?.htmlInput,
"aria-label": "运营人员筛选"
}
}}
/>
)}
/>
);
}
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"
>
<AppIdentity app={app} size="small" />
</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"
>
<AppIdentity app={group.app} size="small" />
<span>全部区域</span>
</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
View 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);
}

View File

@ -0,0 +1,297 @@
// 社交 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: "Finance 口径成功充值合计(谷歌 + 非谷歌三方 + 币商USD",
type: "money"
},
new_user_recharge_usd_minor: { label: "新用户充值", tooltip: "Finance 暂无新用户充值拆分,当前不展示", type: "money" },
google_recharge_usd_minor: { label: "谷歌充值", tooltip: "Finance 口径 Google 成功充值", type: "money" },
mifapay_recharge_usd_minor: { label: "三方充值", tooltip: "Finance 口径非 Google 三方成功充值", type: "money" },
coin_seller_recharge_usd_minor: {
label: "币商充值",
tooltip: "Finance 口径已核验币商充值订单 + H5 币商身份成功充值",
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: "Finance 充值没有同口径活跃人数,当前不展示", type: "money" },
arppu_usd_minor: { label: "ARPPU ($)", tooltip: "Finance 币商/H5 充值没有同口径付费人数,当前不展示", type: "money" },
paid_conversion_rate: { label: "付费转化率", tooltip: "付费用户 / 日活", type: "ratio" },
recharge_conversion_rate: { label: "充值转化率", tooltip: "充值用户 / 日活", type: "ratio" },
d1_retention_rate: { label: "次日留存", tooltip: "当日活跃的前 1 日注册用户 / 前 1 日注册数(观察日回看口径)", type: "ratio" },
d7_retention_rate: { label: "7日留存", tooltip: "当日活跃的前 7 日注册用户 / 前 7 日注册数(观察日回看口径)", type: "ratio" },
d30_retention_rate: { label: "30日留存", tooltip: "当日活跃的前 30 日注册用户 / 前 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 = {};
// Finance 币商/H5 金额没有与旧 paid_users 一致的去重人数;标记需跨聚合传播,
// 避免前端把精确金额再次除以旧人数,重新制造混合口径 ARPU/ARPPU。
const financeRechargeAligned = rows.some((row) => row?.finance_recharge_aligned === true);
out.finance_recharge_aligned = financeRechargeAligned;
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 = !financeRechargeAligned && recharge !== null && active ? Math.round(recharge / active) : null;
out.arppu_usd_minor = !financeRechargeAligned && 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];
}

View File

@ -0,0 +1,22 @@
import { expect, test } from "vitest";
import { aggregateMetricMaps, metricTooltip } from "./metrics.js";
test("describes recharge metrics with the Finance source of truth", () => {
expect(metricTooltip("recharge_usd_minor")).toContain("Finance 口径");
expect(metricTooltip("google_recharge_usd_minor")).toContain("Google 成功充值");
expect(metricTooltip("mifapay_recharge_usd_minor")).toContain("非 Google 三方成功充值");
expect(metricTooltip("coin_seller_recharge_usd_minor")).toContain("已核验币商充值订单 + H5 币商身份成功充值");
expect(metricTooltip("arppu_usd_minor")).toContain("当前不展示");
});
test("keeps ARPU and ARPPU unavailable after aggregating Finance-aligned recharge", () => {
const metrics = aggregateMetricMaps([
{ active_users: 10, finance_recharge_aligned: true, paid_users: 2, recharge_usd_minor: 600 },
{ active_users: 5, finance_recharge_aligned: true, paid_users: 1, recharge_usd_minor: 300 }
]);
expect(metrics.recharge_usd_minor).toBe(900);
expect(metrics.finance_recharge_aligned).toBe(true);
expect(metrics.arpu_usd_minor).toBeNull();
expect(metrics.arppu_usd_minor).toBeNull();
});

213
databi/src/social/state.js Normal file
View File

@ -0,0 +1,213 @@
// 社交 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",
operator: ALL,
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", "operator", "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 (/^\d+$/.test(params.get("operator") || "")) {
filters.operator = params.get("operator");
}
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.operator !== ALL) {
params.set("operator", filters.operator);
}
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;
}

View File

@ -0,0 +1,463 @@
// 社交 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, rangeExclusiveEndMs, 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, includeKpi = 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]);
// 运营人员筛选只接受 master 目录中的正整数 IDURL 中的脏值不能直接进入后端统计查询。
const operatorUserId = useMemo(() => {
const value = String(filters.operator || "");
return /^\d+$/.test(value) ? Number(value) : undefined;
}, [filters.operator]);
const overviewRequestScopes = useMemo(
() => buildOverviewRequestScopes(appCodes, filters.regions, master?.regions || []),
[appCodes, filters.regions, master?.regions]
);
useEffect(() => {
if (!isMasterSettled) {
return;
}
const sequence = ++requestSeq.current;
const startMs = rangeStartMs(range, SOCIAL_BI_TZ);
const endMs = rangeExclusiveEndMs(range, SOCIAL_BI_TZ);
setIsLoading(true);
// 漏斗会执行 cohort 聚合,只在用户进入漏斗视图时请求;其他视图不能为不可见数据承担查询成本或错误提示。
const funnelRequest = includeFunnel
? fetchSocialBiFunnel({ appCodes: funnelAppCodes, endMs, startMs, statTz: SOCIAL_BI_TZ })
: Promise.resolve(null);
// KPI 会执行当前区间和自然月两组负责范围聚合,只在运营中心可见时请求,避免其他视图承担额外查询成本。
const kpiRequest = includeKpi
? fetchSocialBiKpi({ appCodes, endMs, operatorUserId, startMs, statTz: SOCIAL_BI_TZ })
: Promise.resolve(null);
// 默认全量只请求一次;用户显式选择区域时按 App 下发已选区域,让后端只补这些区域的 Finance 逐日渠道。
const overviewRequest = overviewRequestScopes.length
? Promise.all(
overviewRequestScopes.map((scope) =>
fetchSocialBiOverview({
...scope,
endMs,
startMs,
statTz: SOCIAL_BI_TZ
})
)
).then(mergeOverviewResponses)
: Promise.resolve({ access: master?.access || null, apps: [] });
Promise.allSettled([overviewRequest, funnelRequest, kpiRequest]).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 (!includeKpi) {
setKpi(null);
} else 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, includeKpi, isMasterSettled, master?.access, operatorUserId, overviewRequestScopes, 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 = rangeExclusiveEndMs(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/appDailyApp 粒度、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 buildOverviewRequestScopes(appCodes, regions, masterRegions) {
const selectedApps = new Set((appCodes || []).filter(Boolean));
if (!regions?.length || regions.includes(ALL)) {
return [{ appCodes: [...selectedApps] }];
}
const configuredByApp = new Map();
(masterRegions || []).forEach((region) => {
const appCode = String(region?.app_code || "")
.trim()
.toLowerCase();
const regionID = Number(region?.region_id);
if (!appCode || !Number.isFinite(regionID) || regionID <= 0) {
return;
}
if (!configuredByApp.has(appCode)) {
configuredByApp.set(appCode, new Set());
}
configuredByApp.get(appCode).add(regionID);
});
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, new Set(configuredByApp.get(appCode) || []));
}
return;
}
const [rawAppCode, rawRegionID] = value.split(":");
const appCode = String(rawAppCode || "")
.trim()
.toLowerCase();
const regionID = Number(rawRegionID);
if (!appCode || !Number.isFinite(regionID) || regionID <= 0) {
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;
}
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 });
} else {
// 区域目录暂不可用时仍限制到该 App后端会按权限返回可见范围。
scopes.push({ appCodes: [appCode] });
}
});
return scopes;
}
function mergeOverviewResponses(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;
}
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;
}

File diff suppressed because it is too large Load Diff

View 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 { SocialAppIdentity, 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) => (
<SocialAppIdentity appCode={app.app_code} appName={app.app_name} key={app.app_code} />
))}
</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);
}

View File

@ -0,0 +1,487 @@
// OverviewView Hero KPIApp 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 { SocialAppIdentity, 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}&nbsp;&nbsp;<b>${formatChartAmount(valueMetric, numeric)}</b>`;
});
const header = list[0]?.axisValueLabel || list[0]?.name || "";
if (list.length > 1) {
lines.push(`合计&nbsp;&nbsp;<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">
<SocialAppIdentity appCode={row.app_code} appName={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">
<SocialAppIdentity appCode={row.app_code} appName={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>
);
}

View 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, formatMetric, metricChartValue, metricLabel, metricTooltip } from "../metrics.js";
import { SocialAppIdentity, 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 { 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_confighyapp 在区域管理</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">
<SocialAppIdentity appCode={row.app_code} appName={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">
<SocialAppIdentity appCode={row.app_code} appName={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">
<SocialAppIdentity appCode={child.app_code} appName={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>
</>
);
}

View File

@ -0,0 +1,403 @@
// RetentionView D1/D7/D30 ×
// / AppApp/ SocialBiApp.jsx useSocialBi()
import { useMemo, useState } from "react";
import { SocialAppIdentity, 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,
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) ? "" : ` · 回看基数 ${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"
>
<SocialAppIdentity appCode={row.app_code} appName={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">
<SocialAppIdentity appCode={item.row.app_code} appName={item.row.app_name} />
{tableDim === "region" ? <span> · {item.row.region_name}</span> : null}
</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">
留存按观察日回看计算某日的 N 日留存 = 该日往前推 N 天注册的用户中当日仍活跃的比例区间合计 =
各观察日留存人数之和 / 各观察日 cohort 基数之和cohort 日无注册或数据未接入时显示 "--"
</p>
</div>
);
}

View File

@ -0,0 +1,357 @@
// TeamKpiView
// USD "--" 0
import { useMemo, useState } from "react";
import { SocialAppIdentity, useSocialBi } from "../SocialBiApp.jsx";
import { formatCount, formatMoneyMinor, isBlank } from "../format.js";
import { ALL } from "../state.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 { filters, isLoading, kpi, master } = 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 (isLoading && !kpi) {
return <KpiSkeleton />;
}
const summary = kpi?.summary || {};
const operatorCount = isBlank(summary.operator_count) ? operatorCountOf(items) : summary.operator_count;
const selectedOperator = filters.operator === ALL
? null
: (master?.operators || []).find((operator) => String(operator.user_id) === String(filters.operator));
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>
<KpiSummary operator={selectedOperator} operatorCount={operatorCount} summary={summary} />
<div className="sbi-table-scroll sbi-kpi-board-scroll">
{dimension === "person" ? (
<PersonTable rows={personRows} />
) : dimension === "operatorApp" ? (
<OperatorAppTable rows={operatorAppRows} />
) : (
<TeamTable rows={teamRows} />
)}
</div>
</section>
</div>
);
}
function KpiSummary({ operator, operatorCount, summary }) {
const details = operator
? [operator.account, operator.team].filter(Boolean).join(" · ")
: `${formatCount(operatorCount)} 名运营`;
return (
<div aria-label="充值金额汇总" className="sbi-kpi-summary">
<div className="sbi-kpi-summary-person">
<span>{operator ? "当前人员" : "当前范围"}</span>
<strong>{operator ? operatorIdentity(operator) : "全部运营人员"}</strong>
<small>{details}</small>
</div>
<SummaryAmount label="区间总金额" value={summary.range_recharge_usd_minor} />
<SummaryAmount label="当月总金额" value={summary.month_recharge_usd_minor} />
</div>
);
}
function operatorIdentity(operator) {
return operator?.name || operator?.account || `#${operator?.user_id}`;
}
function SummaryAmount({ label, value }) {
return (
<div className="sbi-kpi-summary-amount">
<span>{label}</span>
<strong>{formatMoneyMinor(value)}</strong>
</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({ 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.length ? <EmptyTableRow colSpan={6} /> : null}
{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 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({ 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.length ? <EmptyTableRow colSpan={6} /> : null}
{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 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.length ? <EmptyTableRow colSpan={5} /> : null}
{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 EmptyTableRow({ colSpan }) {
return (
<tr>
<td className="sbi-kpi-empty-cell" colSpan={colSpan}>当前无数据</td>
</tr>
);
}
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({ item }) {
return (
<span className="sbi-kpi-app">
<SocialAppIdentity appCode={item.app_code} appName={item.app_name} />
</span>
);
}

View File

@ -0,0 +1,144 @@
/* 数据明细视图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;
}
/* 日维度按 App或 App+区域)分块展示时的块间分隔线,比常规行线更醒目一档。 */
.sbi-dt-table tr.sbi-dt-group-start td {
border-top: 2px solid var(--sbi-border);
}
/* 截断提示(表尾整行)。 */
.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;
}

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

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

View File

@ -0,0 +1,215 @@
/* 地区洞察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-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);
}

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

View File

@ -0,0 +1,122 @@
/* 运营中心充值排行视图专属样式:仅消费 social-v2.css 的 token类名前缀 sbi-kpi-。 */
.sbi-kpi-view {
display: flex;
flex-direction: column;
gap: 16px;
}
.sbi-kpi-board-scroll {
max-height: 620px;
border-radius: 0 0 var(--sbi-radius) var(--sbi-radius);
}
.sbi-kpi-summary {
display: grid;
grid-template-columns: minmax(220px, 1fr) repeat(2, minmax(170px, 220px));
margin-top: 12px;
border-top: 1px solid var(--sbi-border);
border-bottom: 1px solid var(--sbi-border);
background: #f7fafd;
}
.sbi-kpi-summary-person,
.sbi-kpi-summary-amount {
display: grid;
gap: 3px;
min-width: 0;
padding: 14px 18px;
}
.sbi-kpi-summary-amount {
align-content: center;
border-left: 1px solid var(--sbi-border);
text-align: right;
}
.sbi-kpi-summary span,
.sbi-kpi-summary small {
color: var(--sbi-text-3);
font-size: 11.5px;
}
.sbi-kpi-summary-person strong {
overflow: hidden;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.sbi-kpi-summary-amount strong {
color: var(--sbi-accent);
font-size: 22px;
font-variant-numeric: tabular-nums;
}
.sbi-table td.sbi-kpi-empty-cell {
height: 120px;
color: var(--sbi-text-3);
text-align: center;
}
.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-table-skeleton {
display: grid;
gap: 14px;
padding: 18px;
}
@media (max-width: 1280px) {
.sbi-kpi-summary {
grid-template-columns: minmax(180px, 1fr) repeat(2, minmax(150px, 190px));
}
}

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

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

View 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

File diff suppressed because it is too large Load Diff

View 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

File diff suppressed because it is too large Load Diff

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

View File

@ -0,0 +1,604 @@
/* 社交 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-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 {
display: flex;
align-items: center;
gap: 8px;
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-operator-filter {
width: 210px;
}
.sbi-operator-option {
display: grid !important;
gap: 2px;
}
.sbi-operator-option strong {
color: var(--sbi-text);
font-size: 13px;
}
.sbi-operator-option small {
color: var(--sbi-text-3);
font-size: 11.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;
}
}

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

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

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

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

156
databi/src/utils/time.js Normal file
View File

@ -0,0 +1,156 @@
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);
}
// Finance/Statistics 查询统一使用 [start_ms, end_ms);筛选器只有秒精度,
// 因此把可见结束秒推进 1 秒作为排他上界,避免漏掉结束日 23:59:59.xxx 的账单。
export function rangeExclusiveEndMs(range, timeZone = DEFAULT_TIME_ZONE) {
const endMs = rangeEndMs(range, timeZone);
return endMs === "" ? "" : Number(endMs) + 1000;
}
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");
}

View File

@ -0,0 +1,23 @@
import { expect, test } from "vitest";
import { rangeEndMs, rangeExclusiveEndMs, 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));
});
test("uses the next second as the exclusive Finance query boundary", () => {
const range = { end: "2026-06-06", endTime: "23:59:59", start: "2026-06-06", startTime: "00:00:00" };
expect(rangeExclusiveEndMs(range, "UTC")).toBe(Date.UTC(2026, 5, 7, 0, 0, 0));
expect(rangeExclusiveEndMs(range, "Asia/Shanghai")).toBe(Date.UTC(2026, 5, 6, 16, 0, 0));
});

View File

@ -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;
}
@ -28,6 +37,15 @@ server {
proxy_pass http://127.0.0.1:13100/readyz;
}
location = /external-admin {
return 301 /external-admin/$is_args$args;
}
# 外管使用独立 HTML 入口;深链必须回退到自己的入口,不能落入主后台的 index.html。
location ^~ /external-admin/ {
try_files $uri $uri/ /external-admin/index.html;
}
location / {
try_files $uri $uri/ /index.html;
}

14
external-admin/index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html data-external-admin-theme="black-gold" dir="ltr" lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0d0d11" />
<title>External Admin</title>
<style>html { background: #0d0d11; }</style>
</head>
<body>
<div id="external-admin-root"></div>
<script type="module" src="/external-admin/src/main.jsx"></script>
</body>
</html>

View File

@ -0,0 +1,53 @@
import { lazy, Suspense } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { RequireExternalAuth, RequireExternalCapability, RequirePasswordChanged } from "./auth/RouteGuards.jsx";
import { ExternalAdminLayout } from "./layout/ExternalAdminLayout.jsx";
import { ExternalPageState } from "./shared/PageComponents.jsx";
const LoginPage = lazy(() => import("./pages/LoginPage.jsx"));
const OverviewPage = lazy(() => import("./pages/OverviewPage.jsx"));
const UsersPage = lazy(() => import("./pages/UsersPage.jsx"));
const BansPage = lazy(() => import("./pages/BansPage.jsx"));
const OrganizationPage = lazy(() => import("./pages/OrganizationPage.jsx"));
const RoomsPage = lazy(() => import("./pages/RoomsPage.jsx"));
const GrantsPage = lazy(() => import("./pages/GrantsPage.jsx"));
const BannersPage = lazy(() => import("./pages/BannersPage.jsx"));
const ChangePasswordPage = lazy(() => import("./pages/ChangePasswordPage.jsx"));
export function ExternalAdminApp() {
return (
<Suspense fallback={<ExternalPageState loading />}>
<Routes>
<Route element={<LoginPage />} path="/login" />
<Route element={<RequireExternalAuth />}>
<Route element={<ChangePasswordPage />} path="/change-password" />
<Route element={<RequirePasswordChanged />}>
<Route element={<ExternalAdminLayout />}>
<Route element={<Navigate replace to="/overview" />} index />
<Route element={<OverviewPage />} path="/overview" />
<Route element={<RequireExternalCapability anyOf={["user:list", "user:update", "user:ban", "user-level:grant"]} />}>
<Route element={<UsersPage />} path="/users" />
</Route>
<Route element={<RequireExternalCapability anyOf={["user-ban:list", "user:unban"]} />}>
<Route element={<BansPage />} path="/bans" />
</Route>
<Route element={<RequireExternalCapability anyOf={["host:list", "agency:list", "bd:list", "bd-manager:list", "super-admin:list", "team:view"]} />}>
<Route element={<OrganizationPage />} path="/organization/:kind" />
</Route>
<Route element={<RequireExternalCapability anyOf={["room:list", "room:update"]} />}>
<Route element={<RoomsPage />} path="/rooms" />
</Route>
<Route element={<RequireExternalCapability anyOf={["privilege:list", "privilege:grant", "pretty-id:grant", "user-title:grant", "room-background:grant"]} />}>
<Route element={<GrantsPage />} path="/grants" />
</Route>
<Route element={<RequireExternalCapability anyOf={["banner:create"]} />}>
<Route element={<BannersPage />} path="/banners" />
</Route>
<Route element={<Navigate replace to="/overview" />} path="*" />
</Route>
</Route>
</Route>
</Routes>
</Suspense>
);
}

View File

@ -0,0 +1,77 @@
import { externalRequest, rememberExternalCsrfToken } from "./client.js";
import { arrayValue, asRecord, textValue } from "./normalizers.js";
// These external-session auth routes intentionally have no main-admin OpenAPI operation.
const EXTERNAL_AUTH_PATHS = Object.freeze({
changePassword: "/auth/change-password",
login: "/auth/login",
logout: "/auth/logout",
me: "/auth/me"
});
export async function loginExternal(payload) {
const data = await externalRequest(EXTERNAL_AUTH_PATHS.login, {
body: {
account: String(payload.account || "").trim(),
password: String(payload.password || "")
},
method: "POST",
notifyUnauthorized: false
});
rememberExternalCsrfToken(asRecord(data).csrfToken);
return normalizeSession(data);
}
export async function getExternalSession() {
const data = await externalRequest(EXTERNAL_AUTH_PATHS.me, { notifyUnauthorized: false });
rememberExternalCsrfToken(asRecord(data).csrfToken);
return normalizeSession(data);
}
export async function logoutExternal() {
try {
await externalRequest(EXTERNAL_AUTH_PATHS.logout, { method: "POST", notifyUnauthorized: false });
} finally {
rememberExternalCsrfToken("");
}
}
export async function changeExternalPassword(payload) {
const data = await externalRequest(EXTERNAL_AUTH_PATHS.changePassword, {
body: {
currentPassword: payload.oldPassword,
newPassword: payload.newPassword
},
method: "POST"
});
return data ? normalizeSession(data) : null;
}
export function normalizeSession(data) {
const root = asRecord(data);
const accountRecord = asRecord(root.account);
const linkedUser = asRecord(root.user || root.linkedUser || root.linked_user);
const legacyAccount = asRecord(root.externalAdminUser || root.external_admin_user);
const app = asRecord(root.app || linkedUser.app || accountRecord.app);
const capabilities = [
...arrayValue(root.capabilities),
...arrayValue(root.permissions),
...arrayValue(accountRecord.capabilities, accountRecord.permissions),
...arrayValue(linkedUser.capabilities, linkedUser.permissions)
]
.map(String)
.filter(Boolean);
return {
account: textValue(accountRecord.username, accountRecord.account, legacyAccount.username, legacyAccount.account, root.accountName, typeof root.account === "string" ? root.account : "", root.username),
appCode: textValue(root.appCode, root.app_code, app.appCode, app.app_code, accountRecord.appCode, accountRecord.app_code, linkedUser.appCode, linkedUser.app_code),
appName: textValue(app.name, app.appName, app.app_name, root.appName, root.app_name),
logoUrl: textValue(app.logoUrl, app.logo_url, root.logoUrl, root.logo_url),
capabilities: [...new Set(capabilities)],
displayName: textValue(linkedUser.displayName, linkedUser.display_name, linkedUser.name, linkedUser.username, accountRecord.username, legacyAccount.username, root.displayName),
id: textValue(accountRecord.id, accountRecord.externalAdminUserId, accountRecord.external_admin_user_id, legacyAccount.id, root.accountId, root.account_id),
passwordChangeRequired: Boolean(
root.passwordChangeRequired ?? root.password_change_required ?? accountRecord.passwordChangeRequired ?? accountRecord.password_change_required
)
};
}

View File

@ -0,0 +1,69 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { changeExternalPassword, loginExternal, normalizeSession } from "./auth.js";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("normalizeSession", () => {
test("normalizes account objects, fixed app and capabilities without stringifying objects", () => {
expect(normalizeSession({
account: { id: "ext-1", username: "fami-manager" },
app: { appCode: "fami", appName: "Fami", logoUrl: "https://media.example.com/fami.png" },
capabilities: ["user:list"],
passwordChangeRequired: true
})).toEqual(expect.objectContaining({
account: "fami-manager",
appCode: "fami",
appName: "Fami",
logoUrl: "https://media.example.com/fami.png",
capabilities: ["user:list"],
passwordChangeRequired: true
}));
});
test("merges permission aliases from root and bound user", () => {
const session = normalizeSession({
appCode: "lalu",
permissions: ["host:list"],
user: { permissions: ["team:view"], username: "lalu-ops" }
});
expect(session.capabilities).toEqual(["host:list", "team:view"]);
});
test("keeps the external account separate from the linked App user", () => {
const session = normalizeSession({
account: { id: "external-account-8", username: "fami-manager" },
app: { appCode: "fami", appName: "Fami" },
user: { userId: "app-user-9", username: "App Nickname" }
});
expect(session.account).toBe("fami-manager");
expect(session.displayName).toBe("App Nickname");
expect(session.id).toBe("external-account-8");
});
});
describe("external auth API contract", () => {
test("login sends only account and password so the server resolves the App", async () => {
const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: { account: "fami-manager", appCode: "fami" } }));
vi.stubGlobal("fetch", fetchMock);
await loginExternal({ account: " fami-manager ", appCode: "must-not-be-sent", password: " password bytes " });
expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/external/auth/login");
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ account: "fami-manager", password: " password bytes " });
});
test("change-password sends currentPassword and never oldPassword", async () => {
const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: {} }));
vi.stubGlobal("fetch", fetchMock);
await changeExternalPassword({ newPassword: "new-password", oldPassword: "current-password" });
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ currentPassword: "current-password", newPassword: "new-password" });
});
});
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" }, status });
}

View File

@ -0,0 +1,334 @@
import { API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import { validatePublicAppJumpParam } from "@/shared/app-jump/appJumpConfig.js";
import { externalRequest } from "./client.js";
import { asRecord, booleanValue, entityId, normalizePage, numberValue, textValue } from "./normalizers.js";
import { externalUiError } from "../i18n/errors.js";
// my-team is external-session scoped and is not part of the main-admin OpenAPI contract yet.
const EXTERNAL_ONLY_PATHS = Object.freeze({ myTeamAgencies: "/admin/my-team/agencies" });
export function listUsers(query) {
return externalRequest(externalOperationPath(API_OPERATIONS.appListUsers), { query }).then((data) => normalizePage(data, normalizeUser));
}
export function listBannedUsers(query) {
return externalRequest(externalOperationPath(API_OPERATIONS.appListBannedUsers), { query }).then((data) => normalizePage(data, normalizeBannedUser));
}
export function updateUser(userId, payload) {
return externalRequest(externalOperationPath(API_OPERATIONS.appUpdateUser, { id: userId }), { body: payload, method: "PATCH" });
}
export function banUser(userId, payload) {
return externalRequest(externalOperationPath(API_OPERATIONS.appBanUser, { id: userId }), { body: payload, method: "POST" });
}
export function unbanUser(userId, payload = {}) {
return externalRequest(externalOperationPath(API_OPERATIONS.appUnbanUser, { id: userId }), { body: payload, method: "POST" });
}
export function updateUserLevels(userId, payload) {
return externalRequest(externalOperationPath(API_OPERATIONS.appAdjustUserLevels, { id: userId }), { body: payload, method: "PUT" });
}
export async function grantUserVip(targetUserId, payload) {
const [programData, levelsData] = await Promise.all([
externalRequest(externalOperationPath(API_OPERATIONS.getVipProgram)),
externalRequest(externalOperationPath(API_OPERATIONS.getVipConfig))
]);
const programRoot = asRecord(programData);
const program = asRecord(programRoot.config || programRoot.programConfig || programRoot.program_config || programRoot);
const levelsRoot = asRecord(levelsData);
const levels = Array.isArray(levelsData) ? levelsData : levelsRoot.levels || [];
const level = Number(payload.level);
if (program.status === "disabled") {
throw externalUiError("errors.vipDisabled");
}
if (Array.isArray(levels) && levels.length && !levels.some((item) => Number(item.level) === level)) {
throw externalUiError("errors.vipLevelInvalid");
}
const command = {
commandId: payload.commandId,
level,
reason: payload.reason,
targetUserId
};
if (program.grantMode === "trial_card" || program.grant_mode === "trial_card") {
return externalRequest(externalOperationPath(API_OPERATIONS.grantVipTrialCard), {
body: { ...command, durationMs: Number(payload.durationMs) },
method: "POST"
});
}
return externalRequest(externalOperationPath(API_OPERATIONS.grantVip), { body: command, method: "POST" });
}
export function listOrganizations(kind, query = {}) {
const routes = {
agencies: externalOperationPath(API_OPERATIONS.listAgencies),
bds: externalOperationPath(API_OPERATIONS.listBDs),
"bd-leaders": externalOperationPath(API_OPERATIONS.listManagers),
hosts: externalOperationPath(API_OPERATIONS.listHosts),
managers: externalOperationPath(API_OPERATIONS.listManagers),
"super-admins": externalOperationPath(API_OPERATIONS.listBDLeaders),
team: EXTERNAL_ONLY_PATHS.myTeamAgencies
};
if (!routes[kind]) {
throw externalUiError("errors.organizationRoute");
}
const scopedQuery = kind === "super-admins" ? { ...query, status: query.status || "active" } : query;
return externalRequest(routes[kind], { query: scopedQuery }).then((data) => {
const page = normalizePage(data, normalizeOrganization);
if (kind !== "team") {
return page;
}
const source = asRecord(data);
return {
...page,
totalBdLeaders: numberValue(source.totalBdLeaders ?? source.total_bd_leaders),
totalBds: numberValue(source.totalBds ?? source.total_bds),
truncated: booleanValue(source.truncated)
};
});
}
export function listRooms(query) {
return externalRequest(externalOperationPath(API_OPERATIONS.listRooms), { query }).then((data) => normalizePage(data, normalizeRoom));
}
export function updateRoom(roomId, payload) {
return externalRequest(externalOperationPath(API_OPERATIONS.updateRoom, { room_id: roomId }), { body: buildRoomUpdatePayload(payload), method: "PATCH" });
}
export function buildRoomUpdatePayload(payload) {
const regionText = String(payload.visibleRegionId ?? "").trim();
const visibleRegionId = regionText ? Number(regionText) : 0;
if (!Number.isSafeInteger(visibleRegionId) || visibleRegionId < 0) {
throw externalUiError("errors.regionId");
}
return {
coverUrl: String(payload.coverUrl || "").trim(),
description: String(payload.description || "").trim(),
title: String(payload.title || "").trim(),
visibleRegionId
};
}
export function listResources(query) {
return externalRequest(externalOperationPath(API_OPERATIONS.listResources), { query }).then((data) => normalizePage(data, normalizeResource));
}
export function listResourceGrants(query) {
return externalRequest(externalOperationPath(API_OPERATIONS.listResourceGrants), { query }).then((data) => normalizePage(data));
}
export async function lookupGrantTarget(userIdOrPrettyId) {
const data = await externalRequest(externalOperationPath(API_OPERATIONS.lookupResourceGrantTarget), { query: { user_id: userIdOrPrettyId } });
const source = asRecord(data);
const user = asRecord(source.user || source.targetUser || source.target_user);
return {
id: textValue(source.targetUserId, source.target_user_id, source.userId, source.user_id, user.id, user.userId, user.user_id),
prettyId: textValue(source.prettyId, source.pretty_id, user.prettyId, user.pretty_id, user.displayUserId, user.display_user_id),
username: textValue(source.username, source.nickname, user.username, user.nickname)
};
}
export function grantResource(payload) {
return externalRequest(externalOperationPath(API_OPERATIONS.grantResource), { body: payload, method: "POST" });
}
export function grantPrettyId(payload) {
return externalRequest(externalOperationPath(API_OPERATIONS.grantPrettyId), { body: payload, method: "POST" });
}
export function listBanners(query) {
return externalRequest(externalOperationPath(API_OPERATIONS.listBanners), { query }).then((data) => normalizePage(data, normalizeBanner));
}
export function createBanner(payload) {
return externalRequest(externalOperationPath(API_OPERATIONS.createBanner), { body: buildBannerPayload(payload), method: "POST" });
}
export function buildBannerPayload(payload) {
if (!["h5", "app"].includes(payload.bannerType)) {
throw externalUiError("errors.bannerType");
}
if (!["android", "ios"].includes(payload.platform)) {
throw externalUiError("errors.bannerPlatform");
}
if (!["active", "disabled", "expired"].includes(payload.status)) {
throw externalUiError("errors.bannerStatus");
}
const displayScopes = [...new Set((payload.displayScopes || []).filter((scope) => ["home", "room", "recharge", "me"].includes(scope)))];
if (!displayScopes.length) {
throw externalUiError("errors.bannerScope");
}
if (!payload.coverUrl) {
throw externalUiError("errors.bannerImage");
}
if (payload.bannerType === "h5") {
let target;
try {
target = new URL(payload.param);
} catch {
throw externalUiError("errors.h5Url");
}
if (!["http:", "https:"].includes(target.protocol)) {
throw externalUiError("errors.h5Protocol");
}
}
if (payload.bannerType === "app") {
const validation = validatePublicAppJumpParam(payload.param);
if (!validation.valid) {
throw externalUiError(appJumpValidationKey(validation.message));
}
}
if (displayScopes.includes("room") && !payload.roomSmallImageUrl) {
throw externalUiError("errors.roomBannerImage");
}
const countryCode = String(payload.countryCode || "").trim().toUpperCase();
if (countryCode && !/^[A-Z]{2,3}$/.test(countryCode)) {
throw externalUiError("errors.countryCode");
}
const regionId = Number(payload.regionId || 0);
if (!Number.isSafeInteger(regionId) || regionId < 0) {
throw externalUiError("errors.bannerRegion");
}
const startsAtMs = Number(payload.startsAtMs || 0);
const endsAtMs = Number(payload.endsAtMs || 0);
if (startsAtMs > 0 && endsAtMs > 0 && startsAtMs >= endsAtMs) {
throw externalUiError("errors.bannerSchedule");
}
return {
bannerType: payload.bannerType,
countryCode,
coverUrl: payload.coverUrl,
description: String(payload.description || "").trim(),
displayScope: displayScopes[0],
displayScopes,
endsAtMs,
param: String(payload.param || "").trim(),
platform: payload.platform,
regionId,
roomSmallImageUrl: displayScopes.includes("room") ? payload.roomSmallImageUrl : "",
sortOrder: Number(payload.sortOrder || 0),
startsAtMs,
status: payload.status
};
}
export async function uploadExternalImage(file) {
const body = new FormData();
body.append("file", file);
const data = await externalRequest(externalOperationPath(API_OPERATIONS.uploadImage), { body, method: "POST" });
const source = asRecord(data);
return textValue(source.url, source.fileUrl, source.file_url, asRecord(source.file).url);
}
export function normalizeUser(item) {
const levels = asRecord(item.levels);
const wealth = asRecord(levels.wealth);
const charm = asRecord(levels.charm);
const vip = asRecord(item.vip);
const ban = asRecord(item.ban);
return {
avatar: textValue(item.avatar, item.avatarUrl, item.avatar_url),
banned: booleanValue(ban.active, item.banned, item.isBanned, item.is_banned) || ["banned", "disabled"].includes(String(item.status).toLowerCase()),
charmLevel: numberValue(item.charmLevel ?? item.charm_level ?? charm.displayLevel ?? charm.display_level ?? charm.level),
country: textValue(item.country, item.countryCode, item.country_code),
gender: textValue(item.gender),
id: entityId(item),
prettyId: textValue(item.prettyId, item.pretty_id, item.displayUserId, item.display_user_id, item.shortId, item.short_id),
status: textValue(item.status, item.banned ? "banned" : "active"),
username: textValue(item.username, item.nickname, item.nickName, item.name),
vipLevel: numberValue(item.vipLevel ?? item.vip_level ?? vip.level),
wealthLevel: numberValue(item.wealthLevel ?? item.wealth_level ?? wealth.displayLevel ?? wealth.display_level ?? wealth.level)
};
}
export function normalizeBannedUser(item) {
// Ban-list rows use the ban-log id as `id`; unban must always target the nested App user id instead.
const target = asRecord(item.target || item.user || item.targetUser || item.target_user);
const user = normalizeUser(target);
return {
...user,
banned: true,
bannedAtMs: numberValue(item.bannedAtMs ?? item.banned_at_ms ?? item.createdAtMs ?? item.created_at_ms),
banLogId: textValue(item.id, item.banLogId, item.ban_log_id),
expiresAtMs: numberValue(item.expiresAtMs ?? item.expires_at_ms),
id: textValue(target.userId, target.user_id, target.id),
prettyId: textValue(target.displayUserId, target.display_user_id, target.prettyId, target.pretty_id),
reason: textValue(item.reason)
};
}
export function normalizeOrganization(item) {
const user = asRecord(item.user || item.profile || item.manager);
const agency = Boolean(item.agencyId ?? item.agency_id);
return {
avatar: textValue(agency ? item.ownerAvatar : "", agency ? item.owner_avatar : "", item.avatar, item.avatarUrl, item.avatar_url, user.avatar),
appCode: textValue(item.appCode, item.app_code),
country: textValue(item.regionName, item.region_name, item.country, item.countryCode, item.country_code, user.regionName, user.region_name, user.country),
id: textValue(item.agencyId, item.agency_id, item.userId, item.user_id, item.id, entityId(user)),
name: textValue(item.name, agency ? item.ownerUsername : "", item.displayName, item.display_name, item.username, user.username, user.name),
parentBdUserId: textValue(item.parentBdUserId, item.parent_bd_user_id),
prettyId: textValue(agency ? item.ownerDisplayUserId : "", agency ? item.owner_display_user_id : "", item.displayUserId, item.display_user_id, item.prettyId, item.pretty_id, user.displayUserId, user.display_user_id, user.prettyId, user.pretty_id),
status: textValue(item.status, item.enabled === false ? "disabled" : "active"),
username: textValue(agency ? item.ownerUsername : "", agency ? item.owner_username : "", item.username, user.username, user.nickname),
userId: textValue(agency ? item.ownerUserId : "", agency ? item.owner_user_id : "", item.userId, item.user_id, user.userId, user.user_id)
};
}
function normalizeRoom(item) {
return {
coverUrl: textValue(item.coverUrl, item.cover_url, item.backgroundUrl, item.background_url),
description: textValue(item.description, item.introduction),
id: entityId(item),
ownerName: textValue(item.ownerName, item.owner_name, asRecord(item.owner).username),
status: textValue(item.status, item.liveStatus, item.live_status),
title: textValue(item.title, item.name, item.roomName, item.room_name),
visibleRegionId: textValue(item.visibleRegionId, item.visible_region_id)
};
}
function normalizeResource(item) {
return {
coverUrl: textValue(item.coverUrl, item.cover_url, item.iconUrl, item.icon_url),
id: textValue(item.resourceId, item.resource_id, item.id),
name: textValue(item.name, item.resourceName, item.resource_name, item.resourceCode, item.resource_code),
status: textValue(item.status),
type: textValue(item.resourceType, item.resource_type, item.type)
};
}
function normalizeBanner(item) {
return {
bannerType: textValue(item.bannerType, item.banner_type, item.type),
coverUrl: textValue(item.coverUrl, item.cover_url, item.imageUrl, item.image_url),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
description: textValue(item.description, item.title),
id: textValue(item.id, item.bannerId, item.banner_id),
platform: textValue(item.platform),
sortOrder: numberValue(item.sortOrder ?? item.sort_order),
status: textValue(item.status)
};
}
function externalOperationPath(operation, params) {
// Generated main-admin paths start with /v1; the external gateway already owns that version prefix in its base URL.
return apiEndpointPath(operation, params).replace(/^\/v1(?=\/)/, "");
}
function appJumpValidationKey(message) {
return {
"APP 跳转参数必须是 JSON 对象": "errors.appParamObject",
"APP 跳转 type 不在公共跳转方案内": "errors.appParamType",
"Explore 游戏 H5 需要选择有效 game_code": "errors.appParamGameCode",
"该 APP 跳转需要填写 game_id": "errors.appParamGame",
"该 APP 跳转需要填写 room_id": "errors.appParamRoom",
"该 APP 跳转需要填写有效 window": "errors.appParamWindow",
"请填写 APP 跳转参数": "errors.appParamRequired"
}[message] || "errors.appParamObject";
}

View File

@ -0,0 +1,187 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { buildBannerPayload, buildRoomUpdatePayload, createBanner, grantUserVip, listOrganizations, normalizeBannedUser, normalizeOrganization, normalizeUser, unbanUser } from "./business.js";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("normalizeBannedUser", () => {
test("uses nested target user id instead of ban log id for unban actions", () => {
const item = normalizeBannedUser({
id: "ban-log-9001",
reason: "spam",
target: {
avatar: "https://cdn.test/avatar.png",
displayUserId: "163000",
userId: "318705991371722752",
username: "Fami User"
}
});
expect(item.id).toBe("318705991371722752");
expect(item.banLogId).toBe("ban-log-9001");
expect(item.prettyId).toBe("163000");
expect(item.reason).toBe("spam");
});
test("unban request uses the normalized target user id", async () => {
const user = normalizeBannedUser({ id: "ban-log-1", target: { userId: "canonical-user-2" } });
const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: {} }));
vi.stubGlobal("fetch", fetchMock);
await unbanUser(user.id, { reason: "manual" });
expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/external/app/users/canonical-user-2/unban");
});
});
describe("room API payload", () => {
test("converts region id to int64-compatible number and maps empty to all regions", () => {
expect(buildRoomUpdatePayload({ coverUrl: " cover ", description: " desc ", title: " Room ", visibleRegionId: "18" }))
.toEqual({ coverUrl: "cover", description: "desc", title: "Room", visibleRegionId: 18 });
expect(buildRoomUpdatePayload({ visibleRegionId: "" }).visibleRegionId).toBe(0);
expect(() => buildRoomUpdatePayload({ visibleRegionId: "1.5" })).toThrow("errors.regionId");
});
});
describe("real external DTO normalization", () => {
test("reads nested levels, vip and ban state from the App user DTO", () => {
const user = normalizeUser({
ban: { active: true },
levels: { charm: { displayLevel: 8 }, wealth: { displayLevel: 12 } },
userId: "user-1",
vip: { level: 4 }
});
expect(user).toEqual(expect.objectContaining({ banned: true, charmLevel: 8, vipLevel: 4, wealthLevel: 12 }));
});
test("normalizes agency owner fields and ordinary member fields", () => {
expect(normalizeOrganization({
agencyId: "agency-1",
name: "Fami Guild",
ownerAvatar: "owner.png",
ownerDisplayUserId: "163000",
ownerUserId: "owner-9",
ownerUsername: "Guild Owner",
regionName: "Middle East"
})).toEqual(expect.objectContaining({
avatar: "owner.png",
id: "agency-1",
name: "Fami Guild",
prettyId: "163000",
userId: "owner-9",
username: "Guild Owner"
}));
expect(normalizeOrganization({ displayUserId: "9988", regionName: "SEA", userId: "bd-1", username: "BD One" }))
.toEqual(expect.objectContaining({ id: "bd-1", name: "BD One", prettyId: "9988" }));
});
test("maps BD Manager, Super Admin and team to their real scoped endpoints", async () => {
const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: { items: [], total: 0 } }));
vi.stubGlobal("fetch", fetchMock);
await listOrganizations("bd-leaders", { page: 1 });
await listOrganizations("super-admins", { page: 1 });
await listOrganizations("team", { page: 1 });
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
"/api/v1/external/admin/managers?page=1",
"/api/v1/external/admin/bd-leaders?page=1&status=active",
"/api/v1/external/admin/my-team/agencies?page=1"
]);
});
test("preserves scoped team totals and truncation metadata", async () => {
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({
code: 0,
data: {
agencies: [{ agencyId: "agency-9", ownerUserId: "owner-9", parentBdUserId: "bd-7", status: "active" }],
total: 1,
totalBdLeaders: 2,
totalBds: 7,
truncated: true
}
})));
const team = await listOrganizations("team", { page: 1 });
expect(team).toEqual(expect.objectContaining({ total: 1, totalBdLeaders: 2, totalBds: 7, truncated: true }));
expect(team.items[0]).toEqual(expect.objectContaining({ id: "agency-9", parentBdUserId: "bd-7", userId: "owner-9" }));
});
});
describe("banner API payload", () => {
test("uses only the real appconfig banner enums and room image contract", () => {
expect(buildBannerPayload({
bannerType: "h5",
countryCode: "sa",
coverUrl: "https://cdn.test/banner.webp",
description: "Campaign",
displayScopes: ["home", "room"],
param: "https://fami.test/campaign",
platform: "android",
regionId: 8,
roomSmallImageUrl: "https://cdn.test/room.webp",
sortOrder: 2,
status: "active"
})).toEqual(expect.objectContaining({
bannerType: "h5",
countryCode: "SA",
displayScope: "home",
displayScopes: ["home", "room"],
platform: "android",
roomSmallImageUrl: "https://cdn.test/room.webp",
status: "active"
}));
expect(() => buildBannerPayload({ bannerType: "link", coverUrl: "x", displayScopes: ["home"], platform: "all", status: "enabled" }))
.toThrow("errors.bannerType");
});
test("create API sends the normalized appconfig payload", async () => {
const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: {} }));
vi.stubGlobal("fetch", fetchMock);
await createBanner({
bannerType: "app",
coverUrl: "https://cdn.test/banner.webp",
displayScopes: ["me"],
param: JSON.stringify({ type: "wallet" }),
platform: "ios",
status: "disabled"
});
expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/external/admin/app-config/banners");
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual(expect.objectContaining({
bannerType: "app",
displayScope: "me",
displayScopes: ["me"],
platform: "ios",
status: "disabled"
}));
});
});
describe("VIP grant routing", () => {
test("routes trial-card VIP grants to vipconfig and never sends track=vip to app users", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ code: 0, data: { config: { grantMode: "trial_card", status: "active" } } }))
.mockResolvedValueOnce(jsonResponse({ code: 0, data: { levels: [{ level: 5 }] } }))
.mockResolvedValueOnce(jsonResponse({ code: 0, data: {} }));
vi.stubGlobal("fetch", fetchMock);
await grantUserVip("user-9", { commandId: "vip-test", durationMs: 604800000, level: 5, reason: "manual" });
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
"/api/v1/external/admin/activity/vip-program",
"/api/v1/external/admin/activity/vip-levels",
"/api/v1/external/admin/activity/vip-trial-card-grants"
]);
const payload = JSON.parse(fetchMock.mock.calls[2][1].body);
expect(payload).toEqual(expect.objectContaining({ durationMs: 604800000, level: 5, targetUserId: "user-9" }));
expect(payload).not.toHaveProperty("track");
});
});
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" }, status });
}

View File

@ -0,0 +1,149 @@
const EXTERNAL_API_BASE = "/api/v1/external";
const CSRF_COOKIE_NAME = "hyapp_external_csrf";
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
let unauthorizedHandler = null;
let inMemoryCsrfToken = "";
export class ExternalApiError extends Error {
constructor(message, { code = "", status = 0, details = null } = {}) {
super(message);
this.name = "ExternalApiError";
this.code = code;
this.status = status;
this.details = details;
}
}
export function setExternalUnauthorizedHandler(handler) {
unauthorizedHandler = typeof handler === "function" ? handler : null;
return () => {
if (unauthorizedHandler === handler) {
unauthorizedHandler = null;
}
};
}
export function rememberExternalCsrfToken(token) {
// Cookie remains the source of truth. The memory copy only covers deployments where Cookie Path prevents page JavaScript from reading it.
inMemoryCsrfToken = String(token || "").trim();
}
export function readExternalCsrfToken(cookieText = globalThis.document?.cookie || "") {
const prefix = `${CSRF_COOKIE_NAME}=`;
const cookieValue = String(cookieText)
.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith(prefix));
if (!cookieValue) {
return inMemoryCsrfToken;
}
try {
return decodeURIComponent(cookieValue.slice(prefix.length));
} catch {
return cookieValue.slice(prefix.length);
}
}
export async function externalRequest(path, options = {}) {
const method = String(options.method || "GET").toUpperCase();
const headers = new Headers(options.headers || {});
const body = createBody(options.body, headers);
if (!SAFE_METHODS.has(method)) {
const csrfToken = readExternalCsrfToken();
if (csrfToken) {
headers.set("X-CSRF-Token", csrfToken);
}
}
const response = await fetch(buildUrl(path, options.query), {
body,
credentials: "include",
headers,
method,
signal: options.signal
});
const payload = await readPayload(response);
if (response.status === 401 && options.notifyUnauthorized !== false) {
unauthorizedHandler?.();
}
if (!response.ok) {
throw apiError(payload, response.status, response.statusText || "Request failed");
}
if (isEnvelopeFailure(payload)) {
throw apiError(payload, response.status, "Request failed");
}
const data = unwrapPayload(payload);
if (data && typeof data === "object" && "csrfToken" in data) {
rememberExternalCsrfToken(data.csrfToken);
}
return data;
}
function buildUrl(path, query) {
const normalizedPath = String(path || "").startsWith("/") ? path : `/${path}`;
const params = new URLSearchParams();
Object.entries(query || {}).forEach(([key, value]) => {
if (value === undefined || value === null || value === "") {
return;
}
(Array.isArray(value) ? value : [value]).forEach((item) => params.append(key, String(item)));
});
const search = params.toString();
return `${EXTERNAL_API_BASE}${normalizedPath}${search ? `?${search}` : ""}`;
}
function createBody(body, headers) {
if (body === undefined || body === null) {
return undefined;
}
if (body instanceof FormData || typeof body === "string" || body instanceof Blob) {
return body;
}
headers.set("Content-Type", "application/json");
return JSON.stringify(body);
}
async function readPayload(response) {
if (response.status === 204) {
return null;
}
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
return response.json();
}
const text = await response.text();
return text ? { message: text } : null;
}
function isEnvelopeFailure(payload) {
if (!payload || typeof payload !== "object" || !("code" in payload)) {
return false;
}
return ![0, 200, "0", "200", "OK", "SUCCESS"].includes(payload.code);
}
function unwrapPayload(payload) {
if (!payload || typeof payload !== "object") {
return payload;
}
return "data" in payload ? payload.data : payload;
}
function apiError(payload, status, fallback) {
const source = payload && typeof payload === "object" ? payload : {};
return new ExternalApiError(source.message || source.msg || fallback, {
code: source.code,
details: source.details || source.errors || null,
status
});
}

View File

@ -0,0 +1,74 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
ExternalApiError,
externalRequest,
readExternalCsrfToken,
rememberExternalCsrfToken,
setExternalUnauthorizedHandler
} from "./client.js";
describe("externalRequest", () => {
beforeEach(() => {
rememberExternalCsrfToken("");
document.cookie = "hyapp_external_csrf=; Max-Age=0; Path=/";
});
afterEach(() => {
setExternalUnauthorizedHandler(null);
vi.unstubAllGlobals();
});
test("uses only the isolated cookie session and external API base", async () => {
const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: { items: [] } }));
vi.stubGlobal("fetch", fetchMock);
await externalRequest("/app/users", { query: { keyword: "163000", page: 1 } });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("/api/v1/external/app/users?keyword=163000&page=1");
expect(init.credentials).toBe("include");
expect(init.headers.get("Authorization")).toBeNull();
expect(init.headers.get("X-App-Code")).toBeNull();
expect(init.headers.get("X-CSRF-Token")).toBeNull();
});
test("adds CSRF to writes from the dedicated cookie", async () => {
document.cookie = "hyapp_external_csrf=csrf%20token; Path=/";
const fetchMock = vi.fn(async () => jsonResponse({ code: 0, data: { ok: true } }));
vi.stubGlobal("fetch", fetchMock);
await externalRequest("/auth/change-password", { body: { currentPassword: "old", newPassword: "new" }, method: "POST" });
const [, init] = fetchMock.mock.calls[0];
expect(init.headers.get("X-CSRF-Token")).toBe("csrf token");
expect(JSON.parse(init.body)).toEqual({ currentPassword: "old", newPassword: "new" });
});
test("keeps csrfToken in memory when the API-scoped cookie is unreadable", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ code: 0, data: { csrfToken: "memory-csrf", user: { username: "operator" } } }))
.mockResolvedValueOnce(jsonResponse({ code: 0, data: {} }));
vi.stubGlobal("fetch", fetchMock);
await externalRequest("/auth/me");
await externalRequest("/auth/logout", { method: "POST" });
expect(fetchMock.mock.calls[1][1].headers.get("X-CSRF-Token")).toBe("memory-csrf");
expect(readExternalCsrfToken("")).toBe("memory-csrf");
});
test("invalidates the external session on protected 401", async () => {
const handler = vi.fn();
setExternalUnauthorizedHandler(handler);
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ code: "UNAUTHORIZED", message: "请登录" }, 401)));
await expect(externalRequest("/admin/rooms")).rejects.toEqual(expect.objectContaining({ status: 401 }));
expect(handler).toHaveBeenCalledTimes(1);
expect(new ExternalApiError("x")).toBeInstanceOf(Error);
});
});
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" }, status });
}

View File

@ -0,0 +1,59 @@
export function normalizePage(data, mapItem = normalizeRecord) {
const source = asRecord(data);
const rawItems = Array.isArray(data)
? data
: arrayValue(source.items, source.agencies, source.list, source.records, source.content, source.rows);
const page = positiveNumber(source.page ?? source.page_no ?? source.pageNo, 1);
const pageSize = positiveNumber(source.pageSize ?? source.page_size ?? source.size, rawItems.length || 20);
return {
items: rawItems.map((item) => mapItem(asRecord(item))),
page,
pageSize,
total: nonNegativeNumber(source.total ?? source.total_count ?? source.totalCount, rawItems.length)
};
}
export function normalizeRecord(value) {
return asRecord(value);
}
export function asRecord(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
}
export function arrayValue(...values) {
return values.find(Array.isArray) || [];
}
export function textValue(...values) {
const value = values.find((item) => item !== undefined && item !== null && item !== "");
return value === undefined ? "" : String(value);
}
export function numberValue(value, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
export function booleanValue(...values) {
const value = values.find((item) => item !== undefined && item !== null);
if (typeof value === "string") {
return ["1", "true", "enabled", "active", "yes"].includes(value.toLowerCase());
}
return Boolean(value);
}
export function entityId(item) {
return textValue(item.id, item.userId, item.user_id, item.roomId, item.room_id, item.resourceId, item.resource_id);
}
function positiveNumber(value, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function nonNegativeNumber(value, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
}

View File

@ -0,0 +1,74 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { changeExternalPassword, getExternalSession, loginExternal, logoutExternal } from "../api/auth.js";
import { ExternalApiError, setExternalUnauthorizedHandler } from "../api/client.js";
const ExternalAuthContext = createContext(null);
export function ExternalAuthProvider({ children }) {
const [session, setSession] = useState(null);
const [initializing, setInitializing] = useState(true);
const [initializationError, setInitializationError] = useState(null);
const refresh = useCallback(async () => {
setInitializationError(null);
try {
const nextSession = await getExternalSession();
setSession(nextSession);
return nextSession;
} catch (error) {
setSession(null);
if (!(error instanceof ExternalApiError && error.status === 401)) {
// Preserve the stable API status/code for the UI translation layer; never surface a backend-language message directly.
setInitializationError(error);
}
return null;
} finally {
setInitializing(false);
}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
useEffect(() => {
// Any protected request returning 401 invalidates the entire external session; it never mutates the main-admin session.
return setExternalUnauthorizedHandler(() => setSession(null));
}, []);
const login = useCallback(async (credentials) => {
const nextSession = await loginExternal(credentials);
setSession(nextSession);
return nextSession;
}, []);
const logout = useCallback(async () => {
try {
await logoutExternal();
} finally {
setSession(null);
}
}, []);
const changePassword = useCallback(async (payload) => {
const responseSession = await changeExternalPassword(payload);
const nextSession = responseSession?.appCode ? responseSession : await getExternalSession();
setSession({ ...nextSession, passwordChangeRequired: false });
return nextSession;
}, []);
const value = useMemo(
() => ({ changePassword, initializationError, initializing, login, logout, refresh, session }),
[changePassword, initializationError, initializing, login, logout, refresh, session]
);
return <ExternalAuthContext.Provider value={value}>{children}</ExternalAuthContext.Provider>;
}
export function useExternalAuth() {
const context = useContext(ExternalAuthContext);
if (!context) {
throw new Error("useExternalAuth must be used inside ExternalAuthProvider");
}
return context;
}

View File

@ -0,0 +1,62 @@
import Alert from "@mui/material/Alert";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import CircularProgress from "@mui/material/CircularProgress";
import Typography from "@mui/material/Typography";
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { hasAnyExternalCapability } from "../config/capabilities.js";
import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx";
import { translateExternalError } from "../i18n/errors.js";
import { useExternalAuth } from "./ExternalAuthProvider.jsx";
export function RequireExternalAuth() {
const { initializationError, initializing, refresh, session } = useExternalAuth();
const { t } = useExternalI18n();
const location = useLocation();
if (initializing) {
return (
<Box className="external-full-state" role="status">
<CircularProgress size={28} />
<Typography color="text.secondary">{t("auth.checkingSession")}</Typography>
</Box>
);
}
if (!session) {
if (initializationError) {
return (
<Box className="external-full-state">
<Alert severity="error">{translateExternalError(initializationError, t, "auth.sessionFailed")}</Alert>
<Button onClick={refresh}>{t("auth.recheck")}</Button>
</Box>
);
}
return <Navigate replace state={{ from: location }} to="/login" />;
}
return <Outlet />;
}
export function RequirePasswordChanged() {
const { session } = useExternalAuth();
return session?.passwordChangeRequired ? <Navigate replace to="/change-password" /> : <Outlet />;
}
export function RequireExternalCapability({ anyOf }) {
const { session } = useExternalAuth();
const { t } = useExternalI18n();
if (hasAnyExternalCapability(session, anyOf)) {
return <Outlet />;
}
return (
<Box className="external-page external-full-state">
<Alert severity="warning">{t("auth.noPermission")}</Alert>
<Button component="a" href="/external-admin/overview" variant="outlined">
{t("auth.returnOverview")}
</Button>
</Box>
);
}

View File

@ -0,0 +1,136 @@
import Button from "@mui/material/Button";
import Checkbox from "@mui/material/Checkbox";
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 ListItemText from "@mui/material/ListItemText";
import MenuItem from "@mui/material/MenuItem";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import { useEffect, useState } from "react";
import { AppJumpConfigField } from "@/shared/ui/AppJumpConfigField.jsx";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { ExternalImageUploadField } from "../shared/ExternalImageUploadField.jsx";
import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx";
const displayScopeOptions = [
["home", "banners.scopeHome"],
["room", "banners.scopeRoom"],
["recharge", "banners.scopeRecharge"],
["me", "banners.scopeMe"]
];
export function BannerCreateDialog({ loading, onClose, onSubmit, open }) {
const { direction, locale, t } = useExternalI18n();
const [form, setForm] = useState(initialForm);
useEffect(() => setForm(initialForm), [open]);
const submit = (event) => {
event.preventDefault();
onSubmit({
...form,
regionId: Number(form.regionId || 0),
roomSmallImageUrl: form.displayScopes.includes("room") ? form.roomSmallImageUrl : "",
sortOrder: Number(form.sortOrder || 0)
});
};
return (
<Dialog fullWidth maxWidth="sm" open={open} onClose={loading ? undefined : onClose}>
<Stack component="form" onSubmit={submit}>
<DialogTitle>{t("banners.create")}</DialogTitle>
<DialogContent>
<Stack className="external-dialog-fields" spacing={2}>
<ExternalImageUploadField disabled={loading} label={t("banners.image")} value={form.coverUrl} onChange={(coverUrl) => setForm({ ...form, coverUrl })} />
{form.displayScopes.includes("room") ? <ExternalImageUploadField disabled={loading} label={t("banners.roomImage")} value={form.roomSmallImageUrl} onChange={(roomSmallImageUrl) => setForm({ ...form, roomSmallImageUrl })} /> : null}
<TextField disabled={loading} label={t("banners.platform")} required select value={form.platform} onChange={(event) => setForm({ ...form, platform: event.target.value })}>
<MenuItem value="android">Android</MenuItem>
<MenuItem value="ios">iOS</MenuItem>
</TextField>
<TextField disabled={loading} label={t("banners.description")} slotProps={{ htmlInput: { maxLength: 255 } }} value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
<AppJumpConfigField
allowCustom
appParamLabel={t("banners.appParam")}
disabled={loading}
h5Label={t("banners.h5Param")}
messages={appJumpMessages(t)}
paramValue={form.param}
required
showHint={false}
typeLabel={t("banners.jumpType")}
typeValue={form.bannerType}
onChange={({ param, type }) => setForm({ ...form, bannerType: type, param })}
/>
<TextField
disabled={loading}
label={t("banners.displayScope")}
required
select
slotProps={{ select: { multiple: true, renderValue: (selected) => selected.map((value) => t(displayScopeOptions.find(([key]) => key === value)?.[1]) || value).join(", ") } }}
value={form.displayScopes}
onChange={(event) => setForm({ ...form, displayScopes: typeof event.target.value === "string" ? event.target.value.split(",") : event.target.value })}
>
{displayScopeOptions.map(([value, labelKey]) => <MenuItem key={value} value={value}><Checkbox checked={form.displayScopes.includes(value)} /><ListItemText primary={t(labelKey)} /></MenuItem>)}
</TextField>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5}>
<TextField disabled={loading} label={t("banners.regionId")} slotProps={{ htmlInput: { min: 0 } }} type="number" value={form.regionId} onChange={(event) => setForm({ ...form, regionId: event.target.value })} />
<TextField disabled={loading} helperText={t("banners.countryCodeHint")} label={t("banners.countryCode")} value={form.countryCode} onChange={(event) => setForm({ ...form, countryCode: event.target.value.toUpperCase() })} />
<TextField disabled={loading} label={t("banners.sort")} slotProps={{ htmlInput: { min: 0 } }} type="number" value={form.sortOrder} onChange={(event) => setForm({ ...form, sortOrder: event.target.value })} />
</Stack>
<TimeRangeFilter
direction={direction}
disabled={loading}
label={t("banners.schedule")}
locale={locale}
messages={timeRangeMessages(t)}
value={{ endMs: form.endsAtMs, startMs: form.startsAtMs }}
onChange={({ endMs, startMs }) => setForm({ ...form, endsAtMs: endMs, startsAtMs: startMs })}
/>
<TextField disabled={loading} label={t("common.status")} select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>
<MenuItem value="active">{t("common.enabled")}</MenuItem>
<MenuItem value="disabled">{t("common.disabled")}</MenuItem>
<MenuItem value="expired">{t("common.expired")}</MenuItem>
</TextField>
</Stack>
</DialogContent>
<DialogActions><Button disabled={loading} onClick={onClose}>{t("common.cancel")}</Button><Button disabled={loading || !form.coverUrl} type="submit" variant="contained">{t("common.create")}</Button></DialogActions>
</Stack>
</Dialog>
);
}
const initialForm = {
bannerType: "h5",
countryCode: "",
coverUrl: "",
description: "",
displayScopes: ["home"],
endsAtMs: 0,
param: "",
platform: "android",
regionId: "",
roomSmallImageUrl: "",
sortOrder: "0",
startsAtMs: 0,
status: "active"
};
function appJumpMessages(t) {
return {
appTarget: t("jump.appTarget"),
gameId: t("jump.gameId"),
giftId: t("jump.giftId"),
redPacketNo: t("jump.redPacketNo"),
roomId: t("jump.roomId"),
targets: Object.fromEntries(["wallet", "room", "room_random", "room_window", "room_random_window", "room_game", "room_random_game", "explore_game", "custom"].map((value) => [value, t(`jump.target.${value}`)])),
window: t("jump.window"),
windows: Object.fromEntries(["game_window", "gift_panel", "red_packet_claim", "wheel"].map((value) => [value, t(`jump.window.${value}`)]))
};
}
function timeRangeMessages(t) {
return Object.fromEntries([
"apply", "clear", "completeRange", "duration", "durationDays", "durationHours", "durationMinutes", "end", "endAfterStart", "endDate", "endDateAfterStart", "endDateLaterShort", "endLaterShort", "endMark", "hour", "lessMinute", "minute", "nextMonth", "pending", "previousMonth", "quickDay", "quickHour", "quickWeek", "sameDay", "start", "startDate", "startMark", "unlimited"
].map((key) => [key, t(`time.${key}`)]));
}

View File

@ -0,0 +1,21 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, test, vi } from "vitest";
import { BannerCreateDialog } from "./BannerCreateDialog.jsx";
describe("BannerCreateDialog", () => {
test("uses the shared structured app jump field and unified time range picker", async () => {
const user = userEvent.setup();
render(<BannerCreateDialog open onClose={vi.fn()} onSubmit={vi.fn()} />);
expect(screen.getByRole("textbox", { name: "Parameter (H5 URL)" })).toBeInTheDocument();
expect(screen.getByText("Schedule")).toBeInTheDocument();
await user.click(screen.getByRole("combobox", { name: "Destination type" }));
await user.click(await screen.findByRole("option", { name: "APP" }));
expect(screen.getByRole("combobox", { name: "App destination" })).toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "Parameter (App navigation JSON)" })).toHaveValue(JSON.stringify({ type: "wallet" }));
expect(screen.queryByText("App navigation parameters are generated as JSON. You can edit the JSON directly for custom parameters.")).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,57 @@
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 MenuItem from "@mui/material/MenuItem";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import { useEffect, useState } from "react";
import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx";
export function ResourceGrantDialog({ loading, onClose, onSubmit, open, resources }) {
const { t } = useExternalI18n();
const [form, setForm] = useState(resourceGrantInitial);
useEffect(() => setForm(resourceGrantInitial), [open]);
return (
<GrantDialog loading={loading} open={open} title={t("grants.resourceTitle")} onClose={onClose} onSubmit={() => onSubmit(form)}>
<TextField disabled={loading} label={t("grants.target")} required value={form.target} onChange={(event) => setForm({ ...form, target: event.target.value.trim() })} />
<TextField disabled={loading} label={t("grants.resource")} required select value={form.resourceId} onChange={(event) => setForm({ ...form, resourceId: event.target.value })}>
{resources.map((item) => <MenuItem key={item.id} value={item.id}>{item.name} ({item.id})</MenuItem>)}
</TextField>
<TextField disabled={loading} label={t("grants.quantity")} required slotProps={{ htmlInput: { min: 1 } }} type="number" value={form.quantity} onChange={(event) => setForm({ ...form, quantity: event.target.value })} />
<TextField disabled={loading} helperText={t("grants.permanentHint")} label={t("grants.validDays")} required slotProps={{ htmlInput: { min: 0 } }} type="number" value={form.durationDays} onChange={(event) => setForm({ ...form, durationDays: event.target.value })} />
<TextField disabled={loading} label={t("grants.reason")} minRows={2} multiline required value={form.reason} onChange={(event) => setForm({ ...form, reason: event.target.value })} />
</GrantDialog>
);
}
export function PrettyIdGrantDialog({ loading, onClose, onSubmit, open }) {
const { t } = useExternalI18n();
const [form, setForm] = useState(prettyGrantInitial);
useEffect(() => setForm(prettyGrantInitial), [open]);
return (
<GrantDialog loading={loading} open={open} title={t("grants.prettyIdTitle")} onClose={onClose} onSubmit={() => onSubmit(form)}>
<TextField disabled={loading} label={t("grants.currentTarget")} required value={form.target} onChange={(event) => setForm({ ...form, target: event.target.value.trim() })} />
<TextField disabled={loading} label={t("grants.prettyId")} required slotProps={{ htmlInput: { inputMode: "numeric", pattern: "[0-9]*" } }} value={form.prettyId} onChange={(event) => setForm({ ...form, prettyId: event.target.value.trim() })} />
<TextField disabled={loading} helperText={t("grants.permanentHint")} label={t("grants.validDays")} required slotProps={{ htmlInput: { min: 0 } }} type="number" value={form.durationDays} onChange={(event) => setForm({ ...form, durationDays: event.target.value })} />
<TextField disabled={loading} label={t("grants.reason")} minRows={2} multiline required value={form.reason} onChange={(event) => setForm({ ...form, reason: event.target.value })} />
</GrantDialog>
);
}
function GrantDialog({ children, loading, onClose, onSubmit, open, title }) {
const { t } = useExternalI18n();
return (
<Dialog fullWidth maxWidth="sm" open={open} onClose={loading ? undefined : onClose}>
<Stack component="form" onSubmit={(event) => { event.preventDefault(); onSubmit(); }}>
<DialogTitle>{title}</DialogTitle>
<DialogContent><Stack className="external-dialog-fields" spacing={2}>{children}</Stack></DialogContent>
<DialogActions><Button disabled={loading} onClick={onClose}>{t("common.cancel")}</Button><Button disabled={loading} type="submit" variant="contained">{t("grants.confirm")}</Button></DialogActions>
</Stack>
</Dialog>
);
}
const resourceGrantInitial = { durationDays: "30", quantity: "1", reason: "", resourceId: "", target: "" };
const prettyGrantInitial = { durationDays: "0", prettyId: "", reason: "", target: "" };

View File

@ -0,0 +1,17 @@
import { render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { ExternalI18nProvider } from "../i18n/ExternalI18nProvider.jsx";
import { ResourceGrantDialog } from "./GrantDialogs.jsx";
test("shows only actionable grant fields without a descriptive notice", () => {
render(
<ExternalI18nProvider>
<ResourceGrantDialog loading={false} onClose={vi.fn()} onSubmit={vi.fn()} open resources={[]} />
</ExternalI18nProvider>
);
expect(screen.getByRole("dialog", { name: "Grant privilege item" })).toBeInTheDocument();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByText("Before submission, the user is verified in the current App and the server-returned user ID is used for the grant.")).not.toBeInTheDocument();
expect(screen.getByText("Enter 0 for permanent validity")).toBeInTheDocument();
});

View File

@ -0,0 +1,32 @@
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 Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import { useEffect, useState } from "react";
import { ExternalImageUploadField } from "../shared/ExternalImageUploadField.jsx";
import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx";
export function RoomEditDialog({ loading, onClose, onSubmit, open, room }) {
const { t } = useExternalI18n();
const [form, setForm] = useState({ coverUrl: "", description: "", title: "", visibleRegionId: "" });
useEffect(() => {
setForm({ coverUrl: room?.coverUrl || "", description: room?.description || "", title: room?.title || "", visibleRegionId: room?.visibleRegionId || "" });
}, [room]);
return (
<Dialog fullWidth maxWidth="sm" open={open} onClose={loading ? undefined : onClose}>
<Stack component="form" onSubmit={(event) => { event.preventDefault(); onSubmit(form); }}>
<DialogTitle>{t("rooms.editTitle")}</DialogTitle>
<DialogContent><Stack className="external-dialog-fields" spacing={2}>
<ExternalImageUploadField disabled={loading} label={t("rooms.background")} value={form.coverUrl} onChange={(coverUrl) => setForm({ ...form, coverUrl })} />
<TextField disabled={loading} label={t("rooms.name")} required value={form.title} onChange={(event) => setForm({ ...form, title: event.target.value })} />
<TextField disabled={loading} label={t("rooms.description")} minRows={3} multiline value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
<TextField disabled={loading} label={t("rooms.visibleRegionId")} value={form.visibleRegionId} onChange={(event) => setForm({ ...form, visibleRegionId: event.target.value.trim() })} />
</Stack></DialogContent>
<DialogActions><Button disabled={loading} onClick={onClose}>{t("common.cancel")}</Button><Button disabled={loading} type="submit" variant="contained">{t("common.save")}</Button></DialogActions>
</Stack>
</Dialog>
);
}

View File

@ -0,0 +1,74 @@
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 MenuItem from "@mui/material/MenuItem";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import { useEffect, useState } from "react";
import { ExternalImageUploadField } from "../shared/ExternalImageUploadField.jsx";
import { useExternalI18n } from "../i18n/ExternalI18nProvider.jsx";
export function UserEditDialog({ loading, onClose, onSubmit, open, user }) {
const { t } = useExternalI18n();
const [form, setForm] = useState(emptyEditForm);
useEffect(() => {
setForm({ avatar: user?.avatar || "", country: user?.country || "", gender: user?.gender || "", username: user?.username || "" });
}, [user]);
return (
<BaseDialog loading={loading} open={open} title={t("users.editTitle")} onClose={onClose} onSubmit={() => onSubmit(form)}>
<ExternalImageUploadField disabled={loading} label={t("users.avatar")} value={form.avatar} onChange={(avatar) => setForm({ ...form, avatar })} />
<TextField disabled={loading} label={t("users.name")} required value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} />
<TextField disabled={loading} label={t("users.gender")} select value={form.gender} onChange={(event) => setForm({ ...form, gender: event.target.value })}>
<MenuItem value="">{t("users.notSet")}</MenuItem><MenuItem value="male">{t("users.genderMale")}</MenuItem><MenuItem value="female">{t("users.genderFemale")}</MenuItem><MenuItem value="unknown">{t("users.genderUnknown")}</MenuItem>
</TextField>
<TextField disabled={loading} label={t("users.countryCode")} value={form.country} onChange={(event) => setForm({ ...form, country: event.target.value.trim().toUpperCase() })} />
</BaseDialog>
);
}
export function UserBanDialog({ loading, onClose, onSubmit, open, user }) {
const { t } = useExternalI18n();
const [form, setForm] = useState({ days: "7", reason: "" });
useEffect(() => setForm({ days: "7", reason: "" }), [user]);
return (
<BaseDialog loading={loading} open={open} title={t("users.banTitle", { user: user?.prettyId || user?.username || "" })} onClose={onClose} onSubmit={() => onSubmit(form)}>
<TextField disabled={loading} helperText={t("users.banForeverHint")} label={t("users.banDays")} required slotProps={{ htmlInput: { min: 0 } }} type="number" value={form.days} onChange={(event) => setForm({ ...form, days: event.target.value })} />
<TextField disabled={loading} label={t("users.banReason")} minRows={3} multiline required value={form.reason} onChange={(event) => setForm({ ...form, reason: event.target.value })} />
</BaseDialog>
);
}
export function UserLevelDialog({ loading, onClose, onSubmit, open, user }) {
const { t } = useExternalI18n();
const [form, setForm] = useState({ days: "30", level: "", reason: "", track: "wealth" });
useEffect(() => setForm({ days: "30", level: "", reason: "", track: "wealth" }), [user]);
return (
<BaseDialog loading={loading} open={open} title={t("users.levelTitle", { user: user?.prettyId || user?.username || "" })} onClose={onClose} onSubmit={() => onSubmit(form)}>
<TextField disabled={loading} label={t("users.levelType")} select value={form.track} onChange={(event) => setForm({ ...form, track: event.target.value })}><MenuItem value="wealth">{t("users.wealthLevel")}</MenuItem><MenuItem value="charm">{t("users.charmLevel")}</MenuItem><MenuItem value="vip">{t("users.vipLevel")}</MenuItem></TextField>
<TextField disabled={loading} label={t("users.level")} required slotProps={{ htmlInput: { min: 1 } }} type="number" value={form.level} onChange={(event) => setForm({ ...form, level: event.target.value })} />
<TextField disabled={loading} helperText={t("users.validDaysMin")} label={t("users.validDays")} required slotProps={{ htmlInput: { min: 1 } }} type="number" value={form.days} onChange={(event) => setForm({ ...form, days: event.target.value })} />
<TextField disabled={loading} label={t("users.grantReason")} minRows={2} multiline required value={form.reason} onChange={(event) => setForm({ ...form, reason: event.target.value })} />
</BaseDialog>
);
}
function BaseDialog({ children, loading, onClose, onSubmit, open, title }) {
const { t } = useExternalI18n();
const handleSubmit = (event) => {
event.preventDefault();
onSubmit();
};
return (
<Dialog fullWidth maxWidth="sm" open={open} onClose={loading ? undefined : onClose}>
<Stack component="form" onSubmit={handleSubmit}>
<DialogTitle>{title}</DialogTitle>
<DialogContent><Stack className="external-dialog-fields" spacing={2}>{children}</Stack></DialogContent>
<DialogActions><Button disabled={loading} onClick={onClose}>{t("common.cancel")}</Button><Button disabled={loading} type="submit" variant="contained">{t("common.confirm")}</Button></DialogActions>
</Stack>
</Dialog>
);
}
const emptyEditForm = { avatar: "", country: "", gender: "", username: "" };

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