Compare commits

...

13 Commits

Author SHA1 Message Date
marco 5ae87fc11e feat(cps): 群详情页加按天明细大表格(退款佣金/净佣金/转化)
- 天级范围(3/7/14/30天)显示每日明细表;今日小时模式隐藏(看上面折线)
- 列:点击/独立/复制/点击→下单/有效单/取消风控/成交额/预估佣金/退款佣金/净佣金/结算佣金
- 净佣金=预估-退款(前端算);非美团群订单列显示 "-"

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 17:32:20 +08:00
marco f4043f2abf fix(admin-web): 比价记录页时区/cancelled/详情展示位修复
- 列表时间改 formatWallTime(created_at 北京 wall-clock 口径, 原 formatUtcTime 快 8h)
- cancelled 加颜色/中文(成功/失败/已终止) + 筛选选项
- 详情补 下单商品(items+跳过菜)/最优深链/device_id 展示位
- comparison_results 空值兜底

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 23:32:36 +08:00
marco 472912560c fix(cps): 详情页折线点大小用 style.r(sizeField 是字段映射不能给常量)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 23:06:06 +08:00
marco 9afb213b50 feat(admin): 比价记录 debug 页
按 user_id/手机号查任意用户的全部比价记录:列表(平台/店菜/省/耗时/步数/LLM次数/重试/机型ROM/状态)
+ 详情抽屉(逐平台对比、卡在哪一步=platform_results 细分原因、设备环境、每次 LLM 调用 input/output
JSON 结构化展开、trace 可点链接、raw_payload 全量)。侧栏新增「比价记录」入口。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 22:33:37 +08:00
marco 1a6e590a42 feat(cps): 群对账详情页折线图(点击/独立/复制趋势,3/7/14/30天+小时)
- 对账统计页群名可点进群详情页 /cps/groups/[id]
- @ant-design/plots 折线图,4 指标多线,Segmented 切时间范围,y 轴 0 基线
- 数据稀疏靠后端补零保证折线连续/x 轴铺满

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 22:31:37 +08:00
marco cef5ea0140 feat(cps): 活动表单支持编辑 + 列表加编辑按钮
- ActivityFormModal 改 create+edit 双模式(回填字段, 编辑走 PATCH, 加状态切换)
- 活动列表操作列加「编辑」

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 22:13:36 +08:00
marco b1923c3133 feat(cps): 群/活动列表加删除按钮(Popconfirm 二次确认)
- 群操作列 + 删除;活动列表新增操作列含删除
- 确认弹窗说明影响(已发链接仍可用 / 淘宝活动删后落地页用默认图)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 21:43:35 +08:00
marco ecbce54e70 feat(cps): 新建淘宝活动支持上传/选择落地页图 + 列表缩略图
- ActivityFormModal 淘宝分支加 ImagePicker(上传新图 / 选已有 / 预览),必填
- 活动列表加落地页图缩略图列
- types: CpsActivity.image_url

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 21:32:12 +08:00
marco 654aca4c40 feat(cps): 分发后台从仅美团扩展到美团/淘宝/京东多平台
- 群/活动支持平台多选,sid 仅美团群保留
- 活动按平台切换录入(美团 actId/pvs、淘宝淘口令、京东链接)
- 生成发群链接改批量勾选多活动(/cps/referral-links)
- 统计表加平台/复制列,淘宝/京东无对账字段显示 "-"

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 19:15:27 +08:00
ouzhou a393b5c0e3 feat(config): 配置页 bool 开关控件 + 全站 antd App.useApp 迁移 (#12)
- 系统配置页支持 bool 类型(Switch 控件),供「提现自动对账」「比价期广告」等运营开关使用
- message / Modal.confirm 静态调用迁移到 App.useApp() 的 message / modal,消除 React 19
  "Static function can not consume context" 告警;providers 用 <App> 容器包裹全站

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #12
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-17 10:38:40 +08:00
marco 9134ea9255 feat(cps): CPS 分发与对账后台页
群/活动管理 + 生成带 sid 短链(/c/{code}) + 对账统计(点击 PV/UV + 下单/佣金)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 10:01:08 +08:00
ouzhou f82f511b84 feat(ad-revenue): 广告数据报表页(替换金币审计页) (#11)
- 新增 /ad-revenue:聚合表 + 派生指标(平均 eCPM/毛利/发奖占比)+ 零依赖 SVG 趋势图
  + 行展开下钻展示明细/发奖复算明细
- 删除旧 /ad-audit 页;侧栏「金币审计」→「广告数据」
- feedbacks 由「加载更多」改 antd 页码分页(usePagedList),内容列 3 行省略可展开
- types 用 AdRevenue* 替换 AdCoinAudit*;providers 统一主题圆角/页面底色

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #11
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-15 23:13:22 +08:00
ouzhou 05a1d06e61 fix(admin-web): 时间渲染收口到 formatUtcTime,消灭 new Date().toLocaleString (#10)
review 发现 4 处仍用 new Date(v).toLocaleString 渲染后端 UTC 口径时间戳——
正是 lib/format.ts 头注释明令消灭的反模式(SQLite 本地/非中国浏览器会差 8h,
生产靠 +00:00 偏移侥幸正确)。统一改用 formatUtcTime 按北京显示:
- admins: last_login_at
- audit-logs: created_at
- devices: completed_at
- dashboard/HomeStatsConfig: ops_stat_config.updated_at
均为 server_default now()/datetime.now(utc) 的 UTC 口径字段。
HomeStatsConfig 的「下个更新时刻」用 epoch ms + 强制 Asia/Shanghai,正确,不动。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #10
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-14 22:54:24 +08:00
24 changed files with 3818 additions and 411 deletions
+874 -1
View File
@@ -11,6 +11,7 @@
"@ant-design/cssinjs": "^1.24.0",
"@ant-design/icons": "^5.5.2",
"@ant-design/nextjs-registry": "^1.0.2",
"@ant-design/plots": "^2.6.8",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"antd": "^5.22.0",
"axios": "^1.7.7",
@@ -26,6 +27,19 @@
"typescript": "^5.6.3"
}
},
"node_modules/@ant-design/charts-util": {
"version": "0.0.3",
"resolved": "https://registry.npmmirror.com/@ant-design/charts-util/-/charts-util-0.0.3.tgz",
"integrity": "sha512-x1H7UT6t4dXAyGRoHqlOnEsEqBSTANFGTZEAMI0CWYhYUpp13n0o9grl9oPtoL6FEQMjUBTY+zGJKlHkz8smMw==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.21"
},
"peerDependencies": {
"react": ">=16.8.4",
"react-dom": ">=16.8.4"
}
},
"node_modules/@ant-design/colors": {
"version": "7.2.1",
"resolved": "https://registry.npmmirror.com/@ant-design/colors/-/colors-7.2.1.tgz",
@@ -120,6 +134,24 @@
"react-dom": ">=16.0.0"
}
},
"node_modules/@ant-design/plots": {
"version": "2.6.8",
"resolved": "https://registry.npmmirror.com/@ant-design/plots/-/plots-2.6.8.tgz",
"integrity": "sha512-QsunUs2d5rbq/1BwVhga/siA5H50OaG23YopMYwPD4sPsza6NQzPQ8FM3elNIsD/BIk298tihqX1cJ/MmvVJbQ==",
"license": "MIT",
"dependencies": {
"@ant-design/charts-util": "0.0.3",
"@antv/event-emitter": "^0.1.3",
"@antv/g": "^6.1.7",
"@antv/g2": "^5.2.7",
"@antv/g2-extension-plot": "^0.2.1",
"lodash": "^4.17.21"
},
"peerDependencies": {
"react": ">=16.8.4",
"react-dom": ">=16.8.4"
}
},
"node_modules/@ant-design/react-slick": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/@ant-design/react-slick/-/react-slick-1.1.2.tgz",
@@ -150,6 +182,251 @@
"react-dom": ">=19.0.0"
}
},
"node_modules/@antv/component": {
"version": "2.1.11",
"resolved": "https://registry.npmmirror.com/@antv/component/-/component-2.1.11.tgz",
"integrity": "sha512-dTdz8VAd3rpjOaGEZTluz82mtzrP4XCtNlNQyrxY7VNRNcjtvpTLDn57bUL2lRu1T+iklKvgbE2llMriWkq9vQ==",
"license": "MIT",
"dependencies": {
"@antv/g": "^6.1.11",
"@antv/scale": "^0.4.16",
"@antv/util": "^3.3.10",
"svg-path-parser": "^1.1.0"
}
},
"node_modules/@antv/component/node_modules/@antv/scale": {
"version": "0.4.16",
"resolved": "https://registry.npmmirror.com/@antv/scale/-/scale-0.4.16.tgz",
"integrity": "sha512-5wg/zB5kXHxpTV5OYwJD3ja6R8yTiqIOkjOhmpEJiowkzRlbEC/BOyMvNUq5fqFIHnMCE9woO7+c3zxEQCKPjw==",
"license": "MIT",
"dependencies": {
"@antv/util": "^3.3.7",
"color-string": "^1.5.5",
"fecha": "^4.2.1"
}
},
"node_modules/@antv/coord": {
"version": "0.4.7",
"resolved": "https://registry.npmmirror.com/@antv/coord/-/coord-0.4.7.tgz",
"integrity": "sha512-UTbrMLhwJUkKzqJx5KFnSRpU3BqrdLORJbwUbHK2zHSCT3q3bjcFA//ZYLVfIlwqFDXp/hzfMyRtp0c77A9ZVA==",
"license": "MIT",
"dependencies": {
"@antv/scale": "^0.4.12",
"@antv/util": "^2.0.13",
"gl-matrix": "^3.4.3"
}
},
"node_modules/@antv/coord/node_modules/@antv/scale": {
"version": "0.4.16",
"resolved": "https://registry.npmmirror.com/@antv/scale/-/scale-0.4.16.tgz",
"integrity": "sha512-5wg/zB5kXHxpTV5OYwJD3ja6R8yTiqIOkjOhmpEJiowkzRlbEC/BOyMvNUq5fqFIHnMCE9woO7+c3zxEQCKPjw==",
"license": "MIT",
"dependencies": {
"@antv/util": "^3.3.7",
"color-string": "^1.5.5",
"fecha": "^4.2.1"
}
},
"node_modules/@antv/coord/node_modules/@antv/scale/node_modules/@antv/util": {
"version": "3.3.11",
"resolved": "https://registry.npmmirror.com/@antv/util/-/util-3.3.11.tgz",
"integrity": "sha512-FII08DFM4ABh2q5rPYdr0hMtKXRgeZazvXaFYCs7J7uTcWDHUhczab2qOCJLNDugoj8jFag1djb7wS9ehaRYBg==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"gl-matrix": "^3.3.0",
"tslib": "^2.3.1"
}
},
"node_modules/@antv/coord/node_modules/@antv/util": {
"version": "2.0.17",
"resolved": "https://registry.npmmirror.com/@antv/util/-/util-2.0.17.tgz",
"integrity": "sha512-o6I9hi5CIUvLGDhth0RxNSFDRwXeywmt6ExR4+RmVAzIi48ps6HUy+svxOCayvrPBN37uE6TAc2KDofRo0nK9Q==",
"license": "ISC",
"dependencies": {
"csstype": "^3.0.8",
"tslib": "^2.0.3"
}
},
"node_modules/@antv/event-emitter": {
"version": "0.1.3",
"resolved": "https://registry.npmmirror.com/@antv/event-emitter/-/event-emitter-0.1.3.tgz",
"integrity": "sha512-4ddpsiHN9Pd4UIlWuKVK1C4IiZIdbwQvy9i7DUSI3xNJ89FPUFt8lxDYj8GzzfdllV0NkJTRxnG+FvLk0llidg==",
"license": "MIT"
},
"node_modules/@antv/expr": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/@antv/expr/-/expr-1.0.2.tgz",
"integrity": "sha512-vrfdmPHkTuiS5voVutKl2l06w1ihBh9A8SFdQPEE+2KMVpkymzGOF1eWpfkbGZ7tiFE15GodVdhhHomD/hdIwg==",
"license": "MIT"
},
"node_modules/@antv/g": {
"version": "6.3.1",
"resolved": "https://registry.npmmirror.com/@antv/g/-/g-6.3.1.tgz",
"integrity": "sha512-WYEKqy86LHB2PzTmrZXrIsIe+3Epeds2f68zceQ+BJtRoGki7Sy4IhlC8LrUMztgfT1t3d/0L745NWZwITroKA==",
"license": "MIT",
"dependencies": {
"@antv/g-lite": "2.7.0",
"@antv/util": "^3.3.5",
"@babel/runtime": "^7.25.6",
"gl-matrix": "^3.4.3",
"html2canvas": "^1.4.1"
}
},
"node_modules/@antv/g-canvas": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/@antv/g-canvas/-/g-canvas-2.2.0.tgz",
"integrity": "sha512-h7zVBBo2aO64DuGKvq9sG+yTU3sCUb9DALCVm7nz8qGPs8hhLuFOkKPEzUDNfNYZGJUGzY8UDtJ3QRGRFcvEQg==",
"license": "MIT",
"dependencies": {
"@antv/g-lite": "2.7.0",
"@antv/g-math": "3.1.0",
"@antv/util": "^3.3.5",
"@babel/runtime": "^7.25.6",
"gl-matrix": "^3.4.3",
"tslib": "^2.5.3"
}
},
"node_modules/@antv/g-lite": {
"version": "2.7.0",
"resolved": "https://registry.npmmirror.com/@antv/g-lite/-/g-lite-2.7.0.tgz",
"integrity": "sha512-uSzgHYa5bwR5L2Au7/5tsOhFmXKZKLPBH90+Q9bP9teVs5VT4kOAi0isPSpDI8uhdDC2/VrfTWu5K9HhWI6FWw==",
"license": "MIT",
"dependencies": {
"@antv/g-math": "3.1.0",
"@antv/util": "^3.3.5",
"@antv/vendor": "^1.0.3",
"@babel/runtime": "^7.25.6",
"eventemitter3": "^5.0.1",
"gl-matrix": "^3.4.3",
"tslib": "^2.5.3"
}
},
"node_modules/@antv/g-math": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/@antv/g-math/-/g-math-3.1.0.tgz",
"integrity": "sha512-DtN1Gj/yI0UiK18nSBsZX8RK0LszGwqfb+cBYWgE+ddyTm8dZnW4tPUhV7QXePsS6/A5hHC+JFpAAK7OEGo5ZQ==",
"license": "MIT",
"dependencies": {
"@antv/util": "^3.3.5",
"@babel/runtime": "^7.25.6",
"gl-matrix": "^3.4.3",
"tslib": "^2.5.3"
}
},
"node_modules/@antv/g-plugin-dragndrop": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/@antv/g-plugin-dragndrop/-/g-plugin-dragndrop-2.1.1.tgz",
"integrity": "sha512-+aesDUJVQDs6UJ2bOBbDlaGAPCfHmU0MbrMTlQlfpwNplWueqtgVAZ3L57oZ2ZGHRWUHiRwZGPjXMBM3O2LELw==",
"license": "MIT",
"dependencies": {
"@antv/g-lite": "2.7.0",
"@antv/util": "^3.3.5",
"@babel/runtime": "^7.25.6",
"tslib": "^2.5.3"
}
},
"node_modules/@antv/g2": {
"version": "5.4.8",
"resolved": "https://registry.npmmirror.com/@antv/g2/-/g2-5.4.8.tgz",
"integrity": "sha512-IvgIpwmT4M5/QAd3Mn2WiHIDeBqFJ4WA2gcZhRRSZuZ2KmgCqZWZwwIT0hc+kIGxwYeDoCQqf//t6FMVu3ryBg==",
"license": "MIT",
"dependencies": {
"@antv/component": "^2.1.9",
"@antv/coord": "^0.4.7",
"@antv/event-emitter": "^0.1.3",
"@antv/expr": "^1.0.2",
"@antv/g": "^6.1.24",
"@antv/g-canvas": "^2.0.43",
"@antv/g-plugin-dragndrop": "^2.0.35",
"@antv/scale": "^0.5.1",
"@antv/util": "^3.3.10",
"@antv/vendor": "^1.0.11",
"flru": "^1.0.2",
"pdfast": "^0.2.0"
}
},
"node_modules/@antv/g2-extension-plot": {
"version": "0.2.2",
"resolved": "https://registry.npmmirror.com/@antv/g2-extension-plot/-/g2-extension-plot-0.2.2.tgz",
"integrity": "sha512-KJXCXO7as+h0hDqirGXf1omrNuYzQmY3VmBmp7lIvkepbQ7sz3pPwy895r1FWETGF3vTk5UeFcAF5yzzBHWgbw==",
"dependencies": {
"@antv/g2": "^5.1.8",
"@antv/util": "^3.3.5",
"@antv/vendor": "^1.0.10"
}
},
"node_modules/@antv/scale": {
"version": "0.5.2",
"resolved": "https://registry.npmmirror.com/@antv/scale/-/scale-0.5.2.tgz",
"integrity": "sha512-rTHRAwvpHWC5PGZF/mJ2ZuTDqwwvVBDRph0Uu5PV9BXwzV7K8+9lsqGJ+XHVLxe8c6bKog5nlzvV/dcYb0d5Ow==",
"license": "MIT",
"dependencies": {
"@antv/util": "^3.3.7",
"color-string": "^1.5.5",
"fecha": "^4.2.1"
}
},
"node_modules/@antv/util": {
"version": "3.3.11",
"resolved": "https://registry.npmmirror.com/@antv/util/-/util-3.3.11.tgz",
"integrity": "sha512-FII08DFM4ABh2q5rPYdr0hMtKXRgeZazvXaFYCs7J7uTcWDHUhczab2qOCJLNDugoj8jFag1djb7wS9ehaRYBg==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"gl-matrix": "^3.3.0",
"tslib": "^2.3.1"
}
},
"node_modules/@antv/vendor": {
"version": "1.0.11",
"resolved": "https://registry.npmmirror.com/@antv/vendor/-/vendor-1.0.11.tgz",
"integrity": "sha512-LmhPEQ+aapk3barntaiIxJ5VHno/Tyab2JnfdcPzp5xONh/8VSfed4bo/9xKo5HcUAEydko38vYLfj6lJliLiw==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.2.1",
"@types/d3-color": "^3.1.3",
"@types/d3-dispatch": "^3.0.6",
"@types/d3-dsv": "^3.0.7",
"@types/d3-ease": "^3.0.2",
"@types/d3-fetch": "^3.0.7",
"@types/d3-force": "^3.0.10",
"@types/d3-format": "^3.0.4",
"@types/d3-geo": "^3.1.0",
"@types/d3-hierarchy": "^3.1.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-path": "^3.1.0",
"@types/d3-quadtree": "^3.0.6",
"@types/d3-random": "^3.0.3",
"@types/d3-scale": "^4.0.9",
"@types/d3-scale-chromatic": "^3.1.0",
"@types/d3-shape": "^3.1.7",
"@types/d3-time": "^3.0.4",
"@types/d3-timer": "^3.0.2",
"d3-array": "^3.2.4",
"d3-color": "^3.1.0",
"d3-dispatch": "^3.0.1",
"d3-dsv": "^3.0.1",
"d3-ease": "^3.0.1",
"d3-fetch": "^3.0.1",
"d3-force": "^3.0.0",
"d3-force-3d": "^3.0.5",
"d3-format": "^3.1.0",
"d3-geo": "^3.1.1",
"d3-geo-projection": "^4.0.0",
"d3-hierarchy": "^3.1.2",
"d3-interpolate": "^3.0.1",
"d3-path": "^3.1.0",
"d3-quadtree": "^3.0.1",
"d3-random": "^3.0.1",
"d3-regression": "^1.3.10",
"d3-scale": "^4.0.2",
"d3-scale-chromatic": "^3.1.0",
"d3-shape": "^3.2.0",
"d3-time": "^3.1.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz",
@@ -981,6 +1258,141 @@
"tslib": "^2.8.0"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-dispatch": {
"version": "3.0.7",
"resolved": "https://registry.npmmirror.com/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
"integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
"license": "MIT"
},
"node_modules/@types/d3-dsv": {
"version": "3.0.7",
"resolved": "https://registry.npmmirror.com/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-fetch": {
"version": "3.0.7",
"resolved": "https://registry.npmmirror.com/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
"license": "MIT",
"dependencies": {
"@types/d3-dsv": "*"
}
},
"node_modules/@types/d3-force": {
"version": "3.0.10",
"resolved": "https://registry.npmmirror.com/@types/d3-force/-/d3-force-3.0.10.tgz",
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
"license": "MIT"
},
"node_modules/@types/d3-format": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/@types/d3-format/-/d3-format-3.0.4.tgz",
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
"license": "MIT"
},
"node_modules/@types/d3-geo": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/@types/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/d3-hierarchy": {
"version": "3.1.7",
"resolved": "https://registry.npmmirror.com/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-quadtree": {
"version": "3.0.6",
"resolved": "https://registry.npmmirror.com/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
"license": "MIT"
},
"node_modules/@types/d3-random": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/@types/d3-random/-/d3-random-3.0.3.tgz",
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmmirror.com/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
"license": "MIT"
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.20",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-22.19.20.tgz",
@@ -1114,6 +1526,15 @@
"proxy-from-env": "^2.1.0"
}
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -1159,6 +1580,22 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmmirror.com/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"license": "MIT",
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -1171,6 +1608,15 @@
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmmirror.com/commander/-/commander-7.2.0.tgz",
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/compute-scroll-into-view": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
@@ -1186,12 +1632,309 @@
"toggle-selection": "^1.0.6"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-binarytree": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/d3-binarytree/-/d3-binarytree-1.0.2.tgz",
"integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==",
"license": "MIT"
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dsv": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/d3-dsv/-/d3-dsv-3.0.1.tgz",
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
"license": "ISC",
"dependencies": {
"commander": "7",
"iconv-lite": "0.6",
"rw": "1"
},
"bin": {
"csv2json": "bin/dsv2json.js",
"csv2tsv": "bin/dsv2dsv.js",
"dsv2dsv": "bin/dsv2dsv.js",
"dsv2json": "bin/dsv2json.js",
"json2csv": "bin/json2dsv.js",
"json2dsv": "bin/json2dsv.js",
"json2tsv": "bin/json2dsv.js",
"tsv2csv": "bin/dsv2dsv.js",
"tsv2json": "bin/dsv2json.js"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-fetch": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/d3-fetch/-/d3-fetch-3.0.1.tgz",
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
"license": "ISC",
"dependencies": {
"d3-dsv": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-force": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/d3-force/-/d3-force-3.0.0.tgz",
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-force-3d": {
"version": "3.0.6",
"resolved": "https://registry.npmmirror.com/d3-force-3d/-/d3-force-3d-3.0.6.tgz",
"integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==",
"license": "MIT",
"dependencies": {
"d3-binarytree": "1",
"d3-dispatch": "1 - 3",
"d3-octree": "1",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-geo": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/d3-geo/-/d3-geo-3.1.1.tgz",
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2.5.0 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-geo-projection": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/d3-geo-projection/-/d3-geo-projection-4.0.0.tgz",
"integrity": "sha512-p0bK60CEzph1iqmnxut7d/1kyTmm3UWtPlwdkM31AU+LW+BXazd5zJdoCn7VFxNCHXRngPHRnsNn5uGjLRGndg==",
"license": "ISC",
"dependencies": {
"commander": "7",
"d3-array": "1 - 3",
"d3-geo": "1.12.0 - 3"
},
"bin": {
"geo2svg": "bin/geo2svg.js",
"geograticule": "bin/geograticule.js",
"geoproject": "bin/geoproject.js",
"geoquantize": "bin/geoquantize.js",
"geostitch": "bin/geostitch.js"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-hierarchy": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-octree": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/d3-octree/-/d3-octree-1.1.0.tgz",
"integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==",
"license": "MIT"
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-quadtree": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-random": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/d3-random/-/d3-random-3.0.1.tgz",
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-regression": {
"version": "1.3.10",
"resolved": "https://registry.npmmirror.com/d3-regression/-/d3-regression-1.3.10.tgz",
"integrity": "sha512-PF8GWEL70cHHWpx2jUQXc68r1pyPHIA+St16muk/XRokETzlegj5LriNKg7o4LR0TySug4nHYPJNNRz/W+/Niw==",
"license": "BSD-3-Clause"
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-interpolate": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/dayjs": {
"version": "1.11.21",
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz",
@@ -1293,6 +2036,33 @@
"node": ">= 0.4"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fecha": {
"version": "4.2.3",
"resolved": "https://registry.npmmirror.com/fecha/-/fecha-4.2.3.tgz",
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
"license": "MIT"
},
"node_modules/flru": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/flru/-/flru-1.0.2.tgz",
"integrity": "sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz",
@@ -1375,6 +2145,12 @@
"node": ">= 0.4"
}
},
"node_modules/gl-matrix": {
"version": "3.4.4",
"resolved": "https://registry.npmmirror.com/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"license": "MIT"
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
@@ -1426,6 +2202,19 @@
"node": ">= 0.4"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
@@ -1439,6 +2228,33 @@
"node": ">= 6"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.3.4.tgz",
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1454,6 +2270,12 @@
"string-convert": "^0.2.0"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1588,6 +2410,12 @@
"node": ">= 10"
}
},
"node_modules/pdfast": {
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/pdfast/-/pdfast-0.2.0.tgz",
"integrity": "sha512-cq6TTu6qKSFUHwEahi68k/kqN2mfepjkGrG9Un70cgdRRKLKY6Rf8P8uvP2NvZktaQZNF3YE7agEkLj0vGK9bA==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
@@ -2274,6 +3102,18 @@
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
"license": "MIT"
},
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmmirror.com/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz",
@@ -2350,6 +3190,15 @@
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/simple-swizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmmirror.com/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2394,6 +3243,21 @@
"integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
"license": "MIT"
},
"node_modules/svg-path-parser": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/svg-path-parser/-/svg-path-parser-1.1.0.tgz",
"integrity": "sha512-jGCUqcQyXpfe38R7RFfhrMyfXcBmpMNJI/B+4CE9/Unkh98UporAc461GTthv+TVDuZXsBx7/WiwJb1Oh4tt4A==",
"license": "MIT"
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/throttle-debounce": {
"version": "5.0.2",
"resolved": "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
@@ -2436,9 +3300,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.5.19",
"resolved": "https://registry.npmmirror.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.19.tgz",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.19.tgz",
"integrity": "sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w==",
"cpu": [
"x64"
+1
View File
@@ -12,6 +12,7 @@
"@ant-design/cssinjs": "^1.24.0",
"@ant-design/icons": "^5.5.2",
"@ant-design/nextjs-registry": "^1.0.2",
"@ant-design/plots": "^2.6.8",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"antd": "^5.22.0",
"axios": "^1.7.7",
-247
View File
@@ -1,247 +0,0 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import {
Alert,
Button,
Card,
Checkbox,
DatePicker,
InputNumber,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import dayjs, { type Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format';
import type { AdCoinAudit, AdCoinAuditRow } from '@/lib/types';
const SCENE_TAG: Record<string, { color: string; label: string }> = {
reward_video: { color: 'blue', label: '看视频' },
feed: { color: 'purple', label: '信息流' },
};
const STATUS_TAG: Record<string, string> = {
granted: 'green',
capped: 'orange',
ecpm_missing: 'red',
};
const fmtFactorRange = (a: number | null, b: number | null) => {
if (a == null) return '-';
return a === b || b == null ? String(a) : `${a}${b}`;
};
const fmtIndexRange = (a: number | null, b: number | null) => {
if (a == null) return '-';
return a === b ? String(a) : `${a}${b}`;
};
export default function AdAuditPage() {
const [date, setDate] = useState<Dayjs>(dayjs());
const [userId, setUserId] = useState<number | null>(null);
const [scene, setScene] = useState<string | undefined>();
const [limit, setLimit] = useState<number>(100);
const [data, setData] = useState<AdCoinAudit | null>(null);
const [queriedLimit, setQueriedLimit] = useState<number>(100); // 本次结果对应的 limit,截断提示里展示
const [onlyMismatch, setOnlyMismatch] = useState(false); // 只看不一致(✗)行
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<AdCoinAudit>('/admin/api/ad-coin-audit', {
params: {
date: date.format('YYYY-MM-DD'),
user_id: userId ?? undefined,
scene: scene ?? undefined,
limit,
only_mismatch: onlyMismatch || undefined,
},
});
setData(res.data);
setQueriedLimit(limit);
} catch (e) {
message.error(errMsg(e));
} finally {
setLoading(false);
}
}, [date, userId, scene, limit, onlyMismatch]);
useEffect(() => {
load();
// 仅首次自动拉今天;之后由「查询」按钮触发
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const columns: ColumnsType<AdCoinAuditRow> = [
{
title: '场景',
dataIndex: 'scene',
width: 90,
render: (s: string) => {
const t = SCENE_TAG[s] ?? { color: 'default', label: s };
return <Tag color={t.color}>{t.label}</Tag>;
},
},
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 175 },
{ title: '用户', dataIndex: 'user_id', width: 70 },
{
title: '状态',
dataIndex: 'status',
width: 110,
render: (s: string) => <Tag color={STATUS_TAG[s] ?? 'default'}>{s}</Tag>,
},
{ title: 'eCPM', dataIndex: 'ecpm', width: 90, render: (v: string | null) => v ?? '-' },
{
title: '因子1',
dataIndex: 'ecpm_factor',
width: 70,
render: (v: number | null) => v ?? '-',
},
{ title: '份数', dataIndex: 'units', width: 60 },
{
title: 'LT累计条数',
key: 'lt_index',
width: 100,
render: (_: unknown, r: AdCoinAuditRow) => fmtIndexRange(r.lt_index_start, r.lt_index_end),
},
{
title: '因子2',
key: 'lt_factor',
width: 90,
render: (_: unknown, r: AdCoinAuditRow) => fmtFactorRange(r.lt_factor_start, r.lt_factor_end),
},
{
title: '应发金币',
dataIndex: 'expected_coin',
width: 100,
render: (v: number) => <b>{v}</b>,
},
{ title: '实发金币', dataIndex: 'actual_coin', width: 100 },
{
title: '一致',
dataIndex: 'matched',
width: 70,
render: (m: boolean) =>
m ? <Tag color="green"></Tag> : <Tag color="red"> </Tag>,
},
];
return (
<div>
<h2>广</h2>
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
广,,
</Typography.Paragraph>
{data && (
<Card size="small" style={{ marginBottom: 16 }}>
<Typography.Text strong>:</Typography.Text>{' '}
<Typography.Text code>{data.formula.description}</Typography.Text>
<div style={{ marginTop: 8, color: '#666', fontSize: 13 }}>
<div>
eCPM :{data.formula.ecpm_unit}, ÷100 ;金币: ={' '}
{data.formula.coin_per_yuan}:1; {data.formula.feed_unit_seconds} 1 LT累计条数
</div>
<div style={{ marginTop: 4 }}>
1( eCPM ,阈值单位:):
{data.formula.ecpm_factor_tiers
.map(([f, lo, hi]) => `${lo}~${hi ?? '∞'}元→${f}`)
.join(' | ')}
</div>
<div style={{ marginTop: 4 }}>
2(LT ):
{data.formula.lt_factor_tiers
.map(([f, lo, hi]) => `${lo}${hi != null && hi !== lo ? `~${hi}` : ''}${f}`)
.join(' | ')}
</div>
</div>
</Card>
)}
<Space style={{ marginBottom: 16 }} wrap>
<DatePicker value={date} onChange={(d) => d && setDate(d)} allowClear={false} />
<InputNumber
placeholder="用户 ID(可空)"
value={userId ?? undefined}
onChange={(v) => setUserId(v ?? null)}
style={{ width: 140 }}
/>
<Select
placeholder="场景(全部)"
value={scene}
onChange={setScene}
allowClear
style={{ width: 140 }}
options={[
{ value: 'reward_video', label: '看视频' },
{ value: 'feed', label: '信息流' },
]}
/>
<Select
value={limit}
onChange={setLimit}
style={{ width: 120 }}
options={[50, 100, 200, 500].map((n) => ({ value: n, label: `${n}` }))}
/>
<Checkbox checked={onlyMismatch} onChange={(e) => setOnlyMismatch(e.target.checked)}>
()
</Checkbox>
<Button type="primary" onClick={load} loading={loading}>
</Button>
</Space>
{data && (
<div style={{ marginBottom: 12 }}>
<Space direction="vertical" style={{ width: '100%' }}>
{data.mismatch_count > 0 ? (
<Alert
type="error"
message={`${data.total} 条,其中 ${data.mismatch_count} 条复算与实发不一致(✗)——公式可能未生效或发奖有问题`}
/>
) : (
<Alert type="success" message={`${data.total} 条,全部按公式发放(无不一致)`} />
)}
{onlyMismatch && (
<Alert
type="info"
showIcon
message={`当前只展示不一致(✗)行;上方统计仍为全量(共 ${data.total} 条)。`}
/>
)}
{data.truncated && (
<Alert
type="warning"
showIcon
message={`展示明细已截断到 ${queriedLimit} 条,还有更多未显示——请按用户/场景/日期缩小范围,或调大「条数」。统计数字不受影响。`}
/>
)}
</Space>
</div>
)}
<Table
rowKey={(r) => `${r.scene}-${r.record_id}`}
columns={columns}
dataSource={data?.items ?? []}
loading={loading}
pagination={false}
size="small"
scroll={{ x: 1100 }}
rowClassName={(r) => (r.matched ? '' : 'row-mismatch')}
/>
<style jsx global>{`
.row-mismatch > td {
background: #fff1f0 !important;
}
`}</style>
</div>
);
}
+747
View File
@@ -0,0 +1,747 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import {
App,
Alert,
Button,
Card,
Col,
DatePicker,
Divider,
InputNumber,
Modal,
Popover,
Row,
Select,
Space,
Statistic,
Table,
Tag,
Typography,
} from 'antd';
import {
FallOutlined,
InfoCircleOutlined,
QuestionCircleOutlined,
RiseOutlined,
} from '@ant-design/icons';
import dayjs, { type Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format';
import type {
AdRevenueDaily,
AdRevenueImpression,
AdRevenueRecord,
AdRevenueReport,
AdRevenueRow,
} from '@/lib/types';
const { RangePicker } = DatePicker;
// 广告类型标签
const TYPE_TAG: Record<string, { color: string; label: string }> = {
reward_video: { color: 'blue', label: '激励视频' },
feed: { color: 'purple', label: '信息流' },
draw: { color: 'geekblue', label: 'Draw 信息流' },
};
// 我们的应用环境标签
const APP_TAG: Record<string, { color: string; label: string }> = {
prod: { color: 'green', label: '傻瓜比价(正式)' },
test: { color: 'default', label: '测试应用' },
};
// 发奖状态 → 颜色 + 中文(含「不发金币的原因」:提前关闭/未满10秒/缺eCPM/次数超限)
const STATUS_TAG: Record<string, { color: string; label: string }> = {
granted: { color: 'green', label: '已发' },
capped: { color: 'orange', label: '次数超限' },
ecpm_missing: { color: 'red', label: '缺 eCPM' },
too_short: { color: 'gold', label: '未满10秒' },
closed_early: { color: 'default', label: '提前关闭' },
};
const fmtFactorRange = (a: number | null, b: number | null) => {
if (a == null) return '-';
return a === b || b == null ? String(a) : `${a}${b}`;
};
const fmtIndexRange = (a: number | null, b: number | null) => {
if (a == null) return '-';
return a === b ? String(a) : `${a}${b}`;
};
// 金币计算公式(写死,展示用)——与后端 app/core/rewards.py 同源,改动以后端常量为准。
// 单次奖励(元) = (eCPM元 ÷ 1000) × 因子1(eCPM 元档) × 因子2(LT 累计条数);1 元 = 10000 金币,四舍五入取整。
const COIN_PER_YUAN = 10000;
// 因子1:按 eCPM(元/千次)判档,对应 AD_ECPM_FACTOR_TABLE
const ECPM_FACTOR_ROWS = [
{ key: '4', range: '> 400', factor: 0.6 },
{ key: '3', range: '201 400', factor: 0.4 },
{ key: '2', range: '101 200', factor: 0.3 },
{ key: '1', range: '0 100', factor: 0.1 },
];
// 因子2:按该账号累计第几条/份(不按天重置),对应 AD_LT_FACTOR_TABLE
const LT_FACTOR_ROWS = [
{ key: '1', lt: '第 1 条', factor: 2.0 },
{ key: '2', lt: '第 2 条', factor: 1.5 },
{ key: '3', lt: '第 3 条', factor: 1.3 },
{ key: '4', lt: '4 10 条', factor: 1.1 },
{ key: '5', lt: '第 11 条及以后', factor: 1.0 },
];
// 展开行 - 展示明细:每条广告展示一行(时间/eCPM/收益/adn/底层位)
const IMPRESSION_COLUMNS: ColumnsType<AdRevenueImpression> = [
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 165 },
{ title: 'eCPM(分)', dataIndex: 'ecpm', width: 100 },
{ title: '预估收益(元)', dataIndex: 'revenue_yuan', width: 120, render: (v: number) => v.toFixed(4) },
{
title: 'adn',
dataIndex: 'adn',
width: 90,
render: (v: string | null) => v ?? '-',
},
{
title: '底层代码位ID',
dataIndex: 'slot_id',
render: (v: string | null) => v ?? '-',
},
];
// 展开行 - 发奖明细:该组的逐条发奖复算明细(还原原金币审计的 eCPM/因子1/份数/LT/因子2 等列)
const DETAIL_COLUMNS: ColumnsType<AdRevenueRecord> = [
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 165 },
{
title: '状态',
dataIndex: 'status',
width: 110,
render: (s: string) => {
const t = STATUS_TAG[s] ?? { color: 'default', label: s };
return <Tag color={t.color}>{t.label}</Tag>;
},
},
{ title: 'eCPM(分)', dataIndex: 'ecpm', width: 90, render: (v: string | null) => v ?? '-' },
{ title: '因子1', dataIndex: 'ecpm_factor', width: 70, render: (v: number | null) => v ?? '-' },
{ title: '份数', dataIndex: 'units', width: 60 },
{
title: 'LT累计条数',
key: 'lt_index',
width: 100,
render: (_: unknown, r: AdRevenueRecord) => fmtIndexRange(r.lt_index_start, r.lt_index_end),
},
{
title: '因子2',
key: 'lt_factor',
width: 90,
render: (_: unknown, r: AdRevenueRecord) => fmtFactorRange(r.lt_factor_start, r.lt_factor_end),
},
{ title: '应发金币', dataIndex: 'expected_coin', width: 100, render: (v: number) => <b>{v}</b> },
{ title: '实发金币', dataIndex: 'actual_coin', width: 100 },
{
title: '一致',
dataIndex: 'matched',
width: 80,
render: (m: boolean) => (m ? <Tag color="green"></Tag> : <Tag color="red"> </Tag>),
},
];
// 趋势图(纯 SVG,零依赖):柱=展示条数(左轴),线=预估收益元(右轴)。x 轴按传入点序。
const CHART_BAR = '#69b1ff';
const CHART_LINE = '#fa8c16';
interface TrendPoint {
label: string; // x 轴刻度文案(小时数 / MM-DD)
tip: string; // hover 原生 tooltip 全文
impressions: number;
revenue: number;
}
// 按小时聚合(023),数据源是 items(被 limit 截断时会偏少,调用处给警告)
function aggregateHourly(rows: AdRevenueRow[]): TrendPoint[] {
const byHour = Array.from({ length: 24 }, (_, h) => ({ hour: h, impressions: 0, revenue: 0 }));
for (const r of rows) {
if (r.hour == null || r.hour < 0 || r.hour > 23) continue;
byHour[r.hour].impressions += r.impressions;
byHour[r.hour].revenue += r.revenue_yuan;
}
return byHour.map((b) => ({
label: String(b.hour),
tip: `${String(b.hour).padStart(2, '0')}:00 展示 ${b.impressions} 条 · 预估收益 ${b.revenue.toFixed(4)}`,
impressions: b.impressions,
revenue: b.revenue,
}));
}
// 按天聚合,数据源是 daily(全量,不受 limit 影响);用 from..to 补齐空缺日为 0,轴连续
function aggregateDaily(dateFrom: string, dateTo: string, daily: AdRevenueDaily[]): TrendPoint[] {
const map = new Map(daily.map((d) => [d.date, d]));
const out: TrendPoint[] = [];
let cur = dayjs(dateFrom);
let guard = 0;
while (cur.format('YYYY-MM-DD') <= dateTo && guard < 400) {
const ds = cur.format('YYYY-MM-DD');
const d = map.get(ds);
const impressions = d?.impressions ?? 0;
const revenue = d?.revenue_yuan ?? 0;
out.push({
label: ds.slice(5),
tip: `${ds} 展示 ${impressions} 条 · 预估收益 ${revenue.toFixed(4)}`,
impressions,
revenue,
});
cur = cur.add(1, 'day');
guard += 1;
}
return out;
}
function TrendChart({ points }: { points: TrendPoint[] }) {
const n = points.length;
const maxImp = Math.max(1, ...points.map((p) => p.impressions));
const maxRev = Math.max(1e-9, ...points.map((p) => p.revenue));
const W = 960;
const H = 280;
const padL = 48;
const padR = 56;
const padT = 16;
const padB = 32;
const plotW = W - padL - padR;
const plotH = H - padT - padB;
const step = plotW / Math.max(1, n);
const barW = Math.min(28, step * 0.55);
const yBase = padT + plotH;
const barX = (i: number) => padL + i * step + (step - barW) / 2;
const cx = (i: number) => padL + i * step + step / 2;
const impH = (v: number) => (v / maxImp) * plotH;
const revY = (v: number) => yBase - (v / maxRev) * plotH;
const linePts = points.map((p, i) => `${cx(i)},${revY(p.revenue)}`).join(' ');
const labelEvery = Math.max(1, Math.ceil(n / 12)); // 至多 ~12 个 x 标签,避免拥挤
return (
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ display: 'block' }} role="img" aria-label="趋势图">
{[0, 0.25, 0.5, 0.75, 1].map((t) => {
const y = yBase - t * plotH;
return (
<g key={t}>
<line x1={padL} y1={y} x2={W - padR} y2={y} stroke="#f0f0f0" />
<text x={padL - 8} y={y + 4} textAnchor="end" fontSize={11} fill={CHART_BAR}>
{Math.round(maxImp * t)}
</text>
<text x={W - padR + 8} y={y + 4} textAnchor="start" fontSize={11} fill={CHART_LINE}>
{(maxRev * t).toFixed(2)}
</text>
</g>
);
})}
{points.map((p, i) => (
<rect
key={i}
x={barX(i)}
y={yBase - impH(p.impressions)}
width={barW}
height={impH(p.impressions)}
fill={CHART_BAR}
rx={2}
>
<title>{p.tip}</title>
</rect>
))}
<polyline points={linePts} fill="none" stroke={CHART_LINE} strokeWidth={2} />
{points.map((p, i) => (
<circle key={i} cx={cx(i)} cy={revY(p.revenue)} r={2.5} fill={CHART_LINE}>
<title>{p.tip}</title>
</circle>
))}
{points.map((p, i) =>
i % labelEvery === 0 || i === n - 1 ? (
<text key={i} x={cx(i)} y={H - 14} textAnchor="middle" fontSize={11} fill="#999">
{p.label}
</text>
) : null,
)}
</svg>
);
}
// 广告数据页:单表 —— 按 用户×类型×应用×代码位 聚合,一行同时给出
// 展示条数 / 收益 / 应发金币 / 实发金币 / 是否一致(发奖对账下沉到聚合级)。
export default function AdRevenuePage() {
const { message } = App.useApp();
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]);
const [userId, setUserId] = useState<number | null>(null);
const [adType, setAdType] = useState<string | undefined>();
const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
const [limit, setLimit] = useState<number>(500);
const [queriedGranularity, setQueriedGranularity] = useState<'day' | 'hour'>('day'); // 本次结果对应的粒度,决定是否显示「小时」列
const [queriedMultiDay, setQueriedMultiDay] = useState(false); // 本次结果是否跨多天,决定显示「日期」列 + 按天/按小时图
const [data, setData] = useState<AdRevenueReport | null>(null);
const [queriedLimit, setQueriedLimit] = useState<number>(500);
const [loading, setLoading] = useState(false);
const [formulaOpen, setFormulaOpen] = useState(false);
// 当前选择是否跨多天:跨多天时「按小时」无意义,粒度强制按天
const rangeMultiDay = range[0].format('YYYY-MM-DD') !== range[1].format('YYYY-MM-DD');
const load = useCallback(async () => {
const from = range[0].format('YYYY-MM-DD');
const to = range[1].format('YYYY-MM-DD');
const multiDay = from !== to;
const gran = multiDay ? 'day' : granularity; // 跨多天强制按天
setLoading(true);
try {
const res = await api.get<AdRevenueReport>('/admin/api/ad-revenue-report', {
params: {
date_from: from,
date_to: to,
user_id: userId ?? undefined,
ad_type: adType ?? undefined,
granularity: gran,
limit,
},
});
setData(res.data);
setQueriedLimit(limit);
setQueriedGranularity(gran);
setQueriedMultiDay(multiDay);
} catch (e) {
message.error(errMsg(e));
} finally {
setLoading(false);
}
}, [range, userId, adType, granularity, limit]);
useEffect(() => {
load();
// 仅首次自动拉今天;之后由「查询」按钮触发
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const columns: ColumnsType<AdRevenueRow> = [
...(queriedMultiDay
? [{ title: '日期', dataIndex: 'report_date', width: 110, fixed: 'left' } as ColumnsType<AdRevenueRow>[number]]
: []),
{ title: '用户', dataIndex: 'user_id', width: 80 },
...(queriedGranularity === 'hour'
? [
{
title: '小时',
dataIndex: 'hour',
width: 90,
render: (h: number | null) =>
h == null ? '-' : `${String(h).padStart(2, '0')}:00`,
} as ColumnsType<AdRevenueRow>[number],
]
: []),
{
title: '广告类型',
dataIndex: 'ad_type',
width: 120,
render: (s: string) => {
const t = TYPE_TAG[s] ?? { color: 'default', label: s };
return <Tag color={t.color}>{t.label}</Tag>;
},
},
{
title: '来源应用',
dataIndex: 'app_env',
width: 150,
render: (v: string | null) => {
if (!v) return <Typography.Text type="secondary"></Typography.Text>;
const t = APP_TAG[v] ?? { color: 'default', label: v };
return <Tag color={t.color}>{t.label}</Tag>;
},
},
{
title: '广告位ID',
dataIndex: 'our_code_id',
width: 120,
render: (v: string | null) =>
v ? <Typography.Text code>{v}</Typography.Text> : <Typography.Text type="secondary">-</Typography.Text>,
},
{ title: '展示条数', dataIndex: 'impressions', width: 90, align: 'right' },
{
title: '预估收益(元)',
dataIndex: 'revenue_yuan',
width: 120,
align: 'right',
render: (v: number) => v.toFixed(4),
},
{
title: '应发金币',
dataIndex: 'expected_coin',
width: 100,
align: 'right',
render: (v: number) => <b>{v}</b>,
},
{ title: '实发金币', dataIndex: 'actual_coin', width: 100, align: 'right' },
{
title: '一致',
dataIndex: 'matched',
width: 80,
align: 'center',
render: (m: boolean) => (m ? <Tag color="green"></Tag> : <Tag color="red"> </Tag>),
},
{
title: '底层 ADN',
dataIndex: 'adns',
render: (v: string[]) =>
v.length ? v.map((a) => <Tag key={a}>{a}</Tag>) : <Typography.Text type="secondary">-</Typography.Text>,
},
];
// 派生指标(全部基于全量 total_* 字段,不受 limit 截断影响,准):
// 平均 eCPM = 收益÷展示×1000;发奖成本(元)= 实发金币÷汇率;预估毛利 = 收益−发奖成本;
// 发奖占收益比 = 发奖成本÷收益;应发实发差额(金币)= 应发−实发(正=少发/负=多发)。
const derived = data
? {
avgEcpm: data.total_impressions
? (data.total_revenue_yuan / data.total_impressions) * 1000
: 0,
payoutYuan: data.total_actual_coin / COIN_PER_YUAN,
grossProfit: data.total_revenue_yuan - data.total_actual_coin / COIN_PER_YUAN,
payoutRatioPct:
data.total_revenue_yuan > 0
? (data.total_actual_coin / COIN_PER_YUAN / data.total_revenue_yuan) * 100
: null,
coinGap: data.total_expected_coin - data.total_actual_coin,
}
: null;
return (
<div>
<Space align="center" style={{ display: 'flex', marginBottom: 16 }}>
<h2 style={{ margin: 0 }}>广</h2>
<Button
type="link"
size="small"
icon={<QuestionCircleOutlined />}
onClick={() => setFormulaOpen(true)}
>
</Button>
<Popover
placement="bottomLeft"
title="收益口径说明"
content={
<div style={{ maxWidth: 360, fontSize: 13, lineHeight: 1.7 }}>
广(onAdShow) eCPM ( ÷1000
),<b>广</b>;穿/,,
<b>穿</b> 0广ID / ,
</div>
}
>
<Button type="link" size="small" icon={<InfoCircleOutlined />}>
</Button>
</Popover>
</Space>
<Card size="small" style={{ marginBottom: 16 }}>
<Space wrap size="middle" align="center">
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<RangePicker
value={range}
onChange={(v) => v && v[0] && v[1] && setRange([v[0], v[1]])}
allowClear={false}
presets={[
{ label: '今天', value: [dayjs(), dayjs()] },
{ label: '近 7 天', value: [dayjs().subtract(6, 'day'), dayjs()] },
{ label: '近 30 天', value: [dayjs().subtract(29, 'day'), dayjs()] },
]}
/>
</Space>
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<InputNumber
placeholder="可空"
value={userId ?? undefined}
onChange={(v) => setUserId(v ?? null)}
style={{ width: 130 }}
/>
</Space>
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<Select
placeholder="全部"
value={adType}
onChange={setAdType}
allowClear
style={{ width: 150 }}
options={[
{ value: 'reward_video', label: '激励视频' },
{ value: 'feed', label: '信息流' },
{ value: 'draw', label: 'Draw 信息流' },
]}
/>
</Space>
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<Select
value={rangeMultiDay ? 'day' : granularity}
onChange={setGranularity}
disabled={rangeMultiDay}
style={{ width: 110 }}
title={rangeMultiDay ? '跨多天仅支持按天' : undefined}
options={[
{ value: 'day', label: '按天' },
{ value: 'hour', label: '按小时' },
]}
/>
</Space>
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<Select
value={limit}
onChange={setLimit}
style={{ width: 110 }}
options={[100, 200, 500, 1000].map((n) => ({ value: n, label: `${n}` }))}
/>
</Space>
<Button type="primary" onClick={load} loading={loading}>
</Button>
</Space>
</Card>
{data && derived && (
<Card size="small" style={{ marginBottom: 16 }}>
<Divider orientation="left" plain style={{ marginTop: 0, marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
广
</Typography.Text>
</Divider>
<Row gutter={[16, 12]}>
<Col span={6}>
<Statistic title="展示条数合计" value={data.total_impressions} />
</Col>
<Col span={6}>
<Statistic title="平均 eCPM(元/千次)" value={derived.avgEcpm} precision={2} />
</Col>
<Col span={6}>
<Statistic title="预估收益合计(元)" value={data.total_revenue_yuan} precision={4} />
</Col>
<Col span={6}>
<Statistic title="聚合组数" value={data.total} />
</Col>
</Row>
<Divider orientation="left" plain style={{ marginTop: 20, marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
</Typography.Text>
</Divider>
<Row gutter={[16, 12]}>
<Col span={6}>
<Statistic title="应发金币合计" value={data.total_expected_coin} />
</Col>
<Col span={6}>
<Statistic title="实发金币合计" value={data.total_actual_coin} />
</Col>
<Col span={6}>
<Statistic title="发奖成本(元)" value={derived.payoutYuan} precision={4} />
</Col>
<Col span={6}>
<Statistic
title="预估毛利(元)"
value={derived.grossProfit}
precision={4}
prefix={derived.grossProfit >= 0 ? <RiseOutlined /> : <FallOutlined />}
valueStyle={{ color: derived.grossProfit >= 0 ? '#3f8600' : '#cf1322' }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{' '}
{derived.payoutRatioPct == null ? '—' : `${derived.payoutRatioPct.toFixed(0)}%`}
</Typography.Text>
</Col>
</Row>
</Card>
)}
{data && (
<Space direction="vertical" style={{ width: '100%', marginBottom: 12 }}>
{data.mismatch_count > 0 ? (
<Alert
type="error"
showIcon
message={`${data.total} 组,其中 ${data.mismatch_count} 组应发≠实发(✗);应发−实发 合计 ${
derived && derived.coinGap >= 0 ? '+' : ''
}${derived?.coinGap ?? 0} 金币(正=少发/负=多发)——公式可能未生效或发奖有问题。定位到具体记录可用后端逐条审计接口。`}
/>
) : (
<Alert type="success" showIcon message={`${data.total} 组,应发与实发全部一致。`} />
)}
{data.truncated && (
<Alert
type="warning"
showIcon
message={`聚合明细已截断到 ${queriedLimit} 组,还有更多未显示——请按用户/类型/日期缩小范围,或调大「组数」。合计数字不受影响。`}
/>
)}
</Space>
)}
{data &&
(queriedMultiDay
? (data.daily?.length ?? 0) > 0
: queriedGranularity === 'hour' && (data.items?.length ?? 0) > 0) && (
<Card
size="small"
title={queriedMultiDay ? '按天趋势' : '按小时趋势'}
style={{ marginBottom: 16 }}
extra={
<Space size={16}>
<span style={{ fontSize: 12, color: '#666' }}>
<span
style={{
display: 'inline-block',
width: 10,
height: 10,
background: CHART_BAR,
borderRadius: 2,
marginRight: 4,
}}
/>
</span>
<span style={{ fontSize: 12, color: '#666' }}>
<span
style={{
display: 'inline-block',
width: 14,
borderTop: `2px solid ${CHART_LINE}`,
marginRight: 4,
verticalAlign: 'middle',
}}
/>
()
</span>
</Space>
}
>
{/* 按小时图由 items 聚合,会被 limit 截断;按天图用 daily(全量)不受影响,故只在按小时时警告 */}
{!queriedMultiDay && data.truncated && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 12 }}
message={`图表按小时聚合的是已截断到 ${queriedLimit} 组的明细,可能少算——调大「条数」或缩小筛选可还原真实趋势。`}
/>
)}
{queriedMultiDay ? (
<TrendChart points={aggregateDaily(data.date_from, data.date_to, data.daily)} />
) : (
<TrendChart points={aggregateHourly(data.items)} />
)}
</Card>
)}
<Table
rowKey={(r) => `${r.report_date}-${r.user_id}-${r.ad_type}-${r.app_env ?? ''}-${r.our_code_id ?? ''}-${r.hour ?? ''}`}
columns={columns}
dataSource={data?.items ?? []}
loading={loading}
pagination={false}
size="small"
scroll={{ x: 1100 }}
rowClassName={(r) => (r.matched ? '' : 'row-mismatch')}
expandable={{
rowExpandable: (r) =>
(r.impression_records?.length ?? 0) > 0 || (r.records?.length ?? 0) > 0,
expandedRowRender: (r) => {
const imps = r.impression_records ?? [];
const recs = r.records ?? [];
return (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<div>
<Typography.Text strong></Typography.Text>{' '}
<Typography.Text type="secondary">({imps.length} )</Typography.Text>
<Table<AdRevenueImpression>
style={{ marginTop: 8 }}
rowKey="id"
columns={IMPRESSION_COLUMNS}
dataSource={imps}
pagination={false}
size="small"
scroll={{ x: 700 }}
/>
</div>
<div>
<Typography.Text strong></Typography.Text>{' '}
<Typography.Text type="secondary">({recs.length} )</Typography.Text>
<Table<AdRevenueRecord>
style={{ marginTop: 8 }}
rowKey="record_id"
columns={DETAIL_COLUMNS}
dataSource={recs}
pagination={false}
size="small"
scroll={{ x: 900 }}
rowClassName={(d) => (d.matched ? '' : 'row-mismatch')}
locale={{ emptyText: '该组无发奖记录(只有展示,未发奖)' }}
/>
</div>
</Space>
);
},
}}
/>
<Modal
title="金币计算规则"
open={formulaOpen}
onCancel={() => setFormulaOpen(false)}
footer={null}
width={680}
>
<Typography.Paragraph>
<b>()</b> eCPM<sub></sub> ÷ 1000)× <b>1</b>(eCPM )× <b>2</b>(LT )
</Typography.Paragraph>
<Typography.Paragraph type="secondary" style={{ fontSize: 13 }}>
· eCPM 穿 SDK <Typography.Text code>getEcpm()</Typography.Text> ,/, ÷100 ; ÷1000
<br />· :<b>1 {COIN_PER_YUAN.toLocaleString()} </b>,
<br />· 2LT (); 1 , 10 1
<br />· ,
</Typography.Paragraph>
<Row gutter={16}>
<Col span={12}>
<Typography.Text strong>1 · eCPM(/)</Typography.Text>
<Table
style={{ marginTop: 8 }}
size="small"
pagination={false}
dataSource={ECPM_FACTOR_ROWS}
columns={[
{ title: 'eCPM 范围(元)', dataIndex: 'range' },
{ title: '因子1', dataIndex: 'factor', width: 80 },
]}
/>
</Col>
<Col span={12}>
<Typography.Text strong>2 · LT </Typography.Text>
<Table
style={{ marginTop: 8 }}
size="small"
pagination={false}
dataSource={LT_FACTOR_ROWS}
columns={[
{ title: 'LT 累计条数', dataIndex: 'lt' },
{ title: '因子2', dataIndex: 'factor', width: 80 },
]}
/>
</Col>
</Row>
<Typography.Paragraph type="secondary" style={{ fontSize: 13, marginTop: 12, marginBottom: 0 }}>
示例:eCPM 300 / 1 0.3 × 0.4 × 2.0 0.24 2,400
</Typography.Paragraph>
</Modal>
<style jsx global>{`
.row-mismatch > td {
background: #fff1f0 !important;
}
`}</style>
</div>
);
}
+7 -4
View File
@@ -2,8 +2,9 @@
import { useEffect, useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import { Button, Form, Input, Modal, Select, Table, Tag, message } from 'antd';
import { App, Button, Form, Input, Modal, Select, Table, Tag } from 'antd';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format';
import type { AdminInfo } from '@/lib/types';
const ROLES = [
@@ -11,9 +12,11 @@ const ROLES = [
{ value: 'finance', label: 'finance' },
{ value: 'operator', label: 'operator' },
];
const dt = (v: string | null) => (v ? new Date(v).toLocaleString('zh-CN') : '从未');
// last_login_at 为 UTC 口径(datetime.now(utc)),按北京显示(勿用 new Date().toLocaleString)
const dt = (v: string | null) => (v ? formatUtcTime(v, 'YYYY-MM-DD HH:mm:ss') : '从未');
export default function AdminsPage() {
const { message, modal } = App.useApp();
const [admins, setAdmins] = useState<AdminInfo[]>([]);
const [loading, setLoading] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
@@ -47,7 +50,7 @@ export default function AdminsPage() {
const changeRole = (a: AdminInfo, role: string) => {
if (role === a.role) return;
Modal.confirm({
modal.confirm({
title: `${a.username} 的角色改为 ${role}?`,
onOk: async () => {
try {
@@ -63,7 +66,7 @@ export default function AdminsPage() {
const toggle = (a: AdminInfo) => {
const next = a.status === 'active' ? 'disabled' : 'active';
Modal.confirm({
modal.confirm({
title: `${next === 'disabled' ? '禁用' : '启用'}管理员 ${a.username}?`,
onOk: async () => {
try {
+14 -13
View File
@@ -3,19 +3,18 @@
import { useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import { Button, Input, Space, Table, Tag } from 'antd';
import { useCursorList } from '@/lib/useCursorList';
import { formatUtcTime } from '@/lib/format';
import { usePagedList } from '@/lib/usePagedList';
import type { AuditLog } from '@/lib/types';
const dt = (v: string) => new Date(v).toLocaleString('zh-CN');
// 审计 created_at 为 UTC 口径(server_default now()),按北京显示(勿用 new Date().toLocaleString)
const dt = (v: string) => formatUtcTime(v, 'YYYY-MM-DD HH:mm:ss');
export default function AuditLogsPage() {
const [action, setAction] = useState('');
const [filters, setFilters] = useState<Record<string, unknown>>({});
const { items, nextCursor, loading, loadMore } = useCursorList<AuditLog>(
'/admin/api/audit-logs',
filters,
50,
);
const { items, total, page, pageSize, loading, onChange: onPageChange } =
usePagedList<AuditLog>('/admin/api/audit-logs', filters, 50);
const search = () => setFilters({ action: action || undefined });
@@ -59,14 +58,16 @@ export default function AuditLogsPage() {
columns={columns}
dataSource={items}
loading={loading}
pagination={false}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: onPageChange,
}}
scroll={{ x: 1000 }}
/>
<div style={{ marginTop: 16, textAlign: 'center' }}>
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
{nextCursor ? '加载更多' : '没有更多了'}
</Button>
</div>
</div>
);
}
+334
View File
@@ -0,0 +1,334 @@
'use client';
import { useState } from 'react';
import type { CSSProperties } from 'react';
import {
App, Button, Card, Collapse, Descriptions, Drawer, Input, InputNumber,
Select, Space, Spin, Table, Tag, Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { api, errMsg } from '@/lib/api';
import { formatWallTime, yuan } from '@/lib/format';
import { usePagedList } from '@/lib/usePagedList';
import type { ComparisonRecordDetail, ComparisonRecordListItem } from '@/lib/types';
const STATUS_COLOR: Record<string, string> = { success: 'green', failed: 'red', cancelled: 'default' };
const STATUS_LABEL: Record<string, string> = { success: '成功', failed: '失败', cancelled: '已终止' };
// 「卡在哪一步」: platform_results[*].status 翻成人话(找店/加菜/起送/读价)
const STUCK_LABEL: Record<string, string> = {
success: '成功',
store_not_found: '没找到店',
items_not_found: '菜没匹配上',
below_minimum: '未达起送',
unsupported: '平台不支持',
failed: '失败',
};
const fmtMs = (ms: number | null) => (ms == null ? '-' : `${(ms / 1000).toFixed(1)}s`);
const cents = (c: number | null) => (c == null ? '-' : yuan(c));
// LLM message content 常是 json.dumps 的卡片列表;能 parse 成 JSON 就缩进展开,否则原样。
function pretty(s: string | null): string {
if (!s) return '';
try {
return JSON.stringify(JSON.parse(s), null, 2);
} catch {
return s;
}
}
const preStyle: CSSProperties = {
background: '#f6f8fa', padding: 8, borderRadius: 6, fontSize: 12,
maxHeight: 340, overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: 0,
};
export default function ComparisonRecordsPage() {
const { message } = App.useApp();
const [userId, setUserId] = useState<number | null>(null);
const [phone, setPhone] = useState('');
const [status, setStatus] = useState<string | undefined>();
const [applied, setApplied] = useState<Record<string, unknown>>({});
const { items, total, page, pageSize, loading, onChange } =
usePagedList<ComparisonRecordListItem>('/admin/api/comparison-records', applied);
const [detail, setDetail] = useState<ComparisonRecordDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const search = () =>
setApplied({ user_id: userId ?? undefined, phone: phone || undefined, status });
const reset = () => {
setUserId(null);
setPhone('');
setStatus(undefined);
setApplied({});
};
const openDetail = async (id: number) => {
setDetailLoading(true);
setDetail(null);
try {
const { data } = await api.get<ComparisonRecordDetail>(`/admin/api/comparison-records/${id}`);
setDetail(data);
} catch (e) {
message.error(errMsg(e));
} finally {
setDetailLoading(false);
}
};
const closeDetail = () => {
setDetail(null);
setDetailLoading(false);
};
const columns: ColumnsType<ComparisonRecordListItem> = [
{ title: 'ID', dataIndex: 'id', width: 64 },
{
title: '用户',
key: 'user',
width: 140,
render: (_, r) => (
<div>
<div>{r.phone || `#${r.user_id}`}</div>
{r.nickname && <div style={{ color: '#999', fontSize: 12 }}>{r.nickname}</div>}
</div>
),
},
{ title: '状态', dataIndex: 'status', width: 72, render: (s: string) => <Tag color={STATUS_COLOR[s]}>{STATUS_LABEL[s] || s}</Tag> },
{ title: '源平台', dataIndex: 'source_platform_name', width: 90, render: (v) => v || '-' },
{ title: '店/商品', dataIndex: 'store_name', width: 150, ellipsis: true, render: (v) => v || '-' },
{ title: '最优', dataIndex: 'best_platform_name', width: 80, render: (v) => v || '-' },
{
title: '省',
key: 'saved',
width: 84,
render: (_, r) =>
r.saved_amount_cents == null ? '-' : (
<b style={{ color: r.saved_amount_cents > 0 ? '#3f8600' : '#999' }}>{yuan(r.saved_amount_cents)}</b>
),
},
{ title: '耗时', dataIndex: 'total_ms', width: 64, render: fmtMs },
{ title: '步', dataIndex: 'step_count', width: 48, render: (v) => v ?? '-' },
{ title: 'LLM', dataIndex: 'llm_call_count', width: 56, render: (v) => v ?? '-' },
{
title: '重试',
dataIndex: 'retry_count',
width: 50,
render: (v: number | null) => (v ? <span style={{ color: '#cf1322' }}>{v}</span> : v ?? '-'),
},
{
title: '机型/ROM',
key: 'device',
width: 150,
ellipsis: true,
render: (_, r) => [r.device_model, r.rom_name].filter(Boolean).join(' / ') || '-',
},
{ title: '时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatWallTime(v) },
{
title: '操作',
key: 'op',
fixed: 'right',
width: 80,
render: (_, r) =>
r.trace_url ? (
<a href={r.trace_url} target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}>trace</a>
) : (
<span style={{ color: '#ccc' }}>-</span>
),
},
];
const platformResults =
(detail?.raw_payload?.platform_results as Record<string, unknown>[] | undefined) || [];
return (
<div>
<h2>(debug)</h2>
<Space style={{ marginBottom: 16 }} wrap>
<InputNumber placeholder="user_id" value={userId} onChange={setUserId} style={{ width: 110 }} />
<Input
placeholder="手机号前缀"
value={phone}
onChange={(e) => setPhone(e.target.value)}
onPressEnter={search}
allowClear
style={{ width: 150 }}
/>
<Select
placeholder="状态"
value={status}
onChange={setStatus}
allowClear
style={{ width: 110 }}
options={[
{ value: 'success', label: '成功' },
{ value: 'failed', label: '失败' },
{ value: 'cancelled', label: '已终止' },
]}
/>
<Button type="primary" onClick={search}></Button>
<Button onClick={reset}></Button>
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={items}
loading={loading}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange,
}}
scroll={{ x: 1320 }}
onRow={(r) => ({ onClick: () => openDetail(r.id), style: { cursor: 'pointer' } })}
/>
<Drawer title={detail ? `比价记录 #${detail.id}` : '详情'} width={780} open={detailLoading || !!detail} onClose={closeDetail}>
{detailLoading && <Spin />}
{detail && (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Descriptions size="small" column={2} bordered>
<Descriptions.Item label="用户">
{detail.phone || `#${detail.user_id}`}{detail.nickname ? `(${detail.nickname})` : ''}
</Descriptions.Item>
<Descriptions.Item label="状态"><Tag color={STATUS_COLOR[detail.status]}>{STATUS_LABEL[detail.status] || detail.status}</Tag></Descriptions.Item>
<Descriptions.Item label="业务">{detail.business_type}</Descriptions.Item>
<Descriptions.Item label="耗时">{fmtMs(detail.total_ms)}</Descriptions.Item>
<Descriptions.Item label="步数">{detail.step_count ?? '-'}</Descriptions.Item>
<Descriptions.Item label="LLM 次数/重试">{detail.llm_call_count ?? '-'} / {detail.retry_count ?? '-'}</Descriptions.Item>
<Descriptions.Item label="源平台">{detail.source_platform_name || '-'}{cents(detail.source_price_cents)}</Descriptions.Item>
<Descriptions.Item label="最优">{detail.best_platform_name || '-'}{cents(detail.best_price_cents)}</Descriptions.Item>
<Descriptions.Item label="省" span={2}>{cents(detail.saved_amount_cents)}{detail.is_source_best ? '(源平台最便宜)' : ''}</Descriptions.Item>
<Descriptions.Item label="店/商品" span={2}>{detail.store_name || '-'}</Descriptions.Item>
<Descriptions.Item label="结论文案" span={2}>{detail.information || '-'}</Descriptions.Item>
<Descriptions.Item label="trace" span={2}>
{detail.trace_url
? <a href={detail.trace_url} target="_blank" rel="noreferrer">{detail.trace_url}</a>
: <span style={{ color: '#999' }}>{detail.trace_id} trace_url</span>}
</Descriptions.Item>
<Descriptions.Item label="定位" span={2}>
{detail.longitude != null ? `${detail.longitude}, ${detail.latitude}` : '-'}
</Descriptions.Item>
<Descriptions.Item label="最优深链" span={2}>
{detail.best_deeplink
? <Typography.Text copyable={{ text: detail.best_deeplink }} style={{ fontSize: 12, wordBreak: 'break-all' }}>{detail.best_deeplink}</Typography.Text>
: '-'}
</Descriptions.Item>
</Descriptions>
<Card size="small" title="设备环境">
<Descriptions size="small" column={2}>
<Descriptions.Item label="机型">{detail.device_model || '-'}{detail.device_manufacturer || '-'}</Descriptions.Item>
<Descriptions.Item label="ROM">{[detail.rom_vendor, detail.rom_name, detail.rom_version].filter(Boolean).join(' ') || '-'}</Descriptions.Item>
<Descriptions.Item label="Android">{detail.android_version || '-'}SDK {detail.android_sdk ?? '-'}</Descriptions.Item>
<Descriptions.Item label="App 版本">{detail.app_version || '-'}{detail.app_version_code ?? '-'}</Descriptions.Item>
<Descriptions.Item label="源 App 版本" span={2}>{detail.source_app_version || '-'}</Descriptions.Item>
<Descriptions.Item label="device_id" span={2}>{detail.device_id || '-'}</Descriptions.Item>
</Descriptions>
</Card>
{((detail.items?.length ?? 0) > 0 || (detail.skipped_dish_names?.length ?? 0) > 0) && (
<Card size="small" title={`下单商品${detail.total_dish_count != null ? `(共 ${detail.total_dish_count}` : ''}`}>
{(detail.items?.length ?? 0) > 0 ? (
<ul style={{ margin: 0, paddingLeft: 18 }}>
{detail.items.map((it, i) => (
<li key={i}>
{it.name}{it.qty != null ? ` ×${it.qty}` : ''}
{it.specs?.length ? <span style={{ color: '#999' }}>{it.specs.join('/')}</span> : null}
</li>
))}
</ul>
) : <Typography.Text type="secondary"></Typography.Text>}
{(detail.skipped_dish_names?.length ?? 0) > 0 && (
<div style={{ marginTop: 8, color: '#cf1322' }}>
{detail.skipped_dish_count != null ? ` ${detail.skipped_dish_count} ` : ''}{detail.skipped_dish_names.join('、')}
</div>
)}
</Card>
)}
{platformResults.length > 0 && (
<Card size="small" title="逐平台结局(卡在哪一步)">
<Table
size="small"
rowKey={(_, i) => String(i)}
pagination={false}
dataSource={platformResults}
columns={[
{ title: '平台', dataIndex: 'platform_name', render: (v, r: Record<string, unknown>) => (v as string) || (r.platform_id as string) || '-' },
{ title: '结局', dataIndex: 'status', render: (s: string) => <Tag color={s === 'success' ? 'green' : 'orange'}>{STUCK_LABEL[s] || s || '-'}</Tag> },
{ title: '说明', dataIndex: 'reason', render: (v) => (v as string) || '-' },
]}
/>
</Card>
)}
{(detail.comparison_results?.length ?? 0) > 0 && (
<Collapse
size="small"
items={[{
key: 'cmp',
label: `逐平台对比(${detail.comparison_results.length}`,
children: <pre style={preStyle}>{JSON.stringify(detail.comparison_results, null, 2)}</pre>,
}]}
/>
)}
<Card size="small" title={`LLM 调用明细(${detail.llm_calls?.length ?? 0} 次)`}>
{detail.llm_calls && detail.llm_calls.length > 0 ? (
<Collapse
size="small"
items={detail.llm_calls.map((c, i) => ({
key: String(i),
label: (
<Space size={6} wrap>
<Tag color="blue">{c.scene || 'llm'}</Tag>
<span style={{ color: '#999', fontSize: 12 }}>{c.model}</span>
<span style={{ color: '#999', fontSize: 12 }}>{c.latency_ms ?? '-'}ms</span>
{c.usage?.total_tokens ? <span style={{ color: '#999', fontSize: 12 }}>{c.usage.total_tokens} tok</span> : null}
{c.error ? <Tag color="red">error</Tag> : null}
</Space>
),
children: (
<div>
{c.input_messages.map((m, j) => (
<div key={j} style={{ marginBottom: 8 }}>
<Tag>{m.role}</Tag>
<pre style={preStyle}>{pretty(m.content)}</pre>
</div>
))}
<div style={{ marginTop: 8 }}>
<Tag color="green">output</Tag>
<pre style={preStyle}>{c.error ? `【error】${c.error}` : pretty(c.output)}</pre>
</div>
</div>
),
}))}
/>
) : (
<Typography.Text type="secondary"> LLM / pricebot / LLM</Typography.Text>
)}
</Card>
<Collapse
size="small"
items={[{
key: 'raw',
label: '原始上报 raw_payload',
children: <pre style={preStyle}>{JSON.stringify(detail.raw_payload, null, 2)}</pre>,
}]}
/>
</Space>
)}
</Drawer>
</div>
);
}
+10 -3
View File
@@ -1,14 +1,14 @@
'use client';
import { useEffect, useState } from 'react';
import { Button, Card, Input, InputNumber, Space, Spin, Tag, Tooltip, message } from 'antd';
import { App, Button, Card, Input, InputNumber, Space, Spin, Switch, Tag, Tooltip } from 'antd';
import { api, errMsg } from '@/lib/api';
interface ConfigItem {
key: string;
label: string;
group: string;
type: string; // int / int_list / dict_str_int
type: string; // int / int_list / dict_str_int / bool
help: string | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default: any;
@@ -28,6 +28,7 @@ function toEdit(item: ConfigItem): any {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function fromEdit(type: string, raw: any): any {
if (type === 'int') return Number(raw);
if (type === 'bool') return Boolean(raw);
if (type === 'int_list') {
return String(raw)
.split(',')
@@ -38,6 +39,7 @@ function fromEdit(type: string, raw: any): any {
}
export default function ConfigPage() {
const { message } = App.useApp();
const [items, setItems] = useState<ConfigItem[]>([]);
const [loading, setLoading] = useState(true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -109,7 +111,12 @@ export default function ConfigPage() {
)}
</div>
<Space wrap>
{item.type === 'int' ? (
{item.type === 'bool' ? (
<Switch
checked={!!edits[item.key]}
onChange={(v) => setEdits({ ...edits, [item.key]: v })}
/>
) : item.type === 'int' ? (
<InputNumber
value={edits[item.key]}
onChange={(v) => setEdits({ ...edits, [item.key]: v })}
+260
View File
@@ -0,0 +1,260 @@
'use client';
// CPS 群对账详情:该群点击量/独立点击/复制/独立复制的天级 or 小时级变化折线图。
// 时间范围: 近 3/7/14/30 天(天级) 或 今日(小时级)。数据由后端补零成连续桶,折线不断裂。
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import dynamic from 'next/dynamic';
import type { ColumnsType } from 'antd/es/table';
import { Button, Card, Col, Empty, Row, Segmented, Space, Spin, Statistic, Table, message } from 'antd';
import { api, errMsg } from '@/lib/api';
import { yuan } from '@/lib/format';
// @ant-design/plots 依赖 DOM,关掉 SSR 仅在客户端渲染(否则 Next 预渲染报 document undefined)。
const Line = dynamic(() => import('@ant-design/plots').then((m) => m.Line), { ssr: false });
interface TsPoint {
label: string;
click_pv: number;
click_uv: number;
copy_pv: number;
copy_uv: number;
}
interface TsResp {
group_id: number;
group_name: string;
granularity: string;
points: TsPoint[];
}
// 按天大表格一行(订单侧字段对淘宝/京东群为 null → 显示 "-")
interface DailyRow {
date: string;
click_pv: number;
click_uv: number;
copy_pv: number;
order_count: number | null;
canceled_count: number | null;
gmv_cents: number | null;
est_commission_cents: number | null;
refund_profit_cents: number | null;
settled_commission_cents: number | null;
}
interface DailyResp {
group_name: string;
is_meituan: boolean;
days: number;
rows: DailyRow[];
}
const RANGES = [
{ label: '近 3 天', value: 'd3' },
{ label: '近 7 天', value: 'd7' },
{ label: '近 14 天', value: 'd14' },
{ label: '近 30 天', value: 'd30' },
{ label: '今日(按小时)', value: 'h' },
];
const METRICS: { key: keyof Omit<TsPoint, 'label'>; name: string }[] = [
{ key: 'click_pv', name: '点击量' },
{ key: 'click_uv', name: '独立点击' },
{ key: 'copy_pv', name: '复制次数' },
{ key: 'copy_uv', name: '独立复制' },
];
function rangeToQuery(r: string): string {
if (r === 'h') return 'granularity=hour';
const days: Record<string, number> = { d3: 3, d7: 7, d14: 14, d30: 30 };
return `granularity=day&days=${days[r] ?? 7}`;
}
// 大表格只按天:天级范围 → 天数;今日小时(h) → null(此时隐藏表格,小时维度看上面折线)
function rangeToDays(r: string): number | null {
return ({ d3: 3, d7: 7, d14: 14, d30: 30 } as Record<string, number>)[r] ?? null;
}
export default function GroupDetailPage() {
const params = useParams();
const router = useRouter();
const id = params.id as string;
const [range, setRange] = useState('d7');
const [data, setData] = useState<TsResp | null>(null);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const r = await api.get<TsResp>(
`/admin/api/cps/groups/${id}/timeseries?${rangeToQuery(range)}`,
);
setData(r.data);
} catch (e) {
message.error(errMsg(e, '加载失败'));
} finally {
setLoading(false);
}
}, [id, range]);
useEffect(() => {
load();
}, [load]);
// 按天大表格(独立于折线;今日小时模式 tableDays=null 时不取、不显示)
const [daily, setDaily] = useState<DailyResp | null>(null);
const tableDays = rangeToDays(range);
const loadDaily = useCallback(async () => {
if (tableDays == null) return;
try {
const r = await api.get<DailyResp>(`/admin/api/cps/groups/${id}/daily?days=${tableDays}`);
setDaily(r.data);
} catch (e) {
message.error(errMsg(e, '明细加载失败'));
}
}, [id, tableDays]);
useEffect(() => {
loadDaily();
}, [loadDaily]);
const points = useMemo(() => data?.points ?? [], [data]);
// 展平成「长表」给折线图:每个 (时间桶 × 指标) 一行,colorField 按指标分 4 条线。
const chartData = useMemo(
() =>
points.flatMap((p) =>
METRICS.map((m) => ({ label: p.label, 指标: m.name, value: Number(p[m.key]) })),
),
[points],
);
const totals = useMemo(
() => METRICS.map((m) => ({ name: m.name, sum: points.reduce((s, p) => s + Number(p[m.key]), 0) })),
[points],
);
const hasData = points.some((p) => p.click_pv || p.click_uv || p.copy_pv || p.copy_uv);
const config = {
data: chartData,
xField: 'label',
yField: 'value',
colorField: '指标',
height: 420,
legend: { color: { position: 'top' as const } },
// 折线带数据点(每桶一个);大小用 style.r 固定 —— sizeField/shapeField 是数据字段映射,
// 给常量会被 G2 当字段名找而失效。
point: { style: { r: 3 } },
// y 轴恒从 0 起、上限自适应取整;CPS 数据量小,固定 0 基线对比更直观。
scale: { y: { domainMin: 0, nice: true } },
axis: { x: { title: range === 'h' ? '小时' : '日期' }, y: { title: '次数' } },
interaction: { tooltip: { shared: true } },
};
const dailyColumns: ColumnsType<DailyRow> = [
{ title: '日期', dataIndex: 'date', width: 64, fixed: 'left' },
{ title: '点击', dataIndex: 'click_pv', width: 56 },
{ title: '独立', dataIndex: 'click_uv', width: 56 },
{ title: '复制', dataIndex: 'copy_pv', width: 56, render: (v: number) => v || '-' },
{
title: '点击→下单',
key: 'rate',
width: 86,
render: (_, r) =>
r.order_count == null
? '-'
: r.click_uv
? `${((r.order_count / r.click_uv) * 100).toFixed(0)}%`
: '-',
},
{ title: '有效单', dataIndex: 'order_count', width: 64, render: (v: number | null) => (v == null ? '-' : v) },
{
title: '取消/风控',
dataIndex: 'canceled_count',
width: 78,
render: (v: number | null) => (v == null ? '-' : v ? <span style={{ color: '#cf1322' }}>{v}</span> : 0),
},
{ title: '成交额', dataIndex: 'gmv_cents', width: 84, render: (v: number | null) => (v == null ? '-' : yuan(v)) },
{
title: '预估佣金',
dataIndex: 'est_commission_cents',
width: 84,
render: (v: number | null) => (v == null ? '-' : yuan(v)),
},
{
title: '退款佣金',
dataIndex: 'refund_profit_cents',
width: 84,
render: (v: number | null) =>
v == null ? '-' : v ? <span style={{ color: '#cf1322' }}>{yuan(v)}</span> : yuan(0),
},
{
title: '净佣金',
key: 'net',
width: 84,
render: (_, r) =>
r.est_commission_cents == null ? (
'-'
) : (
<b>{yuan(r.est_commission_cents - (r.refund_profit_cents || 0))}</b>
),
},
{
title: '结算佣金',
dataIndex: 'settled_commission_cents',
width: 84,
render: (v: number | null) => (v == null ? '-' : <span style={{ color: '#3f8600' }}>{yuan(v)}</span>),
},
];
return (
<div>
<Space style={{ marginBottom: 16 }} align="center" wrap>
<Button onClick={() => router.push('/cps')}> </Button>
<span style={{ fontSize: 18, fontWeight: 600 }}>
{data?.group_name ?? ''}
</span>
</Space>
<Card style={{ marginBottom: 16 }}>
<Segmented options={RANGES} value={range} onChange={(v) => setRange(v as string)} />
</Card>
<Row gutter={16} style={{ marginBottom: 16 }}>
{totals.map((t) => (
<Col key={t.name} xs={12} sm={6}>
<Card size="small">
<Statistic title={`${t.name}(合计)`} value={t.sum} />
</Card>
</Col>
))}
</Row>
<Card>
<Spin spinning={loading}>
{hasData ? (
<Line {...config} />
) : (
<Empty
style={{ padding: '80px 0' }}
description={loading ? '加载中…' : '该时间范围内暂无点击数据'}
/>
)}
</Spin>
</Card>
{tableDays != null && (
<Card title={`每日明细(近 ${tableDays} 天)`} style={{ marginTop: 16 }}>
{daily && !daily.is_meituan && (
<div style={{ fontSize: 12, color: '#999', marginBottom: 8 }}>
,/,-
</div>
)}
<Table<DailyRow>
rowKey="date"
size="small"
columns={dailyColumns}
dataSource={daily?.rows ?? []}
pagination={false}
scroll={{ x: 900 }}
/>
</Card>
)}
</div>
);
}
+963
View File
@@ -0,0 +1,963 @@
'use client';
// CPS 优惠券分发与对账。平台:美团(actId+sid,完整对账) / 淘宝(淘口令) / 京东(链接)。
// 淘宝/京东无 API → 只统计点击,对账字段显示 "-"。单页 Tabs:对账统计/群管理/活动管理/订单明细。
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import type { ColumnsType } from 'antd/es/table';
import {
Button,
Card,
Checkbox,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Statistic,
Table,
Tabs,
Tag,
Typography,
Upload,
message,
} from 'antd';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { formatUtcTime, yuan } from '@/lib/format';
import { usePagedList } from '@/lib/usePagedList';
import type {
CpsActivity,
CpsGroup,
CpsGroupStat,
CpsOrder,
CpsReconcileResult,
CpsReferralLinks,
CpsStats,
} from '@/lib/types';
const MT_STATUS: Record<string, { text: string; color: string }> = {
'2': { text: '已付款', color: 'blue' },
'3': { text: '已完成', color: 'cyan' },
'4': { text: '已取消', color: 'red' },
'5': { text: '风控', color: 'orange' },
'6': { text: '已结算', color: 'green' },
};
const PLATFORM_LABEL: Record<string, string> = { meituan: '美团', taobao: '淘宝', jd: '京东' };
const PLATFORM_COLOR: Record<string, string> = { meituan: 'gold', taobao: 'volcano', jd: 'red' };
const PLATFORM_OPTIONS = [
{ label: '美团', value: 'meituan' },
{ label: '淘宝', value: 'taobao' },
{ label: '京东', value: 'jd' },
];
const fmtRate = (r: string | null) => (r ? `${Number(r) / 100}%` : '-');
const DASH = <span style={{ color: '#bbb' }}>-</span>;
const platformTags = (ps: string[] | undefined) =>
(ps || []).map((p) => (
<Tag key={p} color={PLATFORM_COLOR[p]} style={{ marginInlineEnd: 4 }}>
{PLATFORM_LABEL[p] || p}
</Tag>
));
export default function CpsPage() {
return (
<div>
<h2>CPS </h2>
<Typography.Paragraph type="secondary" style={{ marginTop: -8 }}>
(actId + sid,/) / () / () API,
<b></b>,- /c/xxx
</Typography.Paragraph>
<Tabs
defaultActiveKey="stats"
destroyInactiveTabPane
items={[
{ key: 'stats', label: '对账统计', children: <StatsTab /> },
{ key: 'groups', label: '群管理', children: <GroupsTab /> },
{ key: 'activities', label: '活动管理', children: <ActivitiesTab /> },
{ key: 'orders', label: '订单明细', children: <OrdersTab /> },
]}
/>
</div>
);
}
// ───────────── 对账统计 ─────────────
function StatsTab() {
const [days, setDays] = useState(30);
const [data, setData] = useState<CpsStats | null>(null);
const [loading, setLoading] = useState(false);
const [reconciling, setReconciling] = useState(false);
const canReconcile = canDo(['finance']);
const load = useCallback(async (d: number) => {
setLoading(true);
try {
const resp = await api.get<CpsStats>('/admin/api/cps/stats', { params: { days: d } });
setData(resp.data);
} catch (e) {
message.error(errMsg(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load(days);
}, [days, load]);
const reconcile = async () => {
setReconciling(true);
try {
const resp = await api.post<CpsReconcileResult>('/admin/api/cps/orders/reconcile', null, {
params: { days },
});
const r = resp.data;
message.success(`对账完成:拉取 ${r.fetched} 单(新增 ${r.inserted}、更新 ${r.updated})`);
load(days);
} catch (e) {
message.error(errMsg(e));
} finally {
setReconciling(false);
}
};
const columns: ColumnsType<CpsGroupStat> = [
{
title: '群',
dataIndex: 'name',
width: 140,
ellipsis: true,
fixed: 'left',
render: (v: string, r) =>
r.group_id ? <Link href={`/cps/groups/${r.group_id}`}>{v}</Link> : v,
},
{ title: '平台', key: 'platforms', width: 130, render: (_, r) => platformTags(r.platforms) },
{ title: '人数', dataIndex: 'member_count', width: 64, render: (v) => v ?? '-' },
{ title: '点击', dataIndex: 'click_pv', width: 64 },
{ title: '独立', dataIndex: 'click_uv', width: 64 },
{
title: '复制',
dataIndex: 'copy_pv',
width: 64,
render: (v: number) => (v ? v : DASH),
},
{
title: '有效单',
dataIndex: 'order_count',
width: 70,
render: (v: number | null) => (v == null ? DASH : v),
},
{
title: '取消/风控',
dataIndex: 'canceled_count',
width: 80,
render: (v: number | null) => (v == null ? DASH : v ? <span style={{ color: '#cf1322' }}>{v}</span> : 0),
},
{
title: '点击→下单',
key: 'rate',
width: 88,
render: (_, r) =>
r.order_count == null ? DASH : r.click_uv ? `${((r.order_count / r.click_uv) * 100).toFixed(0)}%` : '-',
},
{
title: '成交额',
dataIndex: 'gmv_cents',
width: 96,
render: (v: number | null) => (v == null ? DASH : yuan(v)),
},
{
title: '预估佣金',
dataIndex: 'est_commission_cents',
width: 100,
sorter: (a, b) => (a.est_commission_cents || 0) - (b.est_commission_cents || 0),
render: (v: number | null) => (v == null ? DASH : <b>{yuan(v)}</b>),
},
{
title: '已结算佣金',
dataIndex: 'settled_commission_cents',
width: 100,
render: (v: number | null) => (v == null ? DASH : <span style={{ color: '#3f8600' }}>{yuan(v)}</span>),
},
];
return (
<div>
<Space style={{ marginBottom: 16 }} wrap>
<span></span>
<Select
value={days}
onChange={setDays}
style={{ width: 120 }}
options={[
{ value: 7, label: '近 7 天' },
{ value: 30, label: '近 30 天' },
{ value: 90, label: '近 90 天' },
]}
/>
{canReconcile && (
<Button type="primary" loading={reconciling} onClick={reconcile}>
()
</Button>
)}
<Typography.Text type="secondary">/,-;</Typography.Text>
</Space>
<Space size="large" style={{ marginBottom: 16 }} wrap>
<Card size="small">
<Statistic title="有效订单数(美团)" value={data?.total_order_count ?? 0} />
</Card>
<Card size="small">
<Statistic
title="预估佣金"
value={(data?.total_est_commission_cents ?? 0) / 100}
precision={2}
prefix="¥"
/>
</Card>
<Card size="small">
<Statistic
title="已结算佣金"
value={(data?.total_settled_commission_cents ?? 0) / 100}
precision={2}
prefix="¥"
valueStyle={{ color: '#3f8600' }}
/>
</Card>
</Space>
<Table
rowKey={(r) => r.sid ?? `gid-${r.group_id ?? 'none'}`}
columns={columns}
dataSource={data?.groups ?? []}
loading={loading}
pagination={false}
scroll={{ x: 1200 }}
/>
</div>
);
}
// ───────────── 群管理 ─────────────
function GroupsTab() {
const [keyword, setKeyword] = useState('');
const [applied, setApplied] = useState<Record<string, unknown>>({});
const { items, total, page, pageSize, loading, onChange, reload } = usePagedList<CpsGroup>(
'/admin/api/cps/groups',
applied,
);
const canManage = canDo(['operator']);
const [editing, setEditing] = useState<CpsGroup | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [linkGroup, setLinkGroup] = useState<CpsGroup | null>(null);
const delGroup = async (g: CpsGroup) => {
try {
await api.delete(`/admin/api/cps/groups/${g.id}`);
message.success('已删除群');
reload();
} catch (e) {
message.error(errMsg(e));
}
};
const columns: ColumnsType<CpsGroup> = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '群名', dataIndex: 'name', width: 160, ellipsis: true },
{ title: '平台', key: 'platforms', width: 140, render: (_, g) => platformTags(g.platforms) },
{
title: 'sid',
dataIndex: 'sid',
width: 130,
render: (v: string | null) => (v ? <Typography.Text copyable>{v}</Typography.Text> : DASH),
},
{ title: '人数', dataIndex: 'member_count', width: 70, render: (v) => v ?? '-' },
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s: string) => <Tag color={s === 'active' ? 'green' : 'default'}>{s}</Tag>,
},
{ title: '创建时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatUtcTime(v) },
{
title: '操作',
key: 'op',
fixed: 'right',
width: 200,
render: (_, g) => (
<Space onClick={(e) => e.stopPropagation()}>
{canManage && <a onClick={() => setLinkGroup(g)}></a>}
{canManage && <a onClick={() => setEditing(g)}></a>}
{canManage && (
<Popconfirm
title="删除这个群?"
description="已发出的链接仍可用,但该群不再计入统计。"
okText="删除"
okButtonProps={{ danger: true }}
onConfirm={() => delGroup(g)}
>
<a style={{ color: '#cf1322' }}></a>
</Popconfirm>
)}
</Space>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16 }} wrap>
<Input
placeholder="群名/sid"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onPressEnter={() => setApplied({ keyword: keyword || undefined })}
allowClear
style={{ width: 180 }}
/>
<Button type="primary" onClick={() => setApplied({ keyword: keyword || undefined })}>
</Button>
{canManage && <Button onClick={() => setCreateOpen(true)}></Button>}
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={items}
loading={loading}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t} 个群`,
onChange,
}}
scroll={{ x: 1090 }}
/>
<GroupFormModal open={createOpen} group={null} onClose={() => setCreateOpen(false)} onDone={reload} />
<GroupFormModal open={!!editing} group={editing} onClose={() => setEditing(null)} onDone={reload} />
<ReferralLinkModal group={linkGroup} onClose={() => setLinkGroup(null)} />
</div>
);
}
function GroupFormModal({
open,
group,
onClose,
onDone,
}: {
open: boolean;
group: CpsGroup | null;
onClose: () => void;
onDone: () => void;
}) {
const [form] = Form.useForm();
const isEdit = !!group;
const watchPlatforms = (Form.useWatch('platforms', form) as string[] | undefined) || [];
const hasMeituan = watchPlatforms.includes('meituan');
useEffect(() => {
if (open) {
if (group) {
form.setFieldsValue({
name: group.name,
platforms: group.platforms,
member_count: group.member_count,
remark: group.remark,
status: group.status,
});
} else {
form.resetFields();
form.setFieldsValue({ platforms: ['meituan'] });
}
}
}, [open, group, form]);
const submit = async () => {
const v = await form.validateFields();
try {
if (isEdit && group) {
await api.patch(`/admin/api/cps/groups/${group.id}`, v);
message.success('已更新群');
} else {
await api.post('/admin/api/cps/groups', v);
message.success('已新建群');
}
onClose();
onDone();
} catch (e) {
message.error(errMsg(e));
}
};
return (
<Modal title={isEdit ? '编辑群' : '新建群'} open={open} onOk={submit} onCancel={onClose} destroyOnHidden>
<Form form={form} layout="vertical">
<Form.Item name="name" label="群名" rules={[{ required: true, message: '请输入群名' }]}>
<Input placeholder="如:宝妈优惠群1" />
</Form.Item>
<Form.Item
name="platforms"
label="这个群发哪些平台的券(可多选)"
rules={[{ required: true, message: '至少选一个平台' }]}
>
<Checkbox.Group options={PLATFORM_OPTIONS} />
</Form.Item>
{hasMeituan && !isEdit && (
<Form.Item
name="sid"
label="美团 sid(渠道标识,留空自动生成 g+ID)"
rules={[{ pattern: /^[A-Za-z0-9]+$/, message: '仅字母和数字' }]}
>
<Input placeholder="留空自动生成;或自定义如 baoma1" maxLength={64} />
</Form.Item>
)}
{hasMeituan && isEdit && (
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 12 }}>
sid:{group?.sid || '(无,保存后如缺会在生成美团链接时报错)'}
</Typography.Text>
)}
<Form.Item name="member_count" label="群人数(用于估算转化率)">
<InputNumber style={{ width: '100%' }} min={0} placeholder="如 200" />
</Form.Item>
{isEdit && (
<Form.Item name="status" label="状态">
<Select
options={[
{ value: 'active', label: 'active(启用)' },
{ value: 'archived', label: 'archived(归档)' },
]}
/>
</Form.Item>
)}
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} maxLength={256} />
</Form.Item>
</Form>
</Modal>
);
}
function ReferralLinkModal({ group, onClose }: { group: CpsGroup | null; onClose: () => void }) {
const [activities, setActivities] = useState<CpsActivity[]>([]);
const [activityIds, setActivityIds] = useState<number[]>([]);
const [result, setResult] = useState<CpsReferralLinks | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!group) {
setActivityIds([]);
setResult(null);
return;
}
api
.get('/admin/api/cps/activities', { params: { status: 'active', limit: 100 } })
.then((r) => setActivities(r.data.items as CpsActivity[]))
.catch((e) => message.error(errMsg(e)));
}, [group]);
// 只列群平台范围内的活动
const groupActivities = activities.filter((a) => (group?.platforms || []).includes(a.platform));
const generate = async () => {
if (!group || !activityIds.length) {
message.warning('请勾选要生成的活动');
return;
}
setLoading(true);
try {
const resp = await api.post<CpsReferralLinks>('/admin/api/cps/referral-links', {
group_id: group.id,
activity_ids: activityIds,
});
setResult(resp.data);
} catch (e) {
message.error(errMsg(e));
} finally {
setLoading(false);
}
};
return (
<Modal
title={`生成发群链接 - ${group?.name ?? ''}`}
open={!!group}
onCancel={onClose}
onOk={generate}
okText="生成"
confirmLoading={loading}
destroyOnHidden
width={660}
>
<Typography.Paragraph type="secondary" style={{ fontSize: 12 }}>
,;
</Typography.Paragraph>
{groupActivities.length === 0 ? (
<Typography.Text type="warning">
({(group?.platforms || []).map((p) => PLATFORM_LABEL[p]).join('/')}),
</Typography.Text>
) : (
<Checkbox.Group
value={activityIds}
onChange={(v) => setActivityIds(v as number[])}
style={{ width: '100%' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
{groupActivities.map((a) => (
<Checkbox key={a.id} value={a.id}>
<Tag color={PLATFORM_COLOR[a.platform]}>{PLATFORM_LABEL[a.platform]}</Tag>
{a.name}
{a.act_id ? <span style={{ color: '#999' }}> (actId={a.act_id})</span> : null}
</Checkbox>
))}
</Space>
</Checkbox.Group>
)}
{result && (
<div style={{ marginTop: 16 }}>
<Typography.Text strong>👇 ():</Typography.Text>
{result.results.map((item) => (
<div
key={item.code}
style={{
marginTop: 8,
padding: 10,
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: 6,
}}
>
<div style={{ marginBottom: 4 }}>
<Tag color={PLATFORM_COLOR[item.platform]}>{PLATFORM_LABEL[item.platform]}</Tag>
<span style={{ fontSize: 13 }}>{item.activity_name}</span>
</div>
<Typography.Text copyable={{ text: item.redirect_url }} style={{ fontSize: 14, wordBreak: 'break-all' }}>
{item.redirect_url}
</Typography.Text>
</div>
))}
{result.results.some((i) => !i.redirect_url.startsWith('http')) && (
<Typography.Text type="warning" style={{ fontSize: 12 }}>
部分为相对路径:后端未配 CPS_REDIRECT_BASE,
</Typography.Text>
)}
</div>
)}
</Modal>
);
}
// ───────────── 活动管理 ─────────────
function ActivitiesTab() {
const { items, total, page, pageSize, loading, onChange, reload } = usePagedList<CpsActivity>(
'/admin/api/cps/activities',
{},
);
const canManage = canDo(['operator']);
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<CpsActivity | null>(null);
const delActivity = async (a: CpsActivity) => {
try {
await api.delete(`/admin/api/cps/activities/${a.id}`);
message.success('已删除活动');
reload();
} catch (e) {
message.error(errMsg(e));
}
};
const columns: ColumnsType<CpsActivity> = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{
title: '平台',
dataIndex: 'platform',
width: 80,
render: (v: string) => <Tag color={PLATFORM_COLOR[v]}>{PLATFORM_LABEL[v] || v}</Tag>,
},
{ title: '活动名', dataIndex: 'name', width: 180, ellipsis: true },
{
title: 'actId(美团)',
dataIndex: 'act_id',
width: 110,
render: (v: string | null) => v || '-',
},
{
title: '淘口令/链接',
dataIndex: 'payload',
width: 240,
ellipsis: true,
render: (v: string | null) => v || '-',
},
{
title: '落地页图',
dataIndex: 'image_url',
width: 80,
render: (v: string | null) =>
v ? (
<img src={v} alt="" style={{ width: 36, height: 46, objectFit: 'cover', borderRadius: 4 }} />
) : (
'-'
),
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s: string) => <Tag color={s === 'active' ? 'green' : 'default'}>{s}</Tag>,
},
{ title: '创建时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatUtcTime(v) },
...(canManage
? ([
{
title: '操作',
key: 'op',
fixed: 'right' as const,
width: 120,
render: (_: unknown, a: CpsActivity) => (
<Space>
<a onClick={() => setEditing(a)}></a>
<Popconfirm
title="删除这个活动?"
description="已生成的链接仍可用;淘宝活动删除后其落地页改用默认图。"
okText="删除"
okButtonProps={{ danger: true }}
onConfirm={() => delActivity(a)}
>
<a style={{ color: '#cf1322' }}></a>
</Popconfirm>
</Space>
),
},
] as ColumnsType<CpsActivity>)
: []),
];
return (
<div>
{canManage && (
<Space style={{ marginBottom: 16 }}>
<Button type="primary" onClick={() => setCreateOpen(true)}>
</Button>
</Space>
)}
<Table
rowKey="id"
columns={columns}
dataSource={items}
loading={loading}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t} 个活动`,
onChange,
}}
scroll={{ x: 1200 }}
/>
<ActivityFormModal open={createOpen} activity={null} onClose={() => setCreateOpen(false)} onDone={reload} />
<ActivityFormModal open={!!editing} activity={editing} onClose={() => setEditing(null)} onDone={reload} />
</div>
);
}
// 淘宝活动落地页图选择器:上传新图 或 点选已有图。受控组件(value = image_url 绝对 URL)。
function ImagePicker({ value, onChange }: { value?: string | null; onChange?: (v: string) => void }) {
const [existing, setExisting] = useState<string[]>([]);
const [uploading, setUploading] = useState(false);
useEffect(() => {
api
.get<{ images: string[] }>('/admin/api/cps/activity-images')
.then((r) => setExisting(r.data.images || []))
.catch(() => {});
}, []);
const upload = async (file: File) => {
setUploading(true);
try {
const fd = new FormData();
fd.append('file', file);
const r = await api.post<{ url: string }>('/admin/api/cps/upload-image', fd);
onChange?.(r.data.url);
setExisting((prev) => (prev.includes(r.data.url) ? prev : [r.data.url, ...prev]));
message.success('图片已上传');
} catch (e) {
message.error(errMsg(e, '上传失败'));
} finally {
setUploading(false);
}
};
const thumb = (u: string, w: number, selected: boolean) => (
<img
key={u}
src={u}
alt="落地页图"
onClick={() => onChange?.(u)}
style={{
width: w,
height: Math.round(w * 1.27),
objectFit: 'cover',
borderRadius: 6,
cursor: 'pointer',
border: selected ? '2px solid #ff4d4f' : '2px solid #f0f0f0',
}}
/>
);
return (
<div>
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={(file) => {
upload(file);
return false; // 阻止 antd 默认上传,走我们自己的端点
}}
>
<Button loading={uploading}></Button>
</Upload>
{value && (
<div style={{ marginTop: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 12, color: '#999' }}>:</span>
{thumb(value, 90, true)}
</div>
)}
{existing.length > 0 && (
<div style={{ marginTop: 10 }}>
<div style={{ fontSize: 12, color: '#999', marginBottom: 6 }}>:</div>
<Space wrap>{existing.map((u) => thumb(u, 56, u === value))}</Space>
</div>
)}
</div>
);
}
function ActivityFormModal({
open,
activity,
onClose,
onDone,
}: {
open: boolean;
activity: CpsActivity | null;
onClose: () => void;
onDone: () => void;
}) {
const [form] = Form.useForm();
const isEdit = !!activity;
const platform = (Form.useWatch('platform', form) as string | undefined) || 'meituan';
useEffect(() => {
if (open) {
if (activity) {
form.setFieldsValue({
name: activity.name,
platform: activity.platform,
act_id: activity.act_id,
product_view_sign: activity.product_view_sign,
payload: activity.payload,
image_url: activity.image_url,
remark: activity.remark,
status: activity.status,
});
} else {
form.resetFields();
form.setFieldsValue({ platform: 'meituan' });
}
}
}, [open, activity, form]);
const submit = async () => {
const v = await form.validateFields();
if (v.platform === 'meituan') {
if (!v.act_id && !v.product_view_sign) {
message.warning('美团活动:actId 与 productViewSign 至少填一个');
return;
}
} else if (!v.payload) {
message.warning(v.platform === 'taobao' ? '请填淘口令' : '请填京东链接');
return;
}
try {
if (isEdit && activity) {
await api.patch(`/admin/api/cps/activities/${activity.id}`, v);
message.success('已更新活动');
} else {
await api.post('/admin/api/cps/activities', v);
message.success('已新建活动');
}
onClose();
onDone();
} catch (e) {
message.error(errMsg(e));
}
};
return (
<Modal title={isEdit ? '编辑活动' : '新建活动'} open={open} onOk={submit} onCancel={onClose} destroyOnHidden>
<Form form={form} layout="vertical" initialValues={{ platform: 'meituan' }}>
<Form.Item name="name" label="活动名" rules={[{ required: true }]}>
<Input placeholder="如:点我领取大额红包" />
</Form.Item>
<Form.Item name="platform" label="平台" rules={[{ required: true }]}>
<Select options={PLATFORM_OPTIONS} />
</Form.Item>
{platform === 'meituan' && (
<>
<Form.Item name="act_id" label="actId(美团活动物料 ID,推荐)">
<Input placeholder="美团联盟「我要推广-活动推广」第一列 ID,如 792" maxLength={64} />
</Form.Item>
<Form.Item name="product_view_sign" label="productViewSign(商品券,与 actId 二选一)">
<Input maxLength={128} />
</Form.Item>
</>
)}
{platform === 'taobao' && (
<>
<Form.Item
name="payload"
label="淘口令(整段,运营从淘宝联盟复制,落地页原样复制给用户)"
rules={[{ required: true, message: '请填淘口令' }]}
>
<Input.TextArea rows={4} placeholder="如:8¥abcXYZ¥ https://m.tb.cn/h.xxx 长按复制本条..." maxLength={4096} />
</Form.Item>
<Form.Item
name="image_url"
label="落地页图(用户点链接后看到的页面主图,上传新图或选已有)"
rules={[{ required: true, message: '请上传或选择落地页图' }]}
>
<ImagePicker />
</Form.Item>
</>
)}
{platform === 'jd' && (
<Form.Item
name="payload"
label="京东推广链接(落地页 302 跳到它)"
rules={[{ required: true, message: '请填京东链接' }]}
>
<Input placeholder="如:https://u.jd.com/xxxxxx" maxLength={1024} />
</Form.Item>
)}
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} maxLength={256} />
</Form.Item>
{isEdit && (
<Form.Item name="status" label="状态">
<Select
options={[
{ value: 'active', label: '启用(active)' },
{ value: 'archived', label: '归档(archived)' },
]}
/>
</Form.Item>
)}
</Form>
</Modal>
);
}
// ───────────── 订单明细 ─────────────
function OrdersTab() {
const [sid, setSid] = useState('');
const [status, setStatus] = useState<string | undefined>();
const [applied, setApplied] = useState<Record<string, unknown>>({});
const { items, total, page, pageSize, loading, onChange } = usePagedList<CpsOrder>(
'/admin/api/cps/orders',
applied,
);
const columns: ColumnsType<CpsOrder> = [
{
title: '订单号',
dataIndex: 'order_id',
width: 200,
ellipsis: true,
render: (v: string) => <Typography.Text copyable>{v}</Typography.Text>,
},
{ title: 'sid', dataIndex: 'sid', width: 120, render: (v) => v || '-' },
{ title: '商品', dataIndex: 'product_name', width: 200, ellipsis: true, render: (v) => v || '-' },
{
title: '成交额',
dataIndex: 'pay_price_cents',
width: 100,
render: (v: number | null) => (v != null ? yuan(v) : '-'),
},
{
title: '佣金',
dataIndex: 'commission_cents',
width: 90,
render: (v: number | null) => (v != null ? yuan(v) : '-'),
},
{ title: '佣金率', dataIndex: 'commission_rate', width: 80, render: fmtRate },
{
title: '状态',
dataIndex: 'mt_status',
width: 90,
render: (s: string | null) => {
const m = s ? MT_STATUS[s] : null;
return m ? <Tag color={m.color}>{m.text}</Tag> : s || '-';
},
},
{ title: '支付时间', dataIndex: 'pay_time', width: 150, render: (v) => formatUtcTime(v) },
];
const search = () => setApplied({ sid: sid || undefined, mt_status: status });
return (
<div>
<Space style={{ marginBottom: 16 }} wrap>
<Input
placeholder="sid"
value={sid}
onChange={(e) => setSid(e.target.value)}
onPressEnter={search}
allowClear
style={{ width: 150 }}
/>
<Select
placeholder="状态"
value={status}
onChange={setStatus}
allowClear
style={{ width: 120 }}
options={Object.entries(MT_STATUS).map(([v, m]) => ({ value: v, label: m.text }))}
/>
<Button type="primary" onClick={search}>
</Button>
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={items}
loading={loading}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange,
}}
scroll={{ x: 1030 }}
/>
</div>
);
}
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react';
import {
App,
Button,
Form,
Input,
@@ -12,7 +13,6 @@ import {
Spin,
Switch,
Table,
message,
} from 'antd';
import { api, errMsg } from '@/lib/api';
@@ -51,6 +51,7 @@ const yuan = (cents: number) => (cents / 100).toFixed(2);
/** 首页轮播「种子」管理(真实记录不足时的兜底假数据,现为「生成规则」)。「数据大盘」页的一个区块。 */
export default function HomeMarqueeSeeds() {
const { message } = App.useApp();
const [seeds, setSeeds] = useState<Seed[]>([]);
const [loading, setLoading] = useState(true);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]); // 批量操作选中行
+5 -4
View File
@@ -2,12 +2,12 @@
import { useEffect, useState } from 'react';
import {
App,
Button,
Card,
Checkbox,
Col,
InputNumber,
Modal,
Popconfirm,
Radio,
Row,
@@ -16,9 +16,9 @@ import {
Spin,
Tag,
Tooltip,
message,
} from 'antd';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format';
interface StatItem {
metric: string; // help_users / total_compares / total_saved
@@ -248,6 +248,7 @@ function buildBody(metric: string, e: Edit, immediate: boolean): Record<string,
/** 首页三门面数字(帮助用户/完成比价/累计节省)的展示模式配置。作为「数据大盘」页的一个区块。 */
export default function HomeStatsConfig() {
const { message, modal } = App.useApp();
const [items, setItems] = useState<StatItem[]>([]);
const [loading, setLoading] = useState(true);
const [edits, setEdits] = useState<Record<string, Edit>>({});
@@ -320,7 +321,7 @@ export default function HomeStatsConfig() {
// 智能校验:按下后三者大概会呈现的关系是否合理,不合理则弹窗劝阻、确认才提交。
const rel = checkRelations(items, edits);
if (rel && rel.warnings.length > 0) {
Modal.confirm({
modal.confirm({
title: '数据关系校验',
width: 480,
okText: '仍要这样设置',
@@ -601,7 +602,7 @@ export default function HomeStatsConfig() {
</span>
{it.updated_at && (
<span style={{ color: '#bbb' }}>
{new Date(it.updated_at).toLocaleString('zh-CN')}
{formatUtcTime(it.updated_at, 'YYYY-MM-DD HH:mm:ss')}
</span>
)}
</Space>
+6 -3
View File
@@ -1,16 +1,19 @@
'use client';
import type { ColumnsType } from 'antd/es/table';
import { Button, Modal, Popconfirm, Space, Table, message } from 'antd';
import { App, Button, Popconfirm, Space, Table } from 'antd';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { formatUtcTime } from '@/lib/format';
import { useCursorList } from '@/lib/useCursorList';
import type { DeviceOnboardingItem } from '@/lib/types';
const dt = (v: string) => new Date(v).toLocaleString('zh-CN');
// completed_at 为 UTC 口径(server_default now()),按北京显示(勿用 new Date().toLocaleString)
const dt = (v: string) => formatUtcTime(v);
const EMPTY: Record<string, unknown> = {};
export default function DevicesPage() {
const { message, modal } = App.useApp();
// 后端按设备聚合、全量返回(next_cursor 恒 null),故无筛选/加载更多。
const { items, loading, reload } = useCursorList<DeviceOnboardingItem>(
'/admin/api/onboarding/devices',
@@ -29,7 +32,7 @@ export default function DevicesPage() {
};
const resetAll = () => {
Modal.confirm({
modal.confirm({
title: '确认全部重设新手引导?',
content:
'清空所有设备的引导完成记录,库里所有用户下次打开 App 都会重走一遍新手引导。此操作不可逆。',
+226 -25
View File
@@ -1,23 +1,98 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import type { Key } from 'react';
import type { ColumnsType } from 'antd/es/table';
import { Button, Select, Space, Table, Tag, message } from 'antd';
import type { SorterResult } from 'antd/es/table/interface';
import {
App,
Button,
DatePicker,
Image,
Input,
Select,
Space,
Table,
Tag,
Typography,
} from 'antd';
import type { Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { useCursorList } from '@/lib/useCursorList';
import { formatUtcTime } from '@/lib/format';
import { usePagedList } from '@/lib/usePagedList';
import type { Feedback } from '@/lib/types';
const dt = (v: string) => new Date(v).toLocaleString('zh-CN');
const { Text, Paragraph } = Typography;
const { RangePicker } = DatePicker;
// 截图是 app-server 的 /media 相对路径,本地由 :8770 提供(NEXT_PUBLIC_MEDIA_BASE);生产同域走 nginx 代理。
const MEDIA_BASE = process.env.NEXT_PUBLIC_MEDIA_BASE || '';
const mediaUrl = (p: string) => (p.startsWith('http') ? p : MEDIA_BASE + p);
type SortField = 'id' | 'created_at';
export default function FeedbacksPage() {
const { message, modal } = App.useApp();
// 筛选草稿:点「查询」才应用(避免输入即刷新)
const [status, setStatus] = useState<string | undefined>();
const [filters, setFilters] = useState<Record<string, unknown>>({});
const { items, nextCursor, loading, loadMore, reload } = useCursorList<Feedback>(
'/admin/api/feedbacks',
filters,
);
const [userId, setUserId] = useState('');
const [content, setContent] = useState('');
const [createdRange, setCreatedRange] = useState<[Dayjs, Dayjs] | null>(null);
const [applied, setApplied] = useState<Record<string, unknown>>({});
// 排序(服务端):点列头即时生效,与「查询」按钮分开
const [sortBy, setSortBy] = useState<SortField>('id');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const filters: Record<string, unknown> = { ...applied, sort_by: sortBy, sort_order: sortOrder };
const filterKey = JSON.stringify(filters);
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
usePagedList<Feedback>('/admin/api/feedbacks', filters);
const canHandle = canDo(['operator']);
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
// 筛选/排序变化后清空已选(避免选中项跨筛选错位)
useEffect(() => {
setSelectedRowKeys([]);
}, [filterKey]);
const search = () =>
setApplied({
status,
user_id: userId.trim() ? Number(userId.trim()) : undefined,
content: content.trim() || undefined,
created_from: createdRange?.[0] ? createdRange[0].startOf('day').toISOString() : undefined,
created_to: createdRange?.[1] ? createdRange[1].endOf('day').toISOString() : undefined,
});
const resetFilters = () => {
setStatus(undefined);
setUserId('');
setContent('');
setCreatedRange(null);
setSortBy('id');
setSortOrder('desc');
setApplied({});
};
const onTableChange = (
_pagination: unknown,
_filters: unknown,
sorter: SorterResult<Feedback> | SorterResult<Feedback>[],
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
if (s && s.order && s.field) {
setSortBy(s.field as SortField);
setSortOrder(s.order === 'ascend' ? 'asc' : 'desc');
} else {
setSortBy('id');
setSortOrder('desc');
}
};
const sortOrderOf = (field: SortField) =>
sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null;
const handle = async (f: Feedback) => {
try {
@@ -29,18 +104,90 @@ export default function FeedbacksPage() {
}
};
const selectedNew = items.filter(
(f) => selectedRowKeys.includes(f.id) && f.status === 'new',
);
// 批量:循环调现有逐条 /handle 接口(各自已带审计),Promise.allSettled 汇总成败,不新增 bulk 端点
const bulkHandle = () => {
if (!selectedNew.length) {
message.warning('没有可处理的选中项(仅待处理 new 态生效)');
return;
}
modal.confirm({
title: `确认批量标记处理 ${selectedNew.length} 条反馈?`,
content: '仅对选中的待处理(new)反馈生效',
onOk: async () => {
const results = await Promise.allSettled(
selectedNew.map((f) => api.post(`/admin/api/feedbacks/${f.id}/handle`)),
);
const ok = results.filter((r) => r.status === 'fulfilled').length;
const fail = results.length - ok;
if (fail) message.warning(`批量标记处理完成:成功 ${ok}/${results.length},失败 ${fail}`);
else message.success(`批量标记处理完成:${ok}`);
setSelectedRowKeys([]);
reload();
},
});
};
const columns: ColumnsType<Feedback> = [
{ title: 'ID', dataIndex: 'id', width: 70 },
{ title: 'ID', dataIndex: 'id', width: 70, sorter: true, sortOrder: sortOrderOf('id') },
{ title: '用户', dataIndex: 'user_id', width: 80 },
{ title: '内容', dataIndex: 'content' },
{ title: '联系方式', dataIndex: 'contact', width: 140 },
{
title: '内容',
dataIndex: 'content',
width: 360,
render: (v: string) =>
v ? (
<Paragraph
style={{ marginBottom: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
ellipsis={{ rows: 3, expandable: true, symbol: '展开', tooltip: true }}
>
{v}
</Paragraph>
) : (
<Text type="secondary">-</Text>
),
},
{
title: '截图',
dataIndex: 'images',
width: 180,
render: (imgs: string[] | null) =>
imgs && imgs.length ? (
<Image.PreviewGroup>
<Space size={4} wrap>
{imgs.map((src, i) => (
<Image
key={i}
src={mediaUrl(src)}
width={44}
height={44}
style={{ objectFit: 'cover', borderRadius: 4 }}
/>
))}
</Space>
</Image.PreviewGroup>
) : (
<Text type="secondary"></Text>
),
},
{ title: '联系方式', dataIndex: 'contact', width: 140, render: (v: string) => v || '-' },
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s: string) => <Tag color={s === 'new' ? 'orange' : 'green'}>{s}</Tag>,
},
{ title: '时间', dataIndex: 'created_at', render: dt, width: 180 },
{
title: '时间',
dataIndex: 'created_at',
width: 180,
sorter: true,
sortOrder: sortOrderOf('created_at'),
render: (v: string) => formatUtcTime(v),
},
{
title: '操作',
key: 'op',
@@ -57,28 +204,82 @@ export default function FeedbacksPage() {
return (
<div>
<h2></h2>
<Space style={{ marginBottom: 16 }}>
<Space style={{ marginBottom: 16 }} wrap>
<Select
placeholder="状态"
value={status}
onChange={(v) => {
setStatus(v);
setFilters({ status: v });
}}
onChange={setStatus}
allowClear
style={{ width: 140 }}
style={{ width: 120 }}
options={[
{ value: 'new', label: '待处理' },
{ value: 'handled', label: '已处理' },
]}
/>
</Space>
<Table rowKey="id" columns={columns} dataSource={items} loading={loading} pagination={false} />
<div style={{ marginTop: 16, textAlign: 'center' }}>
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
{nextCursor ? '加载更多' : '没有更多了'}
<Input
placeholder="用户ID"
value={userId}
onChange={(e) => setUserId(e.target.value.replace(/\D/g, ''))}
onPressEnter={search}
allowClear
style={{ width: 120 }}
/>
<Input
placeholder="内容(模糊)"
value={content}
onChange={(e) => setContent(e.target.value)}
onPressEnter={search}
allowClear
style={{ width: 180 }}
/>
<RangePicker
placeholder={['提交起', '提交止']}
value={createdRange}
onChange={(v) => setCreatedRange(v as [Dayjs, Dayjs] | null)}
allowClear
/>
<Button type="primary" onClick={search}>
</Button>
</div>
<Button onClick={resetFilters}></Button>
</Space>
{canHandle && selectedNew.length > 0 && (
<Space style={{ marginBottom: 12, width: '100%' }} wrap>
<span style={{ color: '#888' }}> {selectedNew.length} </span>
<Button type="primary" onClick={bulkHandle}>
</Button>
<Button type="link" onClick={() => setSelectedRowKeys([])}>
</Button>
</Space>
)}
<Table
rowKey="id"
rowSelection={
canHandle
? {
selectedRowKeys,
onChange: setSelectedRowKeys,
getCheckboxProps: (f) => ({ disabled: f.status !== 'new' }),
}
: undefined
}
columns={columns}
dataSource={items}
loading={loading}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t} 条反馈`,
onChange: onPageChange,
}}
onChange={onTableChange}
/>
</div>
);
}
+6 -2
View File
@@ -3,15 +3,17 @@
import { useEffect, useState } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import {
BarChartOutlined,
DashboardOutlined,
FileSearchOutlined,
FlagOutlined,
FundProjectionScreenOutlined,
LogoutOutlined,
MessageOutlined,
MobileOutlined,
MoneyCollectOutlined,
ProfileOutlined,
SettingOutlined,
ShareAltOutlined,
TeamOutlined,
UserOutlined,
} from '@ant-design/icons';
@@ -27,8 +29,10 @@ const MENU = [
{ key: '/devices', icon: <MobileOutlined />, label: '设备管理' },
{ key: '/withdraws', icon: <MoneyCollectOutlined />, label: '提现管理' },
{ key: '/price-reports', icon: <FlagOutlined />, label: '上报审核' },
{ key: '/comparison-records', icon: <ProfileOutlined />, label: '比价记录' },
{ key: '/feedbacks', icon: <MessageOutlined />, label: '反馈工单' },
{ key: '/ad-audit', icon: <FundProjectionScreenOutlined />, label: '金币审计' },
{ key: '/ad-revenue', icon: <BarChartOutlined />, label: '广告数据' },
{ key: '/cps', icon: <ShareAltOutlined />, label: 'CPS 分发' },
{ key: '/config', icon: <SettingOutlined />, label: '系统配置' },
{ key: '/admins', icon: <TeamOutlined />, label: '管理员', superOnly: true },
{ key: '/audit-logs', icon: <FileSearchOutlined />, label: '审计日志' },
+14 -13
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react';
import {
App,
Button,
Card,
Image,
@@ -13,14 +14,13 @@ import {
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { useCursorList } from '@/lib/useCursorList';
import { usePagedList } from '@/lib/usePagedList';
import type { PriceReport, PriceReportSummary } from '@/lib/types';
const { Text } = Typography;
@@ -57,6 +57,7 @@ function statusTag(status: string) {
}
export default function PriceReportsPage() {
const { message, modal } = App.useApp();
const [activeStatus, setActiveStatus] = useState('pending');
const [summary, setSummary] = useState<PriceReportSummary | null>(null);
const [rejecting, setRejecting] = useState<PriceReport | null>(null);
@@ -65,10 +66,8 @@ export default function PriceReportsPage() {
const filters: Record<string, unknown> = {};
if (activeStatus !== 'all') filters.status = activeStatus;
const { items, nextCursor, loading, loadMore, reload } = useCursorList<PriceReport>(
'/admin/api/price-reports',
filters,
);
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
usePagedList<PriceReport>('/admin/api/price-reports', filters);
const canReview = canDo(['operator']);
@@ -90,7 +89,7 @@ export default function PriceReportsPage() {
};
const approve = (r: PriceReport) => {
Modal.confirm({
modal.confirm({
title: '确认通过该上报?',
icon: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
content: (
@@ -277,14 +276,16 @@ export default function PriceReportsPage() {
columns={columns}
dataSource={items}
loading={loading}
pagination={false}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: onPageChange,
}}
scroll={{ x: 1100 }}
/>
<div style={{ marginTop: 16, textAlign: 'center' }}>
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
{nextCursor ? '加载更多' : '没有更多了'}
</Button>
</div>
</Card>
<Modal
+3 -3
View File
@@ -4,11 +4,11 @@ import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import type { ColumnsType } from 'antd/es/table';
import {
App,
Button,
Card,
Col,
Descriptions,
Modal,
Result,
Row,
Space,
@@ -17,7 +17,6 @@ import {
Table,
Tabs,
Tag,
message,
} from 'antd';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
@@ -27,6 +26,7 @@ import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModa
import type { CashTxn, CoinTxn, UserOverview, WithdrawOrder } from '@/lib/types';
export default function UserDetailPage() {
const { message, modal } = App.useApp();
const params = useParams<{ id: string }>();
const uid = Number(params.id);
const [data, setData] = useState<UserOverview | null>(null);
@@ -56,7 +56,7 @@ export default function UserDetailPage() {
const toggleStatus = () => {
if (!data) return;
const next = data.user.status === 'active' ? 'disabled' : 'active';
Modal.confirm({
modal.confirm({
title: `确认${next === 'disabled' ? '封禁' : '解封'}用户 ${data.user.phone}?`,
onOk: async () => {
try {
+28 -21
View File
@@ -5,12 +5,12 @@ import type { Key } from 'react';
import { useRouter } from 'next/navigation';
import type { ColumnsType } from 'antd/es/table';
import type { SorterResult } from 'antd/es/table/interface';
import { Button, DatePicker, Input, Modal, Select, Space, Table, Tag, message } from 'antd';
import { App, Button, DatePicker, Input, Select, Space, Table, Tag } from 'antd';
import dayjs, { type Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { formatUtcTime } from '@/lib/format';
import { useCursorList } from '@/lib/useCursorList';
import { usePagedList } from '@/lib/usePagedList';
import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals';
import type { UserListItem } from '@/lib/types';
@@ -22,6 +22,7 @@ type SortField = 'id' | 'created_at' | 'last_login_at';
export default function UsersPage() {
const router = useRouter();
const { message, modal } = App.useApp();
// 筛选草稿:点「查询」才应用(避免输入即刷新);状态/渠道/日期一并随查询提交
const [phone, setPhone] = useState('');
@@ -37,10 +38,8 @@ export default function UsersPage() {
const filters: Record<string, unknown> = { ...applied, sort_by: sortBy, sort_order: sortOrder };
const filterKey = JSON.stringify(filters);
const { items, nextCursor, loading, loadMore, reload } = useCursorList<UserListItem>(
'/admin/api/users',
filters,
);
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
usePagedList<UserListItem>('/admin/api/users', filters);
const canCoins = canDo(['finance']);
const canStatus = canDo(['operator']);
@@ -99,7 +98,7 @@ export default function UsersPage() {
const toggleStatus = (u: UserListItem) => {
const next = u.status === 'active' ? 'disabled' : 'active';
Modal.confirm({
modal.confirm({
title: `确认${next === 'disabled' ? '封禁' : '解封'}用户 ${u.phone}?`,
onOk: async () => {
try {
@@ -115,7 +114,7 @@ export default function UsersPage() {
const toggleDebugTrace = (u: UserListItem) => {
const next = !u.debug_trace_enabled;
Modal.confirm({
modal.confirm({
title: `确认${next ? '开启' : '关闭'}用户 ${u.phone} 的调试链接权限?`,
content: next
? '开启后,该用户在 App 比价结果页/记录页可复制 trace 调试链接发给开发排障'
@@ -158,7 +157,7 @@ export default function UsersPage() {
message.warning(`没有可${target === 'disabled' ? '封禁(active)' : '解封(disabled)'}的选中用户`);
return;
}
Modal.confirm({
modal.confirm({
title: `确认${label} ${targets.length} 个用户?`,
content: target === 'disabled' ? '仅对选中的 active 用户生效' : '仅对选中的 disabled 用户生效',
okButtonProps: { danger: target === 'disabled' },
@@ -179,7 +178,7 @@ export default function UsersPage() {
message.warning('没有需要变更的选中用户');
return;
}
Modal.confirm({
modal.confirm({
title: `确认${label} ${targets.length} 个用户?`,
onOk: () =>
runBulk(targets, label, (u) =>
@@ -190,8 +189,14 @@ export default function UsersPage() {
const columns: ColumnsType<UserListItem> = [
{ title: 'ID', dataIndex: 'id', width: 80, sorter: true, sortOrder: sortOrderOf('id') },
{ title: '手机号', dataIndex: 'phone' },
{ title: '昵称', dataIndex: 'nickname', render: (v: string | null) => v || '-' },
{ title: '手机号', dataIndex: 'phone', width: 130 },
{
title: '昵称',
dataIndex: 'nickname',
width: 150,
ellipsis: true,
render: (v: string | null) => v || '-',
},
{ title: '渠道', dataIndex: 'register_channel', width: 90 },
{
title: '状态',
@@ -219,9 +224,9 @@ export default function UsersPage() {
title: '操作',
key: 'op',
fixed: 'right',
width: 230,
width: 300,
render: (_: unknown, u: UserListItem) => (
<Space onClick={(e) => e.stopPropagation()}>
<Space wrap={false} onClick={(e) => e.stopPropagation()}>
<a onClick={() => router.push(`/users/${u.id}`)}></a>
{canCoins && <a onClick={() => setCoinUser(u)}></a>}
{canCoins && <a onClick={() => setCashUser(u)}></a>}
@@ -333,15 +338,17 @@ export default function UsersPage() {
columns={columns}
dataSource={items}
loading={loading}
pagination={false}
scroll={{ x: 1100 }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t} 个用户`,
onChange: onPageChange,
}}
scroll={{ x: 1160 }}
onChange={onTableChange}
/>
<div style={{ marginTop: 16, textAlign: 'center' }}>
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
{nextCursor ? '加载更多' : '没有更多了'}
</Button>
</div>
<AdjustCoinModal user={coinUser} open={!!coinUser} onClose={() => setCoinUser(null)} />
<AdjustCashModal user={cashUser} open={!!cashUser} onClose={() => setCashUser(null)} />
+20 -45
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from 'react';
import type { Key } from 'react';
import {
App,
Alert,
Button,
Card,
@@ -22,7 +23,6 @@ import {
Tag,
Timeline,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
@@ -39,7 +39,7 @@ import dayjs, { type Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { formatUtcTime, formatWallTime, utcDayjs, utcFromNow, yuan } from '@/lib/format';
import { useCursorList } from '@/lib/useCursorList';
import { usePagedList } from '@/lib/usePagedList';
import type {
AuditLog,
CashTxn,
@@ -169,6 +169,7 @@ function bulkResultText(action: string, result: WithdrawBulkResult) {
}
export default function WithdrawsPage() {
const { message, modal } = App.useApp();
const [activeStatus, setActiveStatus] = useState('reviewing');
const [summary, setSummary] = useState<WithdrawSummary | null>(null);
const [health, setHealth] = useState<WxpayHealthCheck | null>(null);
@@ -181,7 +182,6 @@ export default function WithdrawsPage() {
const [bulkRejecting, setBulkRejecting] = useState<WithdrawOrder[]>([]);
const [rejectReason, setRejectReason] = useState('');
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
const [pageSize, setPageSize] = useState(20);
const [searchDraft, setSearchDraft] = useState('');
const [keyword, setKeyword] = useState('');
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(null);
@@ -202,11 +202,8 @@ export default function WithdrawsPage() {
filters.sort_by = sortBy;
filters.sort_order = sortOrder;
const filterKey = JSON.stringify(filters);
const { items, nextCursor, loading, loadMore, reload } = useCursorList<WithdrawOrder>(
'/admin/api/withdraws',
filters,
pageSize,
);
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
usePagedList<WithdrawOrder>('/admin/api/withdraws', filters, 20);
const canManage = canDo(['finance']);
const selectedOrders = items.filter((item) => selectedRowKeys.includes(item.id));
const selectedReviewing = selectedOrders.filter((item) => item.status === 'reviewing');
@@ -290,7 +287,7 @@ export default function WithdrawsPage() {
useEffect(() => {
setSelectedRowKeys([]);
}, [filterKey, pageSize]);
}, [filterKey, page, pageSize]);
const refreshAfterChange = async (outBillNo?: string) => {
setSelectedRowKeys([]);
@@ -309,7 +306,7 @@ export default function WithdrawsPage() {
};
const approve = (o: WithdrawOrder) => {
Modal.confirm({
modal.confirm({
title: `确认通过并打款 ${yuan(o.amount_cents)}?`,
content: (
<div>
@@ -372,7 +369,7 @@ export default function WithdrawsPage() {
}
const amount = selectedReviewing.reduce((sum, item) => sum + item.amount_cents, 0);
let confirmText = '';
Modal.confirm({
modal.confirm({
title: `确认批量通过 ${selectedReviewing.length} 笔提现?`,
content: (
<Space direction="vertical" style={{ width: '100%' }}>
@@ -410,7 +407,7 @@ export default function WithdrawsPage() {
message.warning('请选择打款中的提现单');
return;
}
Modal.confirm({
modal.confirm({
title: `确认批量刷新 ${selectedPending.length} 笔打款状态?`,
content: '会逐笔向微信查单,并按最新状态归一化。',
okText: '批量刷新查单',
@@ -432,7 +429,7 @@ export default function WithdrawsPage() {
};
const retry = (o: WithdrawOrder) => {
Modal.confirm({
modal.confirm({
title: `重新查询 ${o.out_bill_no} 的微信状态?`,
content: '会按微信最新状态归一化为成功、失败退款或撤销未确认。',
okText: '刷新查单',
@@ -454,7 +451,7 @@ export default function WithdrawsPage() {
};
const reconcile = () => {
Modal.confirm({
modal.confirm({
title: '批量对账?',
content: '扫描超过 15 分钟仍处于打款中的提现单,逐单向微信查实并归一化。',
okText: '开始对账',
@@ -887,31 +884,6 @@ export default function WithdrawsPage() {
</Space>
</Space>
)}
<Space
wrap
style={{
width: '100%',
justifyContent: 'space-between',
marginBottom: 12,
}}
>
<Text type="secondary">
{items.length} {nextCursor ? ',还有更多' : ',已到最后一页'}
</Text>
<Space>
<Text type="secondary"></Text>
<Select
value={pageSize}
style={{ width: 96 }}
onChange={(value: number) => setPageSize(value)}
options={[
{ value: 20, label: '20 条' },
{ value: 50, label: '50 条' },
{ value: 100, label: '100 条' },
]}
/>
</Space>
</Space>
<Table
rowKey="id"
rowSelection={
@@ -928,18 +900,21 @@ export default function WithdrawsPage() {
columns={columns}
dataSource={items}
loading={loading}
pagination={false}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
showTotal: (t) => `${t}`,
onChange: onPageChange,
}}
scroll={{ x: 1350 }}
onRow={(record) => ({
onClick: () => openDetail(record),
style: { cursor: 'pointer' },
})}
/>
<div style={{ marginTop: 16, textAlign: 'center' }}>
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
{nextCursor ? `继续加载 ${pageSize}` : '没有更多了'}
</Button>
</div>
</Card>
<Drawer
+2 -1
View File
@@ -2,13 +2,14 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button, Card, Form, Input, message } from 'antd';
import { App, Button, Card, Form, Input } from 'antd';
import { api, errMsg } from '@/lib/api';
import { setAuth } from '@/lib/auth';
import type { LoginResponse } from '@/lib/types';
export default function LoginPage() {
const router = useRouter();
const { message } = App.useApp();
const [loading, setLoading] = useState(false);
const onFinish = async (values: { username: string; password: string }) => {
+15 -3
View File
@@ -5,15 +5,27 @@
// 必须在引入 antd 之前 import。
import '@ant-design/v5-patch-for-react-19';
import { AntdRegistry } from '@ant-design/nextjs-registry';
import { ConfigProvider } from 'antd';
import { App, ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<AntdRegistry>
{/* wave 禁用:消除 antd 5 波纹在 Next15 dev 下的 React 版本兼容误报(纯噪音,后台不需要波纹) */}
<ConfigProvider locale={zhCN} wave={{ disabled: true }}>
{children}
{/* 全局主题:统一圆角到 8、页面底色用偏冷的浅灰,让白色卡片更有层次(全站生效) */}
<ConfigProvider
locale={zhCN}
wave={{ disabled: true }}
theme={{
token: {
borderRadius: 8,
colorBgLayout: '#f0f2f5',
},
}}
>
{/* antd <App> : message/notification/Modal App.useApp() ConfigProvider
/locale, "Static function can not consume context" () */}
<App>{children}</App>
</ConfigProvider>
</AntdRegistry>
);
+3 -1
View File
@@ -3,7 +3,7 @@
// 调金币 / 调现金弹窗(受控)。列表页与用户详情页共用,避免两处各写一套。
// 每个弹窗自带「增减 / 设为指定值」两种模式;打开时拉一次 360 概览展示当前余额。
import { useEffect, useState } from 'react';
import { Form, Input, InputNumber, Modal, Segmented, message } from 'antd';
import { App, Form, Input, InputNumber, Modal, Segmented } from 'antd';
import { api, errMsg } from '@/lib/api';
import { yuan } from '@/lib/format';
import type { UserOverview } from '@/lib/types';
@@ -47,6 +47,7 @@ function useBalances(userId: number | undefined, open: boolean) {
}
export function AdjustCoinModal({ user, open, onClose, onDone }: ModalProps) {
const { message } = App.useApp();
const [form] = Form.useForm();
const [mode, setMode] = useState<'delta' | 'set'>('delta');
const balances = useBalances(user?.id, open);
@@ -110,6 +111,7 @@ export function AdjustCoinModal({ user, open, onClose, onDone }: ModalProps) {
}
export function AdjustCashModal({ user, open, onClose, onDone }: ModalProps) {
const { message } = App.useApp();
const [form] = Form.useForm();
const [mode, setMode] = useState<'delta' | 'set'>('delta');
const balances = useBalances(user?.id, open);
+203 -21
View File
@@ -19,6 +19,7 @@ export interface LoginResponse {
export interface CursorPage<T> {
items: T[];
next_cursor: number | null;
total?: number | null; // offset 分页时为符合筛选条件的总条数(页码分页用);加载更多接口可能没有
}
export interface UserListItem {
@@ -187,40 +188,136 @@ export interface AuditLog {
created_at: string;
}
export interface AdCoinAuditRow {
scene: string; // reward_video / feed
record_id: number;
// 广告收益报表聚合行下钻的单条展示明细
// ===== 比价记录(admin debug 页)=====
export interface ComparisonRecordListItem {
id: number;
user_id: number;
phone: string | null;
nickname: string | null;
business_type: string;
trace_id: string;
trace_url: string | null;
status: string;
information: string | null;
store_name: string | null;
source_platform_name: string | null;
best_platform_name: string | null;
source_price_cents: number | null;
best_price_cents: number | null;
saved_amount_cents: number | null;
total_ms: number | null;
step_count: number | null;
llm_call_count: number | null;
retry_count: number | null;
device_model: string | null;
rom_vendor: string | null;
rom_name: string | null;
android_version: string | null;
app_version: string | null;
created_at: string;
status: string; // granted / capped / ecpm_missing
}
// 单次 LLM 调用明细(pricebot chat() 收口落盘,server 按 trace 拉来)
export interface LlmCall {
ts?: number;
scene: string;
model: string;
input_messages: { role: string; content: string }[];
output: string | null;
usage: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number } | null;
latency_ms: number | null;
error: string | null;
}
export interface ComparisonRecordDetail extends ComparisonRecordListItem {
source_platform_id: string | null;
source_package: string | null;
best_platform_id: string | null;
best_deeplink: string | null;
is_source_best: boolean | null;
total_dish_count: number | null;
skipped_dish_count: number | null;
device_id: string | null;
items: { name: string; qty?: number; specs?: string[] }[];
comparison_results: Record<string, unknown>[];
skipped_dish_names: string[];
device_manufacturer: string | null;
rom_version: number | null;
android_sdk: number | null;
app_version_code: number | null;
source_app_version: string | null;
longitude: number | null;
latitude: number | null;
llm_calls: LlmCall[] | null;
raw_payload: Record<string, unknown> | null;
}
export interface AdRevenueImpression {
id: number;
created_at: string;
ecpm: string; // 分/千次展示
revenue_yuan: number; // 本次展示预估收益(元)
adn: string | null;
slot_id: string | null; // 底层 mediation rit
}
// 广告收益报表聚合行下钻的单条发奖复算明细
export interface AdRevenueRecord {
record_id: number;
created_at: string;
status: string; // granted / capped / ecpm_missing / too_short / closed_early
ecpm: string | null;
ecpm_factor: number | null;
units: number;
ecpm_factor: number | null; // 因子1
units: number; // 份数
lt_index_start: number | null;
lt_index_end: number | null;
lt_factor_start: number | null;
lt_factor_start: number | null; // 因子2
lt_factor_end: number | null;
expected_coin: number;
actual_coin: number;
matched: boolean;
}
export interface AdCoinFormula {
description: string;
coin_per_yuan: number;
ecpm_unit: string;
feed_unit_seconds: number;
ecpm_factor_tiers: [number, number, number | null][];
lt_factor_tiers: [number, number, number | null][];
// 广告收益报表:按日期汇总的一天(按天趋势图用;全量,不受 limit 影响)
export interface AdRevenueDaily {
date: string; // 北京时间 YYYY-MM-DD
impressions: number;
revenue_yuan: number;
expected_coin: number;
actual_coin: number;
}
export interface AdCoinAudit {
date: string;
formula: AdCoinFormula;
total: number; // 全量条数(不受 limit/only_mismatch 影响)
mismatch_count: number; // 全量不一致条数
truncated: boolean; // 展示集是否被 limit 截断
items: AdCoinAuditRow[];
// 广告收益报表:按 日期 × 用户 × 广告类型 × 应用 × 代码位 聚合
export interface AdRevenueRow {
report_date: string; // 该组所属日期 北京时间 YYYY-MM-DD
user_id: number;
ad_type: string; // reward_video / feed / draw
app_env: string | null; // prod(傻瓜比价) / test(测试);旧数据为空
our_code_id: string | null; // 我们配置的代码位 104xxx;旧数据为空
hour: number | null; // 北京时间小时 0–23(按小时粒度时有值;按天为 null)
impressions: number; // 展示条数(轮播每条各计一次)
revenue_yuan: number; // 收益(元),预估口径
expected_coin: number; // 应发金币(公式复算,与金币审计同源)
actual_coin: number; // 实发金币(实际入账)
matched: boolean; // 该组应发==实发
adns: string[]; // 底层填充 ADN 子渠道集合
impression_records: AdRevenueImpression[]; // 该组逐条展示明细(展开下钻)
records: AdRevenueRecord[]; // 该组逐条发奖复算明细(展开下钻)
}
export interface AdRevenueReport {
date_from: string; // 起始日 北京时间 YYYY-MM-DD
date_to: string; // 结束日 北京时间 YYYY-MM-DD(闭区间;单日时与 date_from 相同)
daily: AdRevenueDaily[]; // 按日期汇总序列(全量,供按天趋势图)
total: number; // 聚合组总数(全量,不受 limit 影响)
truncated: boolean;
total_impressions: number;
total_revenue_yuan: number;
total_expected_coin: number;
total_actual_coin: number;
mismatch_count: number; // 应发≠实发的组数
items: AdRevenueRow[];
}
export interface DashboardOverview {
@@ -270,3 +367,88 @@ export interface PriceReportSummary {
rejected: number;
total: number;
}
// ===== CPS 分发与对账 =====
export interface CpsGroup {
id: number;
sid: string | null; // 仅美团群有(渠道追踪位)
name: string;
platforms: string[]; // meituan/taobao/jd 多选
member_count: number | null;
status: string; // active / archived
remark: string | null;
created_at: string;
}
export interface CpsActivity {
id: number;
platform: string; // meituan / taobao / jd
name: string;
act_id: string | null; // 美团活动物料 ID
product_view_sign: string | null;
payload: string | null; // 淘宝整段淘口令 / 京东链接
image_url: string | null; // 淘宝落地页主视觉图(绝对 URL)
status: string;
remark: string | null;
created_at: string;
}
export interface CpsOrder {
id: number;
order_id: string;
sid: string | null;
act_id: string | null;
pay_price_cents: number | null;
commission_cents: number | null;
commission_rate: string | null; // "300"=3% "10"=0.1%
mt_status: string | null; // 2付款 3完成 4取消 5风控 6结算
invalid_reason: string | null;
product_name: string | null;
pay_time: string | null;
}
export interface CpsReferralLinkItem {
activity_id: number;
activity_name: string;
platform: string;
redirect_url: string; // 我们的落地页 /c/{code}(发群用)
code: string;
}
export interface CpsReferralLinks {
group_name: string;
results: CpsReferralLinkItem[];
}
export interface CpsReconcileResult {
fetched: number;
inserted: number;
updated: number;
pages: number;
}
export interface CpsGroupStat {
group_id: number | null;
sid: string | null;
name: string;
platforms: string[];
member_count: number | null;
click_pv: number;
click_uv: number;
copy_pv: number; // 淘宝"复制口令"次数
copy_uv: number;
// 对账类:淘宝/京东无法对账 → null(前端显示 "-")
order_count: number | null;
settled_count: number | null;
canceled_count: number | null;
gmv_cents: number | null;
est_commission_cents: number | null;
settled_commission_cents: number | null;
}
export interface CpsStats {
groups: CpsGroupStat[];
total_order_count: number;
total_est_commission_cents: number;
total_settled_commission_cents: number;
}
+75
View File
@@ -0,0 +1,75 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from './api';
import type { CursorPage } from './types';
/**
* hook( offset + total)
* useCursorList()并存:需要页码// N hook
*
* 约定:后端 cursor offset, hook (page-1)*pageSize ;CursorPage.total
* ( antd Table pagination )filters 1
*/
export function usePagedList<T>(
url: string,
filters: Record<string, unknown>,
initialPageSize = 20,
) {
const [items, setItems] = useState<T[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(initialPageSize);
const [loading, setLoading] = useState(false);
const requestSeq = useRef(0);
const filtersKey = JSON.stringify(filters);
const load = useCallback(
async (p: number, ps: number) => {
const seq = requestSeq.current + 1;
requestSeq.current = seq;
setLoading(true);
try {
const params = { ...JSON.parse(filtersKey), limit: ps, cursor: (p - 1) * ps };
const { data } = await api.get<CursorPage<T>>(url, { params });
if (requestSeq.current !== seq) return; // 丢弃过期响应(快速切页/筛选)
setItems(data.items);
setTotal(data.total ?? 0);
} finally {
if (requestSeq.current === seq) setLoading(false);
}
},
[url, filtersKey],
);
// 筛选变化:回第 1 页重载
useEffect(() => {
setPage(1);
load(1, pageSize);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [load]);
// antd Table pagination.onChange(page, pageSize):页变或每页条数变都走这里
const onChange = (p: number, ps: number) => {
if (ps !== pageSize) {
// 每页条数变 → 回第 1 页,避免越界空页
setPageSize(ps);
setPage(1);
load(1, ps);
} else {
setPage(p);
load(p, ps);
}
};
return {
items,
total,
page,
pageSize,
loading,
onChange,
reload: () => load(page, pageSize), // 写操作后刷新当前页
};
}