Compare commits

..

56 Commits

Author SHA1 Message Date
no_gen_mu 32fde71c96 权限管理新增人员支持「自定义」角色:选中后可见页逐页勾选,保存到本人
角色下拉末尾加「自定义」,选中时「可见页面」从只读矩阵切成可勾选(复用 PermEditor),
提交带 pages_override;编辑自定义用户回填已勾选页;列表里 custom 显示「自定义」,
列表内切到自定义会转去编辑页勾选(避免空可见页)。AdminInfo 加 pages_override 字段。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:44:21 +08:00
zhuzihao bc677817e4 feat(marquee): 首页轮播种子预览改内联常驻、随数据源模式实时刷新 (#40)
系统配置「首页轮播种子」页:预览从点按钮弹窗改为常驻在「数据源」开关下方,
切换「混播/只真实/只种子」即时重拉、所见即所得。

- 预览请求带上当前选中的 mode(配套 app-server 预览接口的 mode 入参),不依赖 PATCH
  落库时序;拨动开关(乐观更新)即刻预览对应模式,切换失败回滚预览也随之回滚。
- 移除原「预览效果」弹窗及相关状态;「只真实」无数据时给空态提示。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #40
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-07-08 00:57:11 +08:00
zhuzihao 33c80d6927 fix(withdraws): 提现账本校验区分现金/邀请金两本账展示 (#39)
配合后端 ledger-check 分账改造:
- 异常清单按「现金」「邀请金」分别标注(缺扣款/缺退款/重复退款/
  非退款终态退款/余额差额),便于定位问题落在哪本账。
- 绿色(校验正常)时分别展示两账的余额与流水净额。
- WithdrawLedgerCheck 类型同步后端新增的 7 个 invite_* 字段。

修复现象:此前邀请奖励金提现单被误判为「缺扣款/缺退款流水」,
导致提现审核页现金账本报红。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #39
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-07-07 00:16:25 +08:00
liujiahui b1bdb66a76 feat(users): 用户管理加注销账号入口(super_admin) (#35)
## 用户管理:加注销账号入口(super_admin)

配合后端注销级联清理 + 删除端点(app-server PR #115),后台加删除入口。

### 改动

- **新增 `DeleteUserModal`**:受控弹窗,必填「注销原因(入审计)」,红字说明删什么/保留什么;`api.delete` 带 body 调 `DELETE /admin/api/users/{id}`;后端 `409`(有未提现现金/邀请金 / 在审提现单)经 `errMsg` 原样透出。列表页与详情页共用一套(同 `AdjustBalanceModals`)。
- **用户详情页**:顶部加红色「注销账号」按钮(`canDo(['super_admin'])`,`deleted` 状态隐藏)。
- **用户列表页**:操作列加红色「注销」内联链接(同门控),列宽 300→340。

### 门控

前端 `canDo(['super_admin'])` 与后端 `require_role("super_admin")` 对齐;门控被绕过后端亦兜底(403)。

### 验证

`next build` ✓ Compiled successfully(tsc + eslint + 21 路由静态生成)。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: no_gen_mu <liujianhishen@gmail.com>
Reviewed-on: #35
Co-authored-by: liujiahui <liujiahui@wonderable.ai>
Co-committed-by: liujiahui <liujiahui@wonderable.ai>
2026-07-06 00:25:47 +08:00
chenshirui c905e15ec9 大盘领券和比价广告收益卡改读服务端全量场景小计,激励视频卡改读类型小计 (#37)
Co-authored-by: 陈世睿 <2839904623@qq.com>
Reviewed-on: #37
Co-authored-by: chenshirui <chenshirui@wonderable.ai>
Co-committed-by: chenshirui <chenshirui@wonderable.ai>
2026-07-06 00:24:58 +08:00
zhuzihao dd84d771f9 feat(ad-revenue): 收益报表 DAU/ARPU 随所选区间显示(今日/近7天/近30天) (#38)
- DAU/ARPU 不再写死「今日」:随查询区间显示——今日仍为「今日活跃 DAU / ARPU(今日)」,
  多天为「活跃用户 / ARPU」;tooltip 标出统计区间与口径(同数据大盘:登录 + 开始比价 +
  开始领券,全局去重、不随筛选变化)。
- 「核心指标」分隔线追加「· 统计区间 X」;更新 dau 类型注释与 ARPU 派生注释。
- 依赖后端把 dau 改为按区间统计(见 app-server 对应 PR):原多天返回 null 显示「-」,
  现在近 7 天 / 近 30 天有值。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #38
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-07-06 00:23:26 +08:00
zhuzihao b620948a71 运营后台前端:权限管理(RBAC)+ 系统配置 + 比价记录店/商品搜索 (#36)
一、权限管理(原「管理员」页,重命名为「权限管理」)
- 重建为 4 视图:账号列表 / 新增·编辑人员 / 角色设置(只读权限矩阵,无权限置灰)/ 角色权限勾选矩阵
- 左侧导航按当前角色的可见页过滤(拉 /me 刷新);角色显示中文名 管理员/运营/财务/技术
- 新增人员自动生成一次性密码;编辑复看已确定登录密码(只读),无留存明文的账号不显示密码

二、系统配置
- 首页数据「保存」改为保存即生效
- 首页轮播种子页加 真实/种子/混播 数据源切换

三、比价记录
- 店/商品拆两列 + 各自搜索框;整格换行不截断;搜索命中黄底高亮

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #36
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-07-05 09:46:24 +08:00
zhuzihao 281cfdb0e4 feat: 反馈加反馈类型/运营回复 + 提现加提现类型筛选 (#34)
用户反馈后台 + 提现审核后台的运营字段与筛选。

- 反馈页:新增「反馈类型」列(比价反馈/普通反馈 + 场景)、顶部「反馈类型」筛选;审核抽屉采纳/拒绝
  都能填「给用户的回复」(用户端可见),并回显反馈类型/场景/运营回复。
- 提现页:新增「提现类型」列(福利页提现/邀请提现)、顶部「提现类型」筛选,详情抽屉展示提现类型。
- types.ts:Feedback 加 source/scene/admin_reply,WithdrawOrder 加 source。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #34
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-07-02 19:50:09 +08:00
zhuzihao 2727eaa929 feat(ad-revenue): 收益报表按一次比价/领券聚合展开 + 用户收益详情抽屉 (#33)
- 主表按「单次广告行为」展示:一次比价/领券聚成父行、广告类型标「N 条」,点「+」展开看该次逐条
  发奖复算;信息流统一显示「Draw 信息流」,类型筛选去掉「信息流」选项。
- 新增点用户手机号弹半屏抽屉(UserAdRevenueDrawer,复用 withdraws 的 UserRewardPanel):展示该用户
  看广告统计(6 项)+ 金币记录;抽屉统计区 eCPM 改「元/千」,并补微信昵称。
- 一次比价/领券行显示预估收益(row_revenue_yuan,发奖侧折算);去掉「一致」列 + 应发≠实发行标红 +
  顶部对账 Alert(发奖走「所见即所得」display_coin,与公式复算本就对不上,属噪音)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #33
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-07-02 08:57:04 +08:00
chenshuobo af74ecf9d3 fix: 恢复后台左侧分组导航并补充领券数据入口 (#32)
Co-authored-by: lowmaster-chen <1119780489@qq.com>
Reviewed-on: #32
Co-authored-by: chenshuobo <chenshuobo@wonderable.ai>
Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
2026-07-01 21:47:46 +08:00
zhuzihao 3102a96bd3 feat: 运营后台「领券数据」页 (#31)
- 侧边栏在「用户反馈」后、「广告配置」前加「领券数据」菜单(/coupon-data)。
- 两段式:汇总卡(领券发起数 / 完成数 / 平均耗时 + 耗时 P5/P50/P95/P99)+ 自研零依赖 SVG 趋势图
  (发起 / 完成数双柱 + 平均耗时线,按天 / 按小时)+ 逐条领券明细表。
- 「发起平台」=发起来源(傻瓜比价 / 美团 / 淘宝 / 京东);trace 列有 trace_url 给可点链接;
  手机号列可点 → 复用通用 UserRecordsDrawer 看该用户全部领券(为其加了可选 rewardSuffix prop)。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #31
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-30 22:59:09 +08:00
chenshuobo 7f5dde5c2f feat: 补齐数据大盘京东 CPS 与金币展示 (#29)
改了什么:数据大盘总收入/CPS 收入纳入京东 CPS;京东 CPS 卡片展示有效/无效订单;刷新按钮改为同步全部 CPS 订单;ARPU 展示产品确认的昨日活跃用户分母;激励视频金币展示真实值并标注已计入领券奖励。

验证方式:npx tsc --noEmit;浏览器验证 /dashboard 已显示京东 CPS、ARPU 分母和激励视频金币。

---------

Co-authored-by: lowmaster-chen <1119780489@qq.com>
Reviewed-on: #29
Co-authored-by: chenshuobo <chenshuobo@wonderable.ai>
Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
2026-06-30 21:56:55 +08:00
zhuzihao c8293d57b3 调现金弹窗:目标账户选择 + 四项必填校验 + 邀请余额展示 (#30)
- 新增「目标账户」单选(金币兑现金账户 / 邀请账户),默认不选、必选
- 操作方式改为必选无默认(原默认「增减」),进来需手动选
- 四项(目标账户/操作方式/现金变动/原因)全部必填必选:标题带红星 *,
  点确定时缺项 → 字段下方红字报错并阻断提交
- 提交携带 account;弹窗顶部余额行加显「邀请奖励金」(UserOverview 新字段)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #30
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-30 12:02:52 +08:00
marco 6a3ce5d340 fix(types): 同步 DashboardOverview/AdRevenueReport 类型, 修数据大盘页编译失败
新版数据大盘页(dashboard/page.tsx, #24)引用的后端字段在 types.ts 未同步,
next build 类型检查失败致 admin-web main 无法构建/部署(0.2.6 发车 deploy 卡在此步):
- DashboardOverview 补 period 嵌套结构(date_from/to + users/comparison/coins/cash/trend),
  对齐已部署后端 app/admin/schemas/dashboard.py 的 DashboardPeriod
- AdRevenueReport 补可选 by_ad_type(按广告类型富小计含金币明细);后端当前不下发,
  页面用 ?. 探测、缺失时按 items 现算兜底, 此处仅补类型声明让其编译通过

均为类型声明同步, 不改任何运行逻辑;本地 next build 已通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 02:39:21 +08:00
zhuzihao 98cea13d51 用户反馈/低价审核 admin 页改版(重命名+二维码挪位+统计修复+列增强) (#28)
- 重命名:菜单与页面标题「反馈工单→用户反馈」「上报审核→低价审核」
- 反馈页「加群二维码」卡挪到系统配置页,新增「反馈二维码」tab
- 修复反馈四态统计恒为 0:对接后端新增的 /admin/api/feedbacks/summary
- 两页表格增强:用户列拆为 用户ID/手机号/用户昵称;新增 提交版本号、机型/OS版本 列;
  点手机号弹抽屉看该用户全部记录,抽屉顶部汇总 提交/上报总计 + 奖励总计
- 低价审核:提交时间列可排序、审核结果与操作拆两列、顶部统计改卡片式、
  Trace 列对齐比价记录页(有 trace_url 即可点)
- 修复反馈时间显示快 8 小时:反馈时间为北京 wall-clock,改用 formatWallTime

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #28
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-29 23:11:07 +08:00
chenshuobo d7129186c5 feat: 调整后台左侧导航分组层级 (#27)
改了什么:按看板、奖励审核、数据配置重新组织左侧导航,二级入口展示在一级标题右下方;补齐主干大盘页缺失的类型字段,保证类型检查通过。

验证方式:npx tsc --noEmit;浏览器检查 /dashboard 左侧导航顺序和二级缩进。

---------

Co-authored-by: lowmaster-chen <1119780489@qq.com>
Reviewed-on: #27
Co-authored-by: chenshuobo <chenshuobo@wonderable.ai>
Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
2026-06-29 14:52:46 +08:00
zhuzihao a1d9923f33 feat(ad-revenue): 收益报表大盘展示穿山甲后台收益(预估/收益API/趋势线) (#26)
- 核心指标新增「穿山甲后台收益(T+1)」区:穿山甲预估收益、收益API、客户端预估偏差;
  按天趋势图加一条绿色「穿山甲后台收益」线(数据来自后端 daily[].pangle_*)。
- 仅在全量视图(未按用户/类型/场景筛选)展示;非全量或未同步时显示「-」并提示。
- 收益口径说明补充穿山甲后台收益来源;types.ts 同步新增 total_pangle_revenue_yuan /
  total_pangle_api_revenue_yuan / pangle_revenue_available / daily[].pangle_*。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #26
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-29 09:53:52 +08:00
chenshuobo 510ce349f7 feat: 接入新版数据大盘与系统配置分页 (#24)
改了什么:按产品 HTML 重做数据大盘 UI,接入可确认的大盘聚合数据;将首页配置和首页轮播种子迁入系统配置页并拆分首页/福利页 Tab。
验证:npx tsc --noEmit。

---------

Co-authored-by: lowmaster-chen <1119780489@qq.com>
Reviewed-on: #24
Co-authored-by: chenshuobo <chenshuobo@wonderable.ai>
Co-committed-by: chenshuobo <chenshuobo@wonderable.ai>
2026-06-28 10:37:56 +08:00
zhuzihao 03ef222b71 feat(ad-revenue): 收益报表分页、eCPM 元列、场景筛选与逐行展开+ 大盘改版 (#23)
- 列:eCPM 新增「元」列;广告位ID 移到「一致」列后。
- 分页:「每页条数」接后端 offset 真分页,可翻页看当前筛选下全部数据(原仅展示截断的前 N 条)。
- 场景:「场景」下推后端作为全局筛选(随查询生效,同时影响明细/合计/趋势),移除前端仅过滤当前数据的旧逻辑与过时提示。
- 趋势:按小时趋势改用后端全量 hourly,不受分页影响。
- 展开:明细表每行左侧都有展开号——发奖行看金币复算因子,纯展示行看展示明细(eCPM/收益/ADN/底层rit/代码位/来源)。
- 文案:修正金币规则弹窗「信息流每满10秒折1条」→「一条广告=1条」。
- types 新增 AdRevenueHourly + hourly 字段。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #23
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-27 22:42:28 +08:00
zhuzihao 6ece274af6 feat(analytics): 新增「埋点日志」管理页 (#22)
Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #22
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-26 23:56:25 +08:00
zhuzihao d3efb3c487 feat(analytics): 新增「埋点日志」管理页 (#21)
- src/app/(main)/event-logs/page.tsx:埋点日志列表,五维列 + 展开行看 props 扩展字段,
  按 事件/设备/用户/会话/接收时间 筛选,自动刷新(5s),接收时间列置首
- layout 左侧菜单加「埋点日志」入口
- 对接 admin 后端 GET /admin/api/event-logs(见 app-server 同批 PR)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #21
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-26 23:33:35 +08:00
chenshirui 5f2511e7cd feat(device-liveness): 运营后台设备存活监控页(卡片/筛选/掉线置顶/刷新) (#20)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------

Co-authored-by: 陈世睿 <2839904623@qq.com>
Reviewed-on: #20
Co-authored-by: chenshirui <chenshirui@wonderable.ai>
Co-committed-by: chenshirui <chenshirui@wonderable.ai>
2026-06-26 15:28:41 +08:00
guke 9b7a1aef8a docs(cps): 群详情每日明细「按天·按用户」领券下钻 设计文档 (#19)
增加微信用户每日领券明细

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #19
2026-06-26 15:18:25 +08:00
zhuzihao 82ffc73be5 feat(ad-revenue): 广告收益报表迁出独立页 + 接入提现激励视频 (#18)
- 报表从「广告管理」页迁出为独立「广告收益」页(ad-revenue-report),ad-revenue 页只留广告配置;
  layout 侧边栏拆成「广告配置」+「广告收益」两个入口
- ad-revenue 配置页新增「提现激励视频开关」(withdrawal_ad_enabled),控制客户端提现是否走看视频
- 广告收益报表新增「提现激励视频」类型标签与筛选项(ad_type=withdrawal_video)

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #18
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-24 22:37:49 +08:00
marco c699ad0b8e docs: README 更新功能模块与部署说明
- 功能从 P0 概述扩写为 12 个模块详述(数据大盘/用户/设备/提现/上报审核/比价记录/反馈/广告/CPS分发/系统配置/管理员/审计)
- 补充按角色(finance/operator/super)操作权限说明
- 部署注意:NEXT_PUBLIC_MEDIA_BASE 必须指向 App 后端域名,否则 /media 图片 404

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 04:08:08 +08:00
zhuzihao c488513d24 feat(ad-revenue): 报表主表改逐条广告事件 + 用户列显示手机号 (#17)
对齐后端 /admin/api/ad-revenue-report 的逐条事件结构(每次广告一行,不再按用户折叠):
- 主表列:时间/用户/类型/应用/广告位/eCPM/收益/发奖状态/应发/实发/一致/底层ADN;
  点行展开看该条金币复算因子(因子1/因子2/份数/LT)
- 「用户」列从 user_id 改为显示完整手机号(辅小字 #uid);查不到手机号显示 #uid(无手机号)
- 纯展示行(信息流逐条展示 / 无对应发奖):金币列显「-」、状态显「仅展示」、不计对账
- 统计卡「聚合组数」→「广告事件数」,提示文案 组→条
- rowKey 用后端下发的 event_key

tsc --noEmit 本次两文件零类型错误。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #17
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-24 03:54:13 +08:00
marco 67fc466778 feat(comparison-records): 展示 LLM token(入/出)与按单价估算成本
比价记录页新增 Token 列与成本列;顶部输入框配置每百万 token 单价(localStorage 持久化),仅前端估算不入库。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:24:57 +08:00
zhuzihao 65d968585e feat(feedback): 反馈工单接入审核采纳/拒绝 + 历史反馈 (#16)
对接后端 approve/reject 接口,admin 反馈工单从单一「处理」改为审核双动作:
- FeedbackHandleDrawer: Segmented 采纳/拒绝 —— 采纳(发金币 1~10000 + 采纳备注)、
  拒绝(填用户端可见原因 + 内部备注);抽屉内联展示该用户历史反馈辅助审核
- 列表页: 状态筛选改 审核中/已采纳/未采纳、新增「审核结果」列、操作改 审核/查看,移除旧批量标记
- types.ts: Feedback 字段对齐后端(reject_reason/reward_coins/review_note/reviewed_by_admin_id/reviewed_at)

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #16
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-22 23:20:24 +08:00
zhuzihao bd0bacb836 feat(withdraw-detail): 抽屉占屏一半 + 金币记录页码分页 + 风险提示去分数 (#15)
- 详情抽屉宽度 720→50%
- 金币记录改 antd 页码分页(10/页,显示「共N条」)
- 风险提示去掉不可解释的分数;历史异常拆成「提现拒绝/提现失败」两类

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #15
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-21 23:41:33 +08:00
zhuzihao 12cc6f7aa6 fix(media): 后台图片指向 App 后端,修线上图片 404 (#14)
线上 admin(admin-web.shaguabijia.com)的「上报审核截图 / 反馈截图 / 反馈二维码」
全部 404:NEXT_PUBLIC_MEDIA_BASE 为空时,图片被拼到不 serve /media 的 admin 域。
媒体实际由 App 后端托管,故让前端指向它:

- .env.production: NEXT_PUBLIC_MEDIA_BASE=https://app-api.shaguabijia.com
- lib/media.ts: base 去末尾斜杠,防拼出 //media

注:NEXT_PUBLIC_* 为编译期常量,合并后服务器需 npm run build + 重启服务才生效。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #14
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-21 12:09:45 +08:00
zhuzihao 36d00a9d68 feat(force-onboarding): 用户列表加「开启新手引导」一次性操作 (#13)
Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #13
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-21 00:15:17 +08:00
marco 98417ff8c6 feat(ad): 「广告数据」改「广告管理」,加穿山甲配置面板
- 顶部广告配置面板:app_id / 3 场景广告位ID / 激励验签密钥(Password) / 3 场景开关,
  读写 /admin/api/ad-config
- 原报表保留为下方「收益报表」;左侧菜单更名「广告管理」

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 01:35:19 +08:00
marco b7957e58c0 feat(cps): 群详情页加「群内微信用户」区块(头像/昵称/领券次数)
微信落地页授权来的用户列表:头像+昵称+领券次数(点复制)+点击次数+首次进入。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:44:14 +08:00
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
ouzhou 94d550db9b feat(admin-web): 余额设值/批量、时区与复用整顿、用户列表排序筛选 (#9)
- 调金币/现金:新增「设为指定值」模式;修 InputNumber addonAfter 废弃告警;
  弹窗抽成共享组件 AdjustBalanceModals(列表页/详情页复用,内含防竞态余额拉取)
- 新增 lib/format.ts 统一金额/时间格式化:区分 UTC 字段与钱包流水(北京 wall-clock),
  修正提现详情现金流水快 8 小时、用户注册时间慢 8 小时等偏差
- 提现页:账本校验(ledger-check)改按需触发,写操作后不再重算;现金流水时区修正
- 用户详情页:加载失败 Result 兜底(不再永久转圈)+ 现金流水 tab + 调金币/现金/封禁入口
- 金币审计页:加「只看不一致」过滤;截断提示改用后端精确 truncated 旗标
- 用户列表:注册/最近登录/ID 列排序 + 渠道/昵称/时间范围筛选 + 批量封禁解封/开关调试链接

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

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #9
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-13 23:43:52 +08:00
marco 2eff4a71be feat(onboarding): 删「按用户强制引导」UI + 新增设备管理页
- users 页删 force_onboarding 操作 + types 字段
- 新增设备管理页(/devices): 按设备列表 + 行内「重走引导」+「全部重设」危险操作 + 菜单项

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 00:51:51 +08:00
zhuzihao c66d90a6e1 feat(force-onboarding): 用户列表加「开启新手引导」一次性操作 (#8)
点一下把该用户标记为未看过引导:Ta 下次打开 App 重走一遍新手引导,看完后端自动恢复(只触发这一次);未消费前显示「待重看」可撤销。operator 角色可见。

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

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #8
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-10 22:26:41 +08:00
ouzhou 1717ee8b6a feat(dashboard): 首页三数共享保存+智能校验+保底;轮播种子批量操作 (#7)
- HomeStatsConfig: 三指标共享保存/立即更新;智能关系校验弹窗;真实值保底;初始基数护栏提示;saveAll 半失败合并
- HomeMarqueeSeeds: 勾选 + 批量启用/停用/删除
- 附带并行会话 WIP:users 页

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

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #7
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-10 22:26:36 +08:00
ouzhou 8c7e2b0220 feat(ad-audit): 新增看广告金币审计页 (#4)
- /ad-audit 复算对账页:公式快照 + reward_video/feed 发奖明细 + 实发与复算一致性校验
- 侧栏新增「金币审计」导航
- types 增加 AdCoinAuditRow / AdCoinFormula / AdCoinAudit

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #4
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-10 16:07:52 +08:00
marco 6766fe8c10 Merge pull request 'feat(debug-trace): 用户列表加「开/关调试链接」开关' (#6) from feat/debug-trace-link into main
Reviewed-on: #6
2026-06-10 15:34:50 +08:00
marco 3c757aa838 feat(debug-trace): 用户列表加「开/关调试链接」开关
- UserListItem 加 debug_trace_enabled
- 操作列加开关,调 POST /admin/api/users/{id}/debug-trace(operator 守卫、二次确认,仿封禁)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:31:27 +08:00
marco 97cdc07c4a Merge pull request 'feat: 运营后台「上报审核」页' (#5) from feat/price-report-review into main
Reviewed-on: #5
2026-06-10 14:44:32 +08:00
xianze cac9a38192 feat(price-reports): 运营后台「上报审核」页
用户上报"某平台比我们算的最低价更便宜"+截图,运营人工审核:
- price-reports/page.tsx: 列表+截图预览+通过(发1000金币)/拒绝(填理由),仿 withdraws
- layout.tsx: 侧边栏菜单加「上报审核」(FlagOutlined)
- lib/types.ts: PriceReport / PriceReportSummary DTO
- .gitignore: 加 CLAUDE.md(本地维护不入库,同 android/app-server 约定)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:45:29 +08:00
marco 45b9c7140d Merge pull request 'feat: 运营后台完善微信提现审核列表和批量处理' (#3) from feature/withdraw-review-ops into main
Reviewed-on: #3
2026-06-09 01:55:47 +08:00
42 changed files with 12428 additions and 799 deletions
+8 -2
View File
@@ -1,3 +1,9 @@
# 生产:前端与 admin API 同域(admin.shaguabijia.com),nginx 把 /admin/api 反代到后端 8771。
# 留空 = axios 用相对路径,请求落到当前域名,由 nginx 转发。
# 生产:前端与 admin API 同域(admin-web.shaguabijia.com),nginx 把 /admin/api 反代到后端 8771。
# 留空 = axios 用相对路径,请求落到当前域名,由 nginx 转发。(已验证线上 /admin/api 走通、操作 200)
NEXT_PUBLIC_API_BASE=
# 媒体(反馈二维码 / 上报截图 / 头像)由 App 后端托管在 app-api.shaguabijia.com/media,【不在 admin 域】。
# 必须指到 App 后端的公开域名,否则 admin 里所有 /media 图片都会 404(被拼到 admin 域而非 App 域)。
# 都是 https → 无混合内容;<img> 跨域加载不受 CORS 限制,无需后端配 CORS。
# ⚠️ NEXT_PUBLIC_* 是编译期常量:改这里后必须重新 `npm run build` 再部署,前端才会带上新值。
NEXT_PUBLIC_MEDIA_BASE=https://app-api.shaguabijia.com
+3
View File
@@ -7,3 +7,6 @@ next-env.d.ts
.DS_Store
data/
.run-logs/
# CLAUDE.md 本地维护、不入库(同 android/app-server 约定)
CLAUDE.md
+37 -7
View File
@@ -4,11 +4,34 @@ Next.js 15 + Ant Design 5,对接 `shaguabijia-app-server` 的 admin API(`app/adm
后端是独立 FastAPI app(端口 8771,复用 app-server 的 models/repositories + 同一 PostgreSQL,
鉴权用独立 JWT)。本仓只是前端。
## 功能(P0)
数据大盘 / 用户管理(列表+360详情+封禁+调金币) / 提现管理(列表+重试+对账) /
反馈工单 / 管理员账号(super) / 审计日志。按角色显示操作:finance=钱、operator=用户+反馈、super=全部。
## 功能模块
侧边栏 `MENU`(`src/app/(main)/layout.tsx`)共 12 个模块,均已上线:
- **数据大盘**(`/dashboard`)— 核心经营指标概览。
- **用户管理**(`/users`)— 列表(多条件筛选+服务端排序)+ 360 详情;封禁/解封、调金币、调现金;运营开关:开启新手引导(标记用户重看一次,看完自动恢复)、开/关调试链接(trace 排障)、以及上述操作的批量勾选执行。
- **设备管理**(`/devices`)— 按硬件 ANDROID_ID 聚合走过新手引导的设备;可单设备或全量「重设新手引导」。
- **提现管理**(`/withdraws`)— 提现单审核(通过即发起微信打款 / 拒绝退回余额)、查单刷新、批量通过/拒绝/刷新、批量对账,含微信配置健康检查 + 现金账本校验。
- **上报审核**(`/price-reports`)— 用户上报「某平台更低价」+ 截图,人工核实;通过发放 1000 金币、拒绝需填理由(用户端轮询看到结果)。
- **比价记录**(`/comparison-records`)— 比价过程明细(逐平台结局/卡在哪一步、LLM 调用明细、trace);支持按页面输入的 LLM 单价做 token / 成本估算(纯前端,见 `calcCost` / `pricePerMTok`)。
- **反馈工单**(`/feedbacks`)— 用户反馈审核闭环:采纳发金币 / 拒绝填用户可见原因(`FeedbackHandleDrawer`),并含反馈二维码配置。
- **广告管理**(`/ad-revenue`)— 穿山甲广告配置(应用ID/各场景广告位ID/验签密钥/开关)+ 收益报表(展示/预估收益 与 应发/实发金币逐组对账)。
- **CPS 分发**(`/cps`)— 优惠券分发与对账(美团/淘宝/京东);单页 Tabs:对账统计 / 群管理 / 活动管理 / 订单明细。含群对账详情子页(`/cps/groups/[id]`):点击/复制趋势折线 + 按天明细 + 群内微信用户画像。
- **系统配置**(`/config`)— 业务运行参数(金额/上限等)在线读写,改完即时生效、每次进审计。
- **管理员**(`/admins`)— 管理员账号管理(仅 `super_admin` 可见)。
- **审计日志**(`/audit-logs`)— 所有写操作的审计流水。
### 按角色显示操作
权限以 `src/lib/auth.ts``canDo()` 为准(`super_admin` 恒可全部),各 `page.tsx``canDo([...])` 控制按钮:
- **finance**(钱):提现审核、用户调金币/调现金、CPS 对账(刷新拉美团订单)。
- **operator**(用户+内容):用户封禁/调试链接/新手引导、反馈审核、上报审核、设备重设引导、CPS 群/活动管理。
- **super**(super_admin):以上全部 + 管理员账号管理。
- 数据大盘 / 比价记录 / 广告管理 / 系统配置 / 审计日志:无 `canDo` 门,登录即可查看。
## 本地开发
```bash
# 1. 起 admin 后端(在 shaguabijia-app-server 目录)
conda activate price && uvicorn app.admin.main:admin_app --port 8771
@@ -17,11 +40,13 @@ python scripts/create_admin.py --username admin --role super_admin
# 3. 起前端
npm install && npm run dev # → http://localhost:3001
```
`.env.local` 已指向 `http://localhost:8771`
## 生产部署(阿里云,域名 admin.shaguabijia.com)
### ① 后端 admin(在 app-server 机器,复用同一 codebase)
```bash
scp deploy/shaguabijia-admin.service server:/etc/systemd/system/ # 文件在 app-server/deploy/
ssh server "systemctl daemon-reload && systemctl enable --now shaguabijia-admin"
@@ -29,6 +54,7 @@ ssh server "cd /opt/shaguabijia-app-server && .venv/bin/python scripts/create_ad
```
### ② 前端
```bash
npm run build
rsync -avz --exclude node_modules --exclude .next/cache ./ server:/opt/shaguabijia-admin-web/
@@ -36,13 +62,17 @@ ssh server "cd /opt/shaguabijia-admin-web && npm install --omit=dev && npm run b
```
### ③ nginx
```bash
scp deploy/nginx/admin.shaguabijia.com.conf server:/etc/nginx/conf.d/
ssh server "nginx -t && systemctl reload nginx"
```
## ⚠️ 上线必查
- [ ] app-server 的 `.env``ADMIN_JWT_SECRET` 改成 ≥32 字节强随机串(默认 `change-me-admin` 可伪造 token)
- [ ] nginx 打开 IP 白名单(运营出口 IP)+ HTTPS 证书就位
- [ ] `.env.production``NEXT_PUBLIC_API_BASE` 留空(同域,nginx 反代 /admin/api)
- [ ] 首个 super_admin 已建、能登录
- app-server 的 `.env``ADMIN_JWT_SECRET` 改成 ≥32 字节强随机串(默认 `change-me-admin` 可伪造 token)
- nginx 打开 IP 白名单(运营出口 IP)+ HTTPS 证书就位
- `.env.production``NEXT_PUBLIC_API_BASE` 留空(同域,nginx 反代 /admin/api)
- `.env.production``NEXT_PUBLIC_MEDIA_BASE` 必须指向 **App 后端** `https://app-api.shaguabijia.com`(/media 的真正的家),否则后台所有 /media 图片(上报/反馈截图、二维码、头像)被拼到 admin 域而 404。`NEXT_PUBLIC_*` 是编译期常量,改完必须重新 `npm run build` 才生效(详见 `src/lib/media.ts`)
- 首个 super_admin 已建、能登录
+12
View File
@@ -32,6 +32,18 @@ server {
proxy_read_timeout 60s;
}
# 用户上传媒体(上报截图 / 反馈截图 / 反馈二维码)由 App 后端(:8770)的 /media 托管。
# admin 页面要展示这些图,经此同域反代过去(免跨域 + 免 https 页面引 http 图被拦)。
# 前提:App 后端与本 admin 同机;若分机,把 127.0.0.1:8770 换成 App 后端可达地址。
# 配好此段后,前端 .env.production 的 NEXT_PUBLIC_MEDIA_BASE 留空即走同域。
location /media/ {
proxy_pass http://127.0.0.1:8770;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# 其余 → Next.js 前端
location / {
proxy_pass http://127.0.0.1:3001;
@@ -0,0 +1,746 @@
# CPS 群详情 · 每日明细「按天 · 按用户」领券下钻 — 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在 CPS 群详情页每日明细表每行加「明细」按钮,点开弹窗展示该群该天以用户为单位的领券/点击,并在「点击次数」上悬停展示该用户当天点过的优惠券(券名 ×次数)。
**Architecture:** 后端 `shaguabijia-app-server` 新增只读聚合接口 `/admin/api/cps/groups/{id}/day-users?date=YYYY-MM-DD`(券明细内联返回),并把既有 `/daily``date``MM-DD` 改全为 `YYYY-MM-DD` 供下钻定位整天。前端 `shaguabijia-admin-web` 加明细列 + Modal + Tooltip,一次请求渲染。纯读侧聚合,**不动数据库**。
**Tech Stack:** 后端 FastAPI + SQLAlchemy + pytest`.venv`);前端 Next.js 15 + Ant Design 5 + TypeScript(无测试框架,门槛为 `npm run build`)。
**配套 spec** `docs/superpowers/specs/2026-06-26-cps-day-user-drilldown-design.md`
---
## 仓库与分支
- **前端** `e:\project\shaguabijia-admin-web`:已在分支 `feat/cps-day-user-drilldown`
- **后端** `e:\project\shaguabijia-app-server`:独立 git 仓,需自建同名分支(Task 1 Step 0)。
后端命令统一**从后端仓根目录运行**;git 用 `git -C e:\project\shaguabijia-app-server ...` 避免切目录。
## File Structure
| 文件 | 改动 | 职责 |
|---|---|---|
| `app/admin/routers/cps.py`(后端) | 改 1 行 + 加 1 个 endpoint | `/daily``date` 改全日期;新增 `/day-users` 路由(鉴权、日期解析、窗口计算) |
| `app/admin/repositories/cps.py`(后端) | 加 1 个函数 + 补 1 处 import | `group_day_users(...)`:按 openid 聚合领券/点击 + 券明细(Python 侧) |
| `tests/test_cps_admin.py`(后端) | 新建 | admin CPS 端点测试:`/daily` 日期格式 + `/day-users` 聚合与边界 |
| `src/app/(main)/cps/groups/[id]/page.tsx`(前端) | 改 | 日期列加宽、明细列、Modal、Tooltip、按用户表 |
---
## Task 1: 后端 `/daily` 的 `date` 改为 `YYYY-MM-DD`
**Files:**
- Modify: `app/admin/routers/cps.py:472``group_daily` 内构行处)
- Test: `tests/test_cps_admin.py`(新建)
- [ ] **Step 0: 在后端仓建分支**
先看工作区状态,避免把无关改动带入:
Run:
```
git -C e:\project\shaguabijia-app-server status --short
git -C e:\project\shaguabijia-app-server checkout -b feat/cps-day-user-drilldown
```
Expected: 切到新分支 `feat/cps-day-user-drilldown`
- [ ] **Step 1: 写失败测试(新建测试文件,含共用 fixtures + 种子)**
新建 `tests/test_cps_admin.py`,内容如下(fixtures 仿 `tests/test_admin_read.py` 自包含;种子函数后续 Task 复用):
```python
"""admin CPS 端点测试:每日明细 date 格式 + 按天按用户领券下钻(/day-users)。"""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from app.admin.main import admin_app
from app.admin.repositories import admin_user as admin_repo
from app.admin.repositories import cps as cps_repo
from app.db.session import SessionLocal
from app.models.cps_link import CpsClick
from app.models.cps_wx_user import CpsWxUser
from app.repositories import cps_link as cps_link_repo
_BJ = timezone(timedelta(hours=8))
@pytest.fixture()
def admin_client() -> TestClient:
return TestClient(admin_app)
@pytest.fixture()
def admin_token() -> str:
db = SessionLocal()
try:
if admin_repo.get_by_username(db, "cps_test_admin") is None:
admin_repo.create_admin(
db, username="cps_test_admin", password="cps-pass", role="super_admin"
)
finally:
db.close()
c = TestClient(admin_app)
r = c.post("/admin/api/auth/login", json={"username": "cps_test_admin", "password": "cps-pass"})
return r.json()["access_token"]
def _auth(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
def _seed_cps_day(day_bj: date) -> tuple[int, str]:
"""造淘宝群 + 2 个活动(券) + 2 条 link + day_bj 当天点击(含匿名 + 次日各 1) + 授权画像。
o_user(本群唯一):券A visit×3、券B visit×2、copy×2 → visit_count=5、copy_count=2。
另造 1 条匿名 visit(openid=None)与 1 条次日 visit,均不应计入当天该用户聚合。
clicked_at 统一存 tz-aware UTC(SQLite DateTime 按字段渲染、忽略 tzinfo;
转 UTC 后字段即 UTC 墙钟,与 repo 侧 _as_utc 比较口径一致)。返回 (group_id, openid)。
"""
db = SessionLocal()
try:
gid = cps_repo.create_group(db, name="下钻测试群", platforms=["taobao"]).id
openid = f"od_user_{gid}" # 按群唯一,规避 CpsWxUser.openid 唯一约束跨用例冲突
a1 = cps_repo.create_activity(db, name="618神券", platform="taobao", payload="tkl-1")
a2 = cps_repo.create_activity(db, name="买一送一", platform="taobao", payload="tkl-2")
link1 = cps_link_repo.create_link(
db, group_id=gid, activity_id=a1.id, sid=None, target_url="t1", platform="taobao"
)
link2 = cps_link_repo.create_link(
db, group_id=gid, activity_id=a2.id, sid=None, target_url="t2", platform="taobao"
)
db.add(CpsWxUser(openid=openid, nickname="张三", headimgurl="https://h/1"))
db.commit()
def at(hour: int) -> datetime:
return datetime(day_bj.year, day_bj.month, day_bj.day, hour, tzinfo=_BJ).astimezone(
timezone.utc
)
for _ in range(3):
db.add(CpsClick(link_id=link1.id, group_id=gid, sid=None,
event_type="visit", openid=openid, clicked_at=at(10)))
for _ in range(2):
db.add(CpsClick(link_id=link2.id, group_id=gid, sid=None,
event_type="visit", openid=openid, clicked_at=at(11)))
for _ in range(2):
db.add(CpsClick(link_id=link1.id, group_id=gid, sid=None,
event_type="copy", openid=openid, clicked_at=at(12)))
# 匿名点击 — 不计入
db.add(CpsClick(link_id=link1.id, group_id=gid, sid=None,
event_type="visit", openid=None, clicked_at=at(13)))
# 次日点击 — 验证时间窗,不计入当天
nxt = (datetime(day_bj.year, day_bj.month, day_bj.day, 10, tzinfo=_BJ)
+ timedelta(days=1)).astimezone(timezone.utc)
db.add(CpsClick(link_id=link1.id, group_id=gid, sid=None,
event_type="visit", openid=openid, clicked_at=nxt))
db.commit()
return gid, openid
finally:
db.close()
def test_daily_date_is_full_iso(admin_client: TestClient, admin_token: str) -> None:
"""/daily 每行 date 改为 YYYY-MM-DD(10 字符、两个连字符),不再是 MM-DD。"""
gid, _ = _seed_cps_day(date(2026, 6, 25))
r = admin_client.get(
f"/admin/api/cps/groups/{gid}/daily", params={"days": 3}, headers=_auth(admin_token)
)
assert r.status_code == 200, r.text
rows = r.json()["rows"]
assert rows, "应有按天补零行"
for row in rows:
assert len(row["date"]) == 10 and row["date"].count("-") == 2, row["date"]
```
- [ ] **Step 2: 跑测试确认失败**
Run(从后端仓根目录 `e:\project\shaguabijia-app-server`:
```
.\.venv\Scripts\pytest.exe tests\test_cps_admin.py::test_daily_date_is_full_iso -v
```
Expected: FAIL —— `date` 当前为 `"%m-%d"`(长度 5),断言 `len==10` 不通过。
- [ ] **Step 3: 改 router 的日期格式**
`app/admin/routers/cps.py``group_daily` 内(约 472 行),把构行的日期字段改为全日期:
```python
row = {
"date": cur.strftime("%Y-%m-%d"),
"click_pv": cp["click_pv"] if cp else 0,
"click_uv": cp["click_uv"] if cp else 0,
"copy_pv": cp["copy_pv"] if cp else 0,
}
```
(仅把原 `cur.strftime("%m-%d")` 改成 `cur.strftime("%Y-%m-%d")`,其余不动。)
- [ ] **Step 4: 跑测试确认通过**
Run:
```
.\.venv\Scripts\pytest.exe tests\test_cps_admin.py::test_daily_date_is_full_iso -v
```
Expected: PASS。
- [ ] **Step 5: 提交(后端仓)**
```
git -C e:\project\shaguabijia-app-server add app/admin/routers/cps.py tests/test_cps_admin.py
git -C e:\project\shaguabijia-app-server commit -m "feat(cps): /daily 的 date 改为 YYYY-MM-DD(下钻定位整天用)"
```
---
## Task 2: 后端新增 `group_day_users` 聚合 + `/day-users` 接口
**Files:**
- Modify: `app/admin/repositories/cps.py:20`import 补 `CpsLink`)、文件末尾加函数
- Modify: `app/admin/routers/cps.py``group_wx_users` 路由后追加新路由)
- Test: `tests/test_cps_admin.py`(追加)
- [ ] **Step 1: 写失败测试(聚合 happy path,含匿名/跨天排除、券倒序、合计自洽)**
`tests/test_cps_admin.py` 末尾追加:
```python
def test_day_users_aggregates(admin_client: TestClient, admin_token: str) -> None:
"""当天该群:仅授权用户;领券=copy、点击=visit;coupons=visit 券×次数倒序、合计=点击次数。"""
gid, openid = _seed_cps_day(date(2026, 6, 25))
r = admin_client.get(
f"/admin/api/cps/groups/{gid}/day-users",
params={"date": "2026-06-25"},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
body = r.json()
assert body["group_id"] == gid
assert body["date"] == "2026-06-25"
users = body["users"]
assert len(users) == 1 # 匿名不计、次日不计
u = users[0]
assert u["openid"] == openid
assert u["nickname"] == "张三"
assert u["headimgurl"] == "https://h/1"
assert u["copy_count"] == 2
assert u["visit_count"] == 5
assert [c["name"] for c in u["coupons"]] == ["618神券", "买一送一"]
assert [c["count"] for c in u["coupons"]] == [3, 2]
assert sum(c["count"] for c in u["coupons"]) == u["visit_count"]
```
- [ ] **Step 2: 跑测试确认失败**
Run:
```
.\.venv\Scripts\pytest.exe tests\test_cps_admin.py::test_day_users_aggregates -v
```
Expected: FAIL —— 404/路由不存在(`/day-users` 尚未实现)。
- [ ] **Step 3: 补 repo import**
`app/admin/repositories/cps.py` 第 20 行,给 `cps_link` 的 import 补上 `CpsLink`
```python
from app.models.cps_link import CpsClick, CpsLink
```
- [ ] **Step 4: 加 repo 聚合函数**
`app/admin/repositories/cps.py` 末尾(`group_wx_users` 之后)追加:
```python
def group_day_users(
db: Session, *, group_id: int, start: datetime, end: datetime, limit: int = 200,
) -> list[dict]:
"""该群某天(北京)以用户为单位的领券/点击 + 每人 visit 过的券。
时间窗为半开区间 [start, end)(end=次日 00:00),避免午夜双计。只统计 openid 非空
(可归属到人)的点击 —— 匿名点击(美团/京东 302 多为匿名)不计入。券名 = 该点击 link
对应活动名;活动被硬删则兜底 活动#{id}。copy=领券次数、visit=点击次数;coupons 仅
取 visit 事件按活动分组、按次数倒序(合计 = visit_count)。排序:领券 desc、再点击 desc。
与 group_wx_users 同风格(Python 侧聚合,跨 PG/SQLite 无方言坑)。
注:每日明细行的 click_pv/copy_pv 计全部点击(含匿名、UV 按 ip,ua);本函数只计 openid
用户,故各用户求和 <= 当天行总数,二者口径不同、不必相等。
"""
rows = db.execute(
select(CpsClick.openid, CpsClick.event_type, CpsClick.link_id)
.where(CpsClick.group_id == group_id)
.where(CpsClick.clicked_at >= _as_utc(start))
.where(CpsClick.clicked_at < _as_utc(end))
.where(CpsClick.openid.is_not(None))
).all()
if not rows:
return []
# link_id -> activity_id -> 券名(活动名)
link_ids = {r.link_id for r in rows}
link_to_act = dict(
db.execute(
select(CpsLink.id, CpsLink.activity_id).where(CpsLink.id.in_(link_ids))
).all()
)
act_ids = {aid for aid in link_to_act.values() if aid is not None}
act_name = (
dict(
db.execute(
select(CpsActivity.id, CpsActivity.name).where(CpsActivity.id.in_(act_ids))
).all()
)
if act_ids
else {}
)
def _coupon_name(link_id: int) -> str:
aid = link_to_act.get(link_id)
if aid is None:
return f"链接#{link_id}"
return act_name.get(aid) or f"活动#{aid}"
stat: dict[str, dict] = {}
for openid, event_type, link_id in rows:
s = stat.setdefault(openid, {"copy": 0, "visit": 0, "coupons": {}})
if event_type == "copy":
s["copy"] += 1
else:
s["visit"] += 1
name = _coupon_name(link_id)
s["coupons"][name] = s["coupons"].get(name, 0) + 1
openids = list(stat.keys())
users = {
u.openid: u
for u in db.execute(
select(CpsWxUser).where(CpsWxUser.openid.in_(openids))
).scalars().all()
}
result = [
{
"openid": openid,
"nickname": users[openid].nickname if openid in users else None,
"headimgurl": users[openid].headimgurl if openid in users else None,
"copy_count": s["copy"],
"visit_count": s["visit"],
"coupons": [
{"name": name, "count": cnt}
for name, cnt in sorted(
s["coupons"].items(), key=lambda kv: kv[1], reverse=True
)
],
}
for openid, s in stat.items()
]
result.sort(key=lambda x: (x["copy_count"], x["visit_count"]), reverse=True)
return result[:limit]
```
- [ ] **Step 5: 加 router 端点**
`app/admin/routers/cps.py`,在 `group_wx_users` 路由(文件末尾那个)之后追加:
```python
@router.get("/groups/{group_id}/day-users", summary="某天该群按用户的领券/点击 + 每人点过的券")
def group_day_users(
group_id: int,
db: AdminDb,
date: Annotated[str, Query(description="北京日期 YYYY-MM-DD")],
) -> dict:
group = cps_repo.get_group(db, group_id)
if group is None:
raise HTTPException(status_code=404, detail="群不存在")
bj = timezone(timedelta(hours=8))
try:
day0 = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=bj)
except ValueError as e:
raise HTTPException(status_code=400, detail="date 格式应为 YYYY-MM-DD") from e
start = day0.replace(hour=0, minute=0, second=0, microsecond=0)
end = start + timedelta(days=1)
users = cps_repo.group_day_users(db, group_id=group_id, start=start, end=end)
return {
"group_id": group.id,
"group_name": group.name,
"date": date,
"users": users,
}
```
`datetime`/`timedelta`/`timezone``Annotated``Query``HTTPException` 该文件均已 import;无需新增。)
- [ ] **Step 6: 跑测试确认通过**
Run:
```
.\.venv\Scripts\pytest.exe tests\test_cps_admin.py::test_day_users_aggregates -v
```
Expected: PASS。
- [ ] **Step 7: 提交(后端仓)**
```
git -C e:\project\shaguabijia-app-server add app/admin/repositories/cps.py app/admin/routers/cps.py tests/test_cps_admin.py
git -C e:\project\shaguabijia-app-server commit -m "feat(cps): 新增 /day-users 按天按用户领券下钻接口"
```
---
## Task 3: 后端 `/day-users` 边界用例(404 / 400 / 401 / 空天 / 跨年)
**Files:**
- Test: `tests/test_cps_admin.py`(追加)
> 这些断言验证 Task 2 实现已覆盖的边界。多数应直接通过;若某条失败,回到 Task 2 对应分支修实现。
- [ ] **Step 1: 追加边界测试**
`tests/test_cps_admin.py` 末尾追加:
```python
def test_day_users_group_not_found(admin_client: TestClient, admin_token: str) -> None:
r = admin_client.get(
"/admin/api/cps/groups/999999/day-users",
params={"date": "2026-06-25"},
headers=_auth(admin_token),
)
assert r.status_code == 404
def test_day_users_bad_date(admin_client: TestClient, admin_token: str) -> None:
gid, _ = _seed_cps_day(date(2026, 6, 25))
r = admin_client.get(
f"/admin/api/cps/groups/{gid}/day-users",
params={"date": "2026/06/25"}, # 非 YYYY-MM-DD
headers=_auth(admin_token),
)
assert r.status_code == 400
def test_day_users_requires_auth(admin_client: TestClient) -> None:
r = admin_client.get(
"/admin/api/cps/groups/1/day-users", params={"date": "2026-06-25"}
)
assert r.status_code == 401
def test_day_users_empty_when_no_clicks(admin_client: TestClient, admin_token: str) -> None:
gid, _ = _seed_cps_day(date(2026, 6, 25))
r = admin_client.get(
f"/admin/api/cps/groups/{gid}/day-users",
params={"date": "2026-06-20"}, # 该群当天无任何点击
headers=_auth(admin_token),
)
assert r.status_code == 200
assert r.json()["users"] == []
def test_day_users_cross_year(admin_client: TestClient, admin_token: str) -> None:
"""跨年:YYYY-MM-DD 才能精确定位 12-31(MM-DD 会丢年份);次日(次年 01-01)不计入。"""
gid, openid = _seed_cps_day(date(2025, 12, 31))
r = admin_client.get(
f"/admin/api/cps/groups/{gid}/day-users",
params={"date": "2025-12-31"},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
users = r.json()["users"]
assert len(users) == 1
assert users[0]["openid"] == openid
assert users[0]["visit_count"] == 5 # 次年 01-01 那条被时间窗排除
```
- [ ] **Step 2: 跑整个测试文件确认全绿**
Run:
```
.\.venv\Scripts\pytest.exe tests\test_cps_admin.py -v
```
Expected: 全部 PASS(共 7 个用例:daily 日期、aggregates、404、bad_date、auth、empty、cross_year)。
- [ ] **Step 3: 跑后端全量回归(确认没碰坏既有)**
Run:
```
.\.venv\Scripts\pytest.exe -q
```
Expected: 全绿(与改动前同样的通过数 + 新增 7 条)。
- [ ] **Step 4: 提交(后端仓)**
```
git -C e:\project\shaguabijia-app-server add tests/test_cps_admin.py
git -C e:\project\shaguabijia-app-server commit -m "test(cps): /day-users 边界用例(404/400/401/空天/跨年)"
```
---
## Task 4: 前端日期列显示全日期并加宽
**Files:**
- Modify: `src/app/(main)/cps/groups/[id]/page.tsx`
前端无测试框架,门槛为类型/构建通过(`npm run build`)。
- [ ] **Step 1: 加宽日期列**
`dailyColumns` 第一列(约 174 行)`width: 64``width: 96`
```tsx
{ title: '日期', dataIndex: 'date', width: 96, fixed: 'left' },
```
`date` 现由后端给全 `YYYY-MM-DD`,直接显示即可;`DailyRow.date` 类型仍是 `string`,无需改接口。)
- [ ] **Step 2: 构建确认通过**
Run(前端仓根目录):
```
npm run build
```
Expected: 构建成功,无类型错误。
- [ ] **Step 3: 提交(前端仓)**
```
git add src/app/(main)/cps/groups/[id]/page.tsx
git commit -m "feat(cps): 每日明细日期列改显示全日期并加宽"
```
---
## Task 5: 前端下钻 — 明细列 + Modal + Tooltip
**Files:**
- Modify: `src/app/(main)/cps/groups/[id]/page.tsx`
- [ ] **Step 1: antd 引入 Modal、Tooltip**
第 9 行 import 增加 `Modal, Tooltip`(保持字母序即可):
```tsx
import { Button, Card, Col, Empty, Modal, Row, Segmented, Space, Spin, Statistic, Table, Tooltip, message } from 'antd';
```
- [ ] **Step 2: 加按天用户类型**
`WxUser` 接口定义之后(约 58 行后)追加:
```tsx
// 某天该群按用户领券下钻(/day-users)
interface DayCoupon {
name: string;
count: number;
}
interface DayUser {
openid: string;
nickname: string | null;
headimgurl: string | null;
copy_count: number;
visit_count: number;
coupons: DayCoupon[];
}
```
- [ ] **Step 3: 加弹窗状态 + 拉取 handler**
在组件内 `wxUsers` 相关 `useEffect` 之后(约 140 行后)追加。注意须在 `dailyColumns` 定义之前,供其按钮引用:
```tsx
// 某天领券下钻弹窗
const [dayDetail, setDayDetail] = useState<{
open: boolean;
date: string;
loading: boolean;
users: DayUser[];
}>({ open: false, date: '', loading: false, users: [] });
const openDayDetail = useCallback(
async (date: string) => {
setDayDetail({ open: true, date, loading: true, users: [] });
try {
const r = await api.get<{ users: DayUser[] }>(
`/admin/api/cps/groups/${id}/day-users?date=${date}`,
);
setDayDetail((s) => ({ ...s, loading: false, users: r.data.users || [] }));
} catch (e) {
message.error(errMsg(e, '明细加载失败'));
setDayDetail((s) => ({ ...s, loading: false }));
}
},
[id],
);
```
- [ ] **Step 4: 每日明细表加「明细」列 + 调大 scroll.x**
`dailyColumns` 数组末尾(`结算佣金` 列之后,约 226 行)追加一列:
```tsx
{
title: '明细',
key: 'drill',
width: 64,
fixed: 'right',
render: (_, r) => (
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => openDayDetail(r.date)}>
</Button>
),
},
```
同时,每日明细 `<Table>`(约 304–311 行)列宽合计已升至约 976(日期 96 + 新明细列 64),把其 `scroll={{ x: 900 }}` 改为 `scroll={{ x: 1000 }}`,保证右侧固定列与横向滚动正常:
```tsx
scroll={{ x: 1000 }}
```
- [ ] **Step 5: 加按用户表列定义**
`wxColumns` 定义之后(约 259 行后)追加 `dayUserColumns`
```tsx
const dayUserColumns: ColumnsType<DayUser> = [
{
title: '用户',
key: 'user',
render: (_, u) => (
<Space>
{u.headimgurl ? (
<img
src={u.headimgurl}
alt=""
style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }}
/>
) : (
<span
style={{ width: 28, height: 28, borderRadius: '50%', background: '#f0f0f0', display: 'inline-block' }}
/>
)}
<span>{u.nickname || '(未授权昵称)'}</span>
</Space>
),
},
{
title: '领券次数',
dataIndex: 'copy_count',
width: 100,
sorter: (a, b) => a.copy_count - b.copy_count,
render: (v: number) => (v ? <b style={{ color: '#ff4d4f' }}>{v}</b> : 0),
},
{
title: '点击次数',
dataIndex: 'visit_count',
width: 100,
render: (v: number, u) =>
u.coupons.length ? (
<Tooltip
title={
<div>
{u.coupons.map((c) => (
<div key={c.name}>
{c.name} ×{c.count}
</div>
))}
</div>
}
>
<span style={{ cursor: 'help', textDecoration: 'underline dotted' }}>{v}</span>
</Tooltip>
) : (
v
),
},
];
```
- [ ] **Step 6: 渲染 Modal**
在最外层 `</div>`return 末尾,约 329 行)之前、群内微信用户 `Card` 之后,追加:
```tsx
<Modal
open={dayDetail.open}
title={`群「${data?.group_name ?? ''}${dayDetail.date} · 用户领券明细`}
footer={null}
width={640}
onCancel={() => setDayDetail((s) => ({ ...s, open: false }))}
>
<div style={{ fontSize: 12, color: '#999', marginBottom: 8 }}>
(openid);/,
</div>
<Table<DayUser>
rowKey="openid"
size="small"
loading={dayDetail.loading}
columns={dayUserColumns}
dataSource={dayDetail.users}
pagination={false}
locale={{ emptyText: '当天无授权用户领券/点击' }}
/>
</Modal>
```
- [ ] **Step 7: 构建确认通过**
Run:
```
npm run build
```
Expected: 构建成功,无类型错误。
- [ ] **Step 8: 提交(前端仓)**
```
git add src/app/(main)/cps/groups/[id]/page.tsx
git commit -m "feat(cps): 每日明细下钻弹窗(按用户领券/点击 + 券明细 tooltip)"
```
---
## Task 6: 全栈联调(手动验证)
**前置:** 两仓本地起服务。后端 admin API(8771)+ 前端(3001)。可用前端仓 `bash start.sh`(拉起 8770/8771/3001),默认登录 `admin` / `admin12345`
- [ ] **Step 1: 选一个有授权点击的淘宝群**
后端起来后,直接验证接口(用上面手动登录拿的 admin token,或浏览器已登录的会话):
Run(确认接口连通,期望 200 + JSON 结构):
```
.\.venv\Scripts\pytest.exe tests\test_cps_admin.py -v
```
(接口逻辑已由自动化测试覆盖;本步主要确认前端联调环境。)
- [ ] **Step 2: 前端页面手动核对**
打开 `http://localhost:3001` → CPS 分发 → 进任一群详情 → 每日明细表:
- [ ] 日期列完整显示 `YYYY-MM-DD`,列宽不挤。
- [ ] 每行末尾有「明细」按钮(右侧固定列)。
- [ ] 点「明细」→ 弹窗打开、标题含群名与日期、加载态正常。
- [ ] 有数据的淘宝群:用户行显示头像/昵称、领券次数、点击次数;悬停「点击次数」浮出 `券名 ×次数`,各次数之和 = 点击次数。
- [ ] 昵称为空的用户显示 `(未授权昵称)`
- [ ] 美团/京东群或无授权用户的天:弹窗走空态文案 `当天无授权用户领券/点击`
- [ ] **Step 3: 收尾**
确认前后端各自分支提交完整:
```
git -C e:\project\shaguabijia-app-server log --oneline -3
git log --oneline -5
```
---
## Self-Review(计划自检结论)
- **Spec 覆盖:** `/day-users` 接口(Task 2)、券明细 visit×次数倒序(Task 2 test + 前端 Step 5)、`/daily` date 改全日期(Task 1)、匿名排除(Task 2 实现 + Task 2/3 test)、404/400/401Task 3)、跨年(Task 3)、前端明细列/Modal/Tooltip/日期列加宽(Task 45)、不动数据库(无 migration 任务)—— 均有对应任务。
- **类型一致:** 后端 `group_day_users(db, *, group_id, start, end, limit)` 在 repo 定义、router 按此调用;响应键 `group_id/group_name/date/users[*].{openid,nickname,headimgurl,copy_count,visit_count,coupons[*].{name,count}}` 与前端 `DayUser/DayCoupon` 接口逐字对应。
- **无占位:** 所有步骤含可直接落地的完整代码与确切命令。
@@ -0,0 +1,132 @@
# CPS 群详情 · 每日明细「按天 · 按用户」领券下钻
日期:2026-06-26
状态:已与用户确认设计,待写实现计划
## 背景
`shaguabijia-admin-web`(纯前端)+ 后端 `shaguabijia-app-server`Admin API`/admin/api/*`,端口 8771)。
CPS 群对账详情页 `src/app/(main)/cps/groups/[id]/page.tsx` 现有两张表:
- **每日明细**`GET /admin/api/cps/groups/{id}/daily`):按天一行,含点击/复制/订单佣金。行 `date` 原为 `"MM-DD"`(北京),下钻要完整日期 → **本次改为 `"YYYY-MM-DD"`**(见后端改动)。
- **群内微信用户**`GET /admin/api/cps/groups/{id}/wx-users`):**累计**(非按天)每用户画像,列含 用户 / 领券次数(`copy_count`) / 点击次数(`visit_count`)。
底层数据 `cps_click`(一事件一行):`event_type` = `visit`(进落地页/被跳转) | `copy`(淘宝"复制口令")、`openid`(微信授权才有)、`clicked_at``link_id`。一条点击对应的「优惠券」= `link_id → cps_link.activity_id → cps_activity.name`(活动即优惠券)。`cps_wx_user``openid``nickname/headimgurl`
**既有口径(沿用,不改)**:领券次数 = `copy` 事件数;点击次数 = `visit` 事件数。
## 目标
每日明细表每行末尾加「明细」按钮 → 弹窗展示**该群、该天、以用户为单位**的领券情况:
- 列:用户名、领券次数、点击次数。
- 鼠标悬停「点击次数」→ tooltip 展示该用户当天 **visit 过的全部优惠券**,每行 `券名 ×次数`,按次数倒序,**合计 = 点击次数**。
## 方案:A(一个新接口,券明细内联返回)
下钻数据量小(单群 / 单天 / 有 openid 的用户)。新接口一次返回每个用户的领券数、点击数及其 visit 过的券列表,前端一次请求渲染表格 + tooltip,无悬停二次加载、无闪烁。
(已否决方案 B:用户列表与券明细拆两接口、悬停再拉 —— 多一接口、多一往返、有闪烁,对这数据量不值得。)
## 数据库:无需改动
纯读侧聚合,所需数据已全部在库,**不加表 / 不加字段 / 无 migration / 无需回填**
- `cps_click``group_id`(idx) / `clicked_at`(idx) / `openid`(idx) / `event_type` / `link_id` —— 筛群、筛天、分用户、分领券/点击、定位券所需字段齐全。
- 券归属 `link_id → cps_link.activity_id → cps_activity.name` 在**写入时**就落了(`record_click` 一直写 `link_id`),历史点击可反查,**无需回填**。
- 查询条件 `group_id + clicked_at 区间 + openid IS NOT NULL` 均命中现有单列索引;数据量小、Python 侧聚合,**无需新增复合索引**。
## 后端改动(`app/admin/routers/cps.py` + `app/admin/repositories/cps.py`
### 新增接口 `GET /admin/api/cps/groups/{group_id}/day-users`
- 入参:`date`(必填,`YYYY-MM-DD`)。
- 鉴权:`Depends(get_current_admin)`(只读,登录即可;与 `/daily``/wx-users` 同口径,**不加** `require_role`)。
- 群不存在 → 404`date` 非法 → 400`detail` 说明格式)。
- **时间窗**`date` 按北京时区(UTC+8)解释成 `[当天 00:00, 次日 00:00)` **半开区间**,转 UTC 后查 `clicked_at >= start AND clicked_at < next_day_start`(半开避免午夜双计)。北京日界 → UTC 用既有 `_as_utc` 约定,与 `group_click_timeseries` 分桶口径一致。
- **取数**`cps_click` where `group_id == {group_id}` AND 时间窗内 AND `openid IS NOT NULL`
- **解析券名**:取该群相关 `cps_link`(或按出现的 `link_id` 批量取)建 `link_id → activity_id` 映射,再 `activity_id → cps_activity.name` 映射;Python 侧聚合(与本仓既有风格一致)。活动被硬删 → 券名兜底 `活动#{activity_id}`
- **按 openid 聚合**
- `copy_count` = 该用户 `copy` 事件数(= 领券次数)
- `visit_count` = 该用户 `visit` 事件数(= 点击次数)
- `coupons` = 该用户 **visit 事件**按活动分组的 `{name, count}` 列表,按 `count` 倒序;`copy` 事件不计入券列表(与用户选择"只算 visit"一致)
- 关联 `cps_wx_user``openid IN (...)`)取 `nickname/headimgurl`(未授权为 null)。
- **排序**`copy_count` desc,再 `visit_count` desc(沿用 `group_wx_users` 习惯)。
- 可选 `limit`(默认 200,与 `group_wx_users` 一致)。
**响应**
```json
{
"group_id": 1,
"group_name": "宝妈薅羊毛1群",
"date": "2026-06-25",
"users": [
{
"openid": "oABC...",
"nickname": "张三",
"headimgurl": "https://thirdwx.qlogo.cn/...",
"copy_count": 2,
"visit_count": 5,
"coupons": [
{ "name": "618神券", "count": 3 },
{ "name": "买一送一", "count": 2 }
]
}
]
}
```
实现建议:在 `cps_repo``group_day_users(db, *, group_id, date_start, date_end, limit=200) -> list[dict]`,router 负责日期解析与窗口计算后调用之(与 `group_daily`/`group_wx_users` 的分工一致)。
### 微调既有 `/daily``date` 改全日期(不新增字段)
把每行 `date``"MM-DD"` 改成 **`"YYYY-MM-DD"`**(北京),**不再新增 `date_iso`**。下钻必须知道完整日期(`MM-DD` 丢年份,跨年会错);`date` 直接给全日期,契约更简单、无冗余字段。`group_daily` 已在循环里持有北京日期 `cur`,把 `cur.strftime("%m-%d")` 改成 `cur.strftime("%Y-%m-%d")` 即可。
- 该 endpoint 唯一消费方是本前端(随本次一起改),故**非破坏性**。
- 折线图/时序用的是 `/timeseries``label` 字段(另一套,仍 `MM-DD`),**不受影响**,本次不动。
## 前端改动(`src/app/(main)/cps/groups/[id]/page.tsx`
1. `DailyRow.date` 现为 `"YYYY-MM-DD"`(类型仍 `string`,无需加字段)。日期列 `{ title:'日期', dataIndex:'date' }` 宽度 64 → **调宽到约 96,完整显示 `YYYY-MM-DD`**(跨月/跨年看范围更直观)。表格 `scroll.x` 随新列 + 加宽日期列相应上调(约 980)。
2. 新增类型:
```ts
interface DayCoupon { name: string; count: number; }
interface DayUser {
openid: string; nickname: string | null; headimgurl: string | null;
copy_count: number; visit_count: number; coupons: DayCoupon[];
}
```
3. `dailyColumns` 末尾加**「明细」列**`key:'drill'`, `fixed:'right'`, `width≈64`),render 一个 `type="link"` 小按钮,`onClick` → `openDayDetail(row.date)`(全日期既用于查询也用于弹窗标题)。
4. 弹窗状态:`{ open, date, loading, users }`。`openDayDetail(date)` 置 open+loading`api.get('/admin/api/cps/groups/${id}/day-users?date=' + date)` → 填 `users`;失败 `message.error(errMsg(e, ...))`。
5. 新增 `<Modal>`(声明式组件,不受 antd5 静态方法 context 限制,无需 `App.useApp()`)含 `<Table>`,三列:
- **用户**:头像 + 昵称(空 → `(未授权昵称)`),与既有「群内微信用户」表的用户单元一致(头像直接 `<img src={headimgurl}>`,微信 URL 为绝对地址,非 `/media`)。
- **领券次数**`copy_count`,沿用既有红色加粗样式(0 显示 0)。
- **点击次数**`visit_count`,整格用 `<Tooltip>` 包裹,`title` 渲染 `coupons` 每行 `券名 ×次数``coupons` 为空时不挂 tooltip(直接显示数字)。
6. Modal 标题:`群「{group_name}」{date} · 用户领券明细`。空态文案:`仅统计微信授权(openid)用户;美团/京东多为匿名跳转,可能为空`。
7. antd 引入新增 `Modal, Tooltip`。
## 默认与边界(已确认)
- **匿名点击(openid 为空)不计入**(无法归属到人),镜像既有 `/wx-users`。后果:美团/京东群弹窗常为空 —— 空态文案已提示。
- **数据口径差异(需在代码注释点明,避免误读为 bug)**:每日明细行的 `click_pv/copy_pv` 计**全部**点击(含匿名、UV 按 `(ip,ua)`);本弹窗只计 `openid` 用户。故弹窗各用户求和 **≤** 当天行的点击/复制总数,二者不必相等。
- **谁出现在弹窗**:当天有任意事件(visit 或 copy)的授权用户都列(含领券数为 0 的纯点击用户)。
- **领券次数不做 tooltip**:需求只要点击次数的 tooltip,保持最小。
- **权限**:不加角色门槛(与本页其它只读统计一致)。
## 不做(YAGNI
- 领券次数的「领了哪些券」tooltip。
- 匿名点击的聚合行 / 占位行。
- 用户级下单归因(需 user-level sid,另一期)。
## 验证(本仓无测试框架,手动验证)
- 后端:选一个有 `cps_click` 数据的淘宝群,`curl .../day-users?date=<某天>`,核对:①只含 openid 非空用户;②某用户 `coupons` 的 `count` 之和 = 其 `visit_count`;③`date` 非法返回 400、群不存在返回 404;④跨年日期(如去年 12-31)能取到正确那天。
- 前端:`npm run build` 通过;日期列完整显示 `YYYY-MM-DD`、列宽不挤;明细按钮 → 弹窗加载;悬停点击次数显示券×次数且合计对得上;美团/京东群弹窗走空态文案;昵称为空显示占位。
## 落点文件
- 后端:`app/admin/routers/cps.py`(新 endpoint + `/daily` 的 `date` 改 `YYYY-MM-DD`)、`app/admin/repositories/cps.py`(新 `group_day_users` + `group_daily` 的 `date` 改全日期)。
- 前端:`src/app/(main)/cps/groups/[id]/page.tsx`(类型、日期列加宽、明细列、Modal、Tooltip)。
+874 -61
View File
File diff suppressed because it is too large Load Diff
+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",
@@ -0,0 +1,84 @@
'use client';
// 点收益报表里的用户手机号 → 半屏抽屉展示该用户的广告收益详情(统计卡 + 金币记录)。
// 复用「提现详情」里的 UserRewardPanel(数据走 /users/{id}/reward-stats + /coin-records);
// 用户基本信息(昵称/注册时间/钱包)取自 /users/{id} 概览。抽屉样式(width 50% 半屏)与提现详情一致。
import { useEffect, useState } from 'react';
import { Drawer } from 'antd';
import { api } from '@/lib/api';
import type { WithdrawUserSnapshot } from '@/lib/types';
import UserRewardPanel from '../withdraws/UserRewardPanel';
// GET /admin/api/users/{id} 概览里我们要用到的子集(前端未单独定义 AdminUserOverview 类型)。
interface UserOverviewResp {
user: {
id: number;
phone: string;
nickname: string | null;
status: string;
wechat_nickname: string | null;
created_at: string;
last_login_at: string;
};
cash_balance_cents: number;
withdraw_total: number;
withdraw_success_cents: number;
}
interface Props {
open: boolean;
onClose: () => void;
userId: number | null;
phone: string | null; // 报表行已有的手机号:概览拉取失败时兜底展示
}
export default function UserAdRevenueDrawer({ open, onClose, userId, phone }: Props) {
// UserRewardPanel 的 user 快照:它只读 phone/nickname/wechat_nickname/created_at,其余按 snapshot 形状补齐。
const [user, setUser] = useState<WithdrawUserSnapshot | null>(null);
useEffect(() => {
if (!open || userId == null) return;
let alive = true;
setUser(null);
api
.get<UserOverviewResp>(`/admin/api/users/${userId}`)
.then((r) => {
if (!alive) return;
const o = r.data;
setUser({
id: o.user.id,
phone: o.user.phone,
nickname: o.user.nickname,
status: o.user.status,
wechat_nickname: o.user.wechat_nickname, // 概览接口已返回微信昵称
wechat_avatar_url: null,
created_at: o.user.created_at,
last_login_at: o.user.last_login_at,
cash_balance_cents: o.cash_balance_cents,
withdraw_total: o.withdraw_total,
withdraw_success_cents: o.withdraw_success_cents,
});
})
.catch(() => {
// 概览失败:退回只带手机号,昵称/注册天数显示「-」,不阻塞统计/金币记录(它们由 userId 独立拉)
if (alive) setUser(phone ? ({ phone } as WithdrawUserSnapshot) : null);
});
return () => {
alive = false;
};
}, [open, userId, phone]);
return (
<Drawer
title={`用户广告收益详情${phone ? ` · ${phone}` : ''}`}
width="50%"
open={open}
onClose={onClose}
destroyOnHidden
>
{/* userId 就绪即渲染;UserRewardPanel 内部按 userId 自行拉统计与金币记录,user 基本信息随后补上。
statsVariant="ad":统计区只显示 6 项看广告统计(累计提现/现金余额/激励视频观看数+eCPM/draw观看数+eCPM) */}
{userId != null && <UserRewardPanel userId={userId} user={user} statsVariant="ad" />}
</Drawer>
);
}
File diff suppressed because it is too large Load Diff
+102
View File
@@ -0,0 +1,102 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { Alert, App, Button, Card, Form, Input, Switch } from 'antd';
import { api, errMsg } from '@/lib/api';
// 广告配置面板:穿山甲 app_id / 各场景广告位ID / 验签密钥 / 各场景开关。
// 客户端(发版后)从 /api/v1/platform/ad-config 拉取;此处经 admin /ad-config 读写。
// 收益报表在独立的「广告收益」页(/ad-revenue-report)。
interface AdCfg {
app_id: string;
reward_code_id: string;
compare_draw_code_id: string;
coupon_draw_code_id: string;
reward_mkey: string;
reward_enabled: boolean;
compare_ad_enabled: boolean;
coupon_ad_enabled: boolean;
withdrawal_ad_enabled: boolean;
}
export default function AdConfigPage() {
const { message } = App.useApp();
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
try {
const r = await api.get<AdCfg>('/admin/api/ad-config');
form.setFieldsValue(r.data);
} catch (e) {
message.error(errMsg(e, '广告配置加载失败'));
}
}, [form, message]);
useEffect(() => {
load();
}, [load]);
const save = async () => {
const v = await form.validateFields();
setSaving(true);
try {
await api.patch('/admin/api/ad-config', v);
message.success('已保存。客户端下次拉取生效(应用ID需冷启,广告位ID即时)');
load();
} catch (e) {
message.error(errMsg(e, '保存失败'));
} finally {
setSaving(false);
}
};
return (
<Card
title="广告配置(穿山甲)"
extra={
<Button type="primary" loading={saving} onClick={save}>
</Button>
}
>
<Alert
type="info"
style={{ marginBottom: 16 }}
message="应用ID改了客户端需冷启生效,广告位ID即时;换激励位必须同步换验签密钥(m-key),否则发不出金币。这些配置需客户端发版接入后才会读取。"
/>
<Form form={form} layout="vertical">
<Form.Item name="app_id" label="穿山甲应用ID (APP_ID)" rules={[{ required: true }]}>
<Input placeholder="如 5830519" />
</Form.Item>
<Form.Item name="reward_code_id" label="福利页 · 激励视频 广告位ID" rules={[{ required: true }]}>
<Input placeholder="如 104099389" />
</Form.Item>
<Form.Item
name="reward_mkey"
label="福利激励 · 验签密钥 m-key(机密,不下发客户端;换激励位必须同步换;留空回退 .env)"
>
<Input.Password placeholder="GroMore S2S 回调验签密钥" autoComplete="off" />
</Form.Item>
<Form.Item name="compare_draw_code_id" label="比价Draw信息流代码位" rules={[{ required: true }]}>
<Input placeholder="比价Draw信息流代码位,如 104098712" />
</Form.Item>
<Form.Item name="coupon_draw_code_id" label="领券Draw信息流代码位" rules={[{ required: true }]}>
<Input placeholder="领券Draw信息流代码位,如 104098712" />
</Form.Item>
<Form.Item name="reward_enabled" label="福利激励视频开关" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="compare_ad_enabled" label="比价广告开关" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="coupon_ad_enabled" label="领券广告开关" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item
name="withdrawal_ad_enabled"
label="提现激励视频开关(关 = 客户端直接放行提现,不看视频)"
valuePropName="checked"
>
<Switch />
</Form.Item>
</Form>
</Card>
);
}
+416 -90
View File
@@ -1,78 +1,290 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import { Button, Form, Input, Modal, Select, Table, Tag, message } from 'antd';
import {
App, Button, Card, Checkbox, Input, Select, Space, Table, Tag,
} from 'antd';
import { api, errMsg } from '@/lib/api';
import type { AdminInfo } from '@/lib/types';
import { formatUtcTime } from '@/lib/format';
import { getAdmin } from '@/lib/auth';
import type { AdminInfo, AdminRole, PermissionGroup } from '@/lib/types';
const ROLES = [
{ value: 'super_admin', label: 'super_admin' },
{ value: 'finance', label: 'finance' },
{ value: 'operator', label: 'operator' },
];
const dt = (v: string | null) => (v ? new Date(v).toLocaleString('zh-CN') : '从未');
const dt = (v: string | null) => (v ? formatUtcTime(v, 'YYYY-MM-DD HH:mm:ss') : '从未');
// 随机初始密码(排除易混字符 0O1lI),客户端生成、转交本人,后端只存 hash
function genPassword(len = 10): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789';
let s = '';
for (let i = 0; i < len; i += 1) s += chars[Math.floor(Math.random() * chars.length)];
return s;
}
type View = 'list' | 'person' | 'roles' | 'roleForm';
// 「自定义」哨兵角色:不是共享角色,选它时可见页由本人逐页勾选(存 pages_override),与后端一致。
const CUSTOM_ROLE = 'custom';
const CUSTOM_LABEL = '自定义';
// ===== 只读权限矩阵:角色详情 / 人员「可见页面」预览共用(深色=有,灰色=无)=====
function PermMatrix({ catalog, pages }: { catalog: PermissionGroup[]; pages: string[] }) {
const has = useMemo(() => new Set(pages), [pages]);
return (
<div style={{ border: '1px solid #f0f0f0', borderRadius: 8, overflow: 'hidden' }}>
{catalog.map((g) => (
<div key={g.group} style={{ display: 'flex', borderBottom: '1px solid #f0f2f5' }}>
<div style={{ width: 150, flexShrink: 0, padding: '14px', fontWeight: 500, color: 'rgba(0,0,0,.85)' }}>
{g.group}
</div>
<div style={{ flex: 1, padding: '12px 16px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px 28px' }}>
{g.pages.map((p) => (
// 深色=可见,灰色(--ink-3)=不可见
<span key={p.key} style={{ fontSize: 13.5, color: has.has(p.key) ? 'rgba(0,0,0,.85)' : '#94a0b1' }}>
{p.label}
</span>
))}
</div>
</div>
))}
</div>
);
}
// ===== 可编辑权限矩阵:角色表单用(勾选框 + 分类全选/半选)=====
function PermEditor({
catalog, value, onChange,
}: { catalog: PermissionGroup[]; value: string[]; onChange: (next: string[]) => void }) {
const has = useMemo(() => new Set(value), [value]);
const setPages = (s: Set<string>) => onChange(Array.from(s));
const togglePage = (key: string) => {
const n = new Set(has);
if (n.has(key)) n.delete(key); else n.add(key);
setPages(n);
};
const toggleGroup = (g: PermissionGroup, on: boolean) => {
const n = new Set(has);
g.pages.forEach((p) => (on ? n.add(p.key) : n.delete(p.key)));
setPages(n);
};
return (
<div style={{ border: '1px solid #f0f0f0', borderRadius: 8, overflow: 'hidden' }}>
{catalog.map((g) => {
const cnt = g.pages.filter((p) => has.has(p.key)).length;
const all = cnt === g.pages.length && cnt > 0;
return (
<div key={g.group} style={{ display: 'flex', borderBottom: '1px solid #fafafa' }}>
<div style={{ width: 140, flexShrink: 0, padding: '14px', background: '#fafbfd' }}>
<Checkbox
checked={all}
indeterminate={cnt > 0 && !all}
onChange={(e) => toggleGroup(g, e.target.checked)}
>
{g.group}
</Checkbox>
</div>
<div style={{ flex: 1, padding: '12px 16px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px 24px' }}>
{g.pages.map((p) => (
<Checkbox key={p.key} checked={has.has(p.key)} onChange={() => togglePage(p.key)}>
{p.label}
</Checkbox>
))}
</div>
</div>
);
})}
</div>
);
}
export default function AdminsPage() {
const [admins, setAdmins] = useState<AdminInfo[]>([]);
const [loading, setLoading] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [form] = Form.useForm();
const { message, modal } = App.useApp();
const me = getAdmin();
const isSuper = me?.role === 'super_admin';
const load = async () => {
const [view, setView] = useState<View>('list');
const [admins, setAdmins] = useState<AdminInfo[]>([]);
const [roles, setRoles] = useState<AdminRole[]>([]);
const [catalog, setCatalog] = useState<PermissionGroup[]>([]);
const [loading, setLoading] = useState(false);
const roleByName = useMemo(() => {
const m: Record<string, AdminRole> = {};
roles.forEach((r) => { m[r.name] = r; });
return m;
}, [roles]);
const roleOptions = roles.map((r) => ({ value: r.name, label: r.label }));
// 人员表单角色下拉:真实角色 + 末尾「自定义」。选「自定义」→ 下方可见页改为逐页勾选。
const personRoleOptions = [...roleOptions, { value: CUSTOM_ROLE, label: CUSTOM_LABEL }];
// 角色 key → 展示名(custom 显示「自定义」;查不到回退 key 原文)
const roleLabelOf = (name: string) =>
(name === CUSTOM_ROLE ? CUSTOM_LABEL : roleByName[name]?.label ?? name);
const loadAll = async () => {
setLoading(true);
try {
const { data } = await api.get<AdminInfo[]>('/admin/api/admins');
setAdmins(data);
const [a, r, c] = await Promise.all([
api.get<AdminInfo[]>('/admin/api/admins'),
api.get<AdminRole[]>('/admin/api/roles'),
api.get<PermissionGroup[]>('/admin/api/roles/catalog'),
]);
setAdmins(a.data);
setRoles(r.data);
setCatalog(c.data);
} catch (e) {
message.error(errMsg(e));
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
useEffect(() => { loadAll(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
const create = async () => {
const v = await form.validateFields();
// ===== 人员表单(新增 / 编辑)状态 =====
const [personEditing, setPersonEditing] = useState<AdminInfo | null>(null);
const [personName, setPersonName] = useState('');
const [personRole, setPersonRole] = useState('');
const [initPw, setInitPw] = useState('');
// 「自定义」角色时逐页勾选的可见页集(role==custom 才用到)
const [personPages, setPersonPages] = useState<string[]>([]);
const openCreatePerson = () => {
setPersonEditing(null);
setPersonName('');
setPersonRole(roles.find((r) => !r.is_builtin)?.name ?? roles[0]?.name ?? 'operator');
setPersonPages([]);
setInitPw(genPassword());
setView('person');
};
const openEditPerson = (a: AdminInfo) => {
setPersonEditing(a);
setPersonName(a.username);
setPersonRole(a.role);
// 编辑「自定义」用户时,回填其已勾选的可见页(供继续增删)
setPersonPages(a.role === CUSTOM_ROLE ? (a.pages_override ?? []) : []);
setInitPw(''); // 编辑态密码只读展示 a.password(已确定的登录密码),不用 initPw
setView('person');
};
const copyText = (v: string) => {
navigator.clipboard?.writeText(v);
message.success('已复制到剪贴板');
};
// 密码展示框(对齐原型 .pw-show:虚线灰框 + 大号等宽 + 复制)
const pwBox = (value: string) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, background: '#f4f6fa', border: '1px dashed #cbd5e1', borderRadius: 8, padding: '12px 14px' }}>
<code style={{ flex: 1, fontFamily: '"DIN Alternate", "SF Mono", Consolas, monospace', fontSize: 22, fontWeight: 700, letterSpacing: 1, color: 'rgba(0,0,0,.85)' }}>{value}</code>
<Button onClick={() => copyText(value)}></Button>
</div>
);
const isCustom = personRole === CUSTOM_ROLE;
const submitPerson = async () => {
if (isCustom && personPages.length === 0) { message.error('自定义角色请至少勾选一个可见页面'); return; }
try {
await api.post('/admin/api/admins', v);
message.success('已创建');
setCreateOpen(false);
form.resetFields();
load();
if (personEditing) {
// 密码只读、不改;提交变更的角色 +(自定义时)勾选的可见页
const roleChanged = personRole !== personEditing.role;
const pagesChanged = isCustom
&& JSON.stringify(personPages) !== JSON.stringify(personEditing.pages_override ?? []);
if (roleChanged || pagesChanged) {
const payload: Record<string, unknown> = {};
if (roleChanged) payload.role = personRole;
// 自定义:带上勾选集(切到 custom 或改现有 custom 勾选都要);切回普通角色不带,后端会清空 override
if (isCustom) payload.pages_override = personPages;
await api.patch(`/admin/api/admins/${personEditing.id}`, payload);
}
message.success('已保存');
} else {
if (personName.trim().length < 3) { message.error('用户名至少 3 位'); return; }
await api.post('/admin/api/admins', {
username: personName.trim(),
password: initPw,
role: personRole,
...(isCustom ? { pages_override: personPages } : {}),
});
message.success('已创建,请把初始密码转交本人');
}
setView('list');
loadAll();
} catch (e) {
message.error(errMsg(e));
}
};
// ===== 列表操作 =====
const changeRole = (a: AdminInfo, role: string) => {
if (role === a.role) return;
Modal.confirm({
title: `${a.username} 的角色改为 ${role}?`,
// 改成「自定义」需要逐页勾选 → 转去编辑页(列表里没有页选择器,直接切会得到空可见页)
if (role === CUSTOM_ROLE) { openEditPerson({ ...a, role: CUSTOM_ROLE }); return; }
modal.confirm({
title: `${a.username} 的角色改为 ${roleLabelOf(role)}?`,
onOk: async () => {
try {
await api.patch(`/admin/api/admins/${a.id}`, { role });
message.success('已更新');
load();
} catch (e) {
message.error(errMsg(e));
}
try { await api.patch(`/admin/api/admins/${a.id}`, { role }); message.success('已更新'); loadAll(); }
catch (e) { message.error(errMsg(e)); }
},
});
};
const toggleStatus = (a: AdminInfo) => {
const next = a.status === 'active' ? 'disabled' : 'active';
modal.confirm({
title: `${next === 'disabled' ? '禁用' : '启用'}管理员 ${a.username}?`,
onOk: async () => {
try { await api.patch(`/admin/api/admins/${a.id}`, { status: next }); message.success('已更新'); loadAll(); }
catch (e) { message.error(errMsg(e)); }
},
});
};
const deletePerson = (a: AdminInfo) => {
modal.confirm({
title: '确认删除',
content: `确定删除管理员「${a.username}」吗?删除后该账号将无法登录,不可恢复。`,
okText: '确认删除', okButtonProps: { danger: true },
onOk: async () => {
try { await api.delete(`/admin/api/admins/${a.id}`); message.success('已删除'); loadAll(); }
catch (e) { message.error(errMsg(e)); }
},
});
};
const toggle = (a: AdminInfo) => {
const next = a.status === 'active' ? 'disabled' : 'active';
Modal.confirm({
title: `${next === 'disabled' ? '禁用' : '启用'}管理员 ${a.username}?`,
// ===== 角色设置状态 =====
const [selRoleId, setSelRoleId] = useState<number | null>(null);
const selRole = roles.find((r) => r.id === selRoleId) ?? roles[0] ?? null;
const openRoles = () => {
setSelRoleId((prev) => prev ?? roles[0]?.id ?? null);
setView('roles');
};
// 角色表单(新增 / 编辑)
const [roleEditing, setRoleEditing] = useState<AdminRole | null>(null);
const [roleName, setRoleName] = useState('');
const [rolePages, setRolePages] = useState<string[]>([]);
const openCreateRole = () => {
setRoleEditing(null); setRoleName(''); setRolePages([]); setView('roleForm');
};
const openEditRole = (r: AdminRole) => {
setRoleEditing(r); setRoleName(r.label); setRolePages(r.pages); setView('roleForm');
};
const submitRole = async () => {
if (!roleName.trim()) { message.error('请输入角色名称'); return; }
try {
if (roleEditing) {
// 只改展示名 + 可见页(key 不可变)
await api.patch(`/admin/api/roles/${roleEditing.id}`, { label: roleName.trim(), pages: rolePages });
message.success('角色已保存');
} else {
await api.post('/admin/api/roles', { name: roleName.trim(), pages: rolePages });
message.success('角色已创建');
}
await loadAll();
setView('roles');
} catch (e) {
message.error(errMsg(e));
}
};
const deleteRole = (r: AdminRole) => {
modal.confirm({
title: '确认删除角色',
content: `确定删除角色「${r.label}」吗?${r.in_use > 0 ? `当前有 ${r.in_use} 名成员在用,需先改派。` : '此操作不可恢复。'}`,
okText: '确认删除', okButtonProps: { danger: true },
onOk: async () => {
try {
await api.patch(`/admin/api/admins/${a.id}`, { status: next });
message.success('已更新');
load();
} catch (e) {
message.error(errMsg(e));
}
try { await api.delete(`/admin/api/roles/${r.id}`); message.success('已删除角色'); await loadAll(); setSelRoleId(null); }
catch (e) { message.error(errMsg(e)); }
},
});
};
@@ -81,60 +293,174 @@ export default function AdminsPage() {
{ title: 'ID', dataIndex: 'id', width: 70 },
{ title: '用户名', dataIndex: 'username' },
{
title: '角色',
dataIndex: 'role',
title: '角色', dataIndex: 'role', width: 170,
// options 含「自定义」→ custom 用户在列表里显示为「自定义」;选它会转去编辑页勾选(见 changeRole)
render: (r: string, a: AdminInfo) => (
<Select
size="small"
value={r}
style={{ width: 140 }}
options={ROLES}
onChange={(v) => changeRole(a, v)}
/>
<Select size="small" value={r} style={{ width: 150 }} options={personRoleOptions} onChange={(v) => changeRole(a, v)} />
),
},
{ title: '状态', dataIndex: 'status', width: 90, render: (s: string) => <Tag color={s === 'active' ? 'green' : 'default'}>{s === 'active' ? 'active' : '已禁用'}</Tag> },
{ title: '最后登录', dataIndex: 'last_login_at', width: 180, render: dt },
{
title: '状态',
dataIndex: 'status',
render: (s: string) => <Tag color={s === 'active' ? 'green' : 'red'}>{s}</Tag>,
},
{ title: '最后登录', dataIndex: 'last_login_at', render: dt },
{
title: '操作',
key: 'op',
title: '操作', key: 'op', width: 200,
render: (_: unknown, a: AdminInfo) => (
<a onClick={() => toggle(a)}>{a.status === 'active' ? '禁用' : '启用'}</a>
<Space size="middle">
<a onClick={() => openEditPerson(a)}></a>
<a onClick={() => toggleStatus(a)}>{a.status === 'active' ? '禁用' : '启用'}</a>
<a style={{ color: '#cf1322' }} onClick={() => deletePerson(a)}></a>
</Space>
),
},
];
// ============ 视图 1:管理员账号列表 ============
if (view === 'list') {
return (
<div>
<h2></h2>
<Space style={{ marginBottom: 16 }}>
<Button type="primary" onClick={openCreatePerson}>+ </Button>
<Button onClick={openRoles}></Button>
</Space>
<Table rowKey="id" columns={columns} dataSource={admins} loading={loading} pagination={false} />
</div>
);
}
// ============ 视图 2:新增 / 编辑人员 ============
if (view === 'person') {
const rolePagesPreview = roleByName[personRole]?.pages ?? [];
return (
<div>
<Space style={{ marginBottom: 16 }}><a onClick={() => setView('list')}> </a></Space>
<Card style={{ maxWidth: 720, margin: '0 auto' }}>
<h2 style={{ fontSize: 19, fontWeight: 600, marginBottom: 22 }}>{personEditing ? '编辑人员' : '新增人员'}</h2>
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 8 }}><span style={{ color: '#cf1322' }}> *</span></div>
<Input value={personName} disabled={!!personEditing} onChange={(e) => setPersonName(e.target.value)} placeholder="至少 3 位" />
{personEditing && <div style={{ color: '#999', fontSize: 12, marginTop: 6 }}></div>}
</div>
{!personEditing ? (
// 新增:系统生成初始密码(可换)
<div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span></span>
<a onClick={() => setInitPw(genPassword())}> </a>
</div>
{pwBox(initPw)}
</div>
) : personEditing.password ? (
// 编辑:显示该成员已确定的登录密码(只读、不可换)
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 8 }}></div>
{pwBox(personEditing.password)}
<div style={{ color: '#999', fontSize: 12, marginTop: 6 }}>,</div>
</div>
) : (
// 编辑:无留存明文(脚本/起后台建的超管)→ 不显示密码
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 8 }}></div>
<div style={{ color: '#999', fontSize: 13, padding: '6px 0' }}>,</div>
</div>
)}
<div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span><span style={{ color: '#cf1322' }}> *</span></span>
{isSuper && <a onClick={openRoles}> </a>}
</div>
<Select value={personRole} options={personRoleOptions} onChange={setPersonRole} style={{ width: '100%' }} />
<div style={{ color: '#999', fontSize: 12, marginTop: 6 }}>
{isCustom
? '「自定义」:下方逐页勾选该成员可见的页面,仅对本人生效(不影响任何角色)'
: '可见页面完全由所选角色决定;如需调整请到「角色设置」修改该角色'}
</div>
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span></span>
<span style={{ color: '#999', fontSize: 12 }}>
{isCustom ? '勾选本成员可见的页面' : '跟随所选角色,此处只读(深色=可见)'}
</span>
</div>
{isCustom ? (
<PermEditor catalog={catalog} value={personPages} onChange={setPersonPages} />
) : (
<PermMatrix catalog={catalog} pages={personRole === 'super_admin' ? catalog.flatMap((g) => g.pages.map((p) => p.key)) : rolePagesPreview} />
)}
</div>
<div style={{ marginTop: 24, textAlign: 'right' }}>
<Space><Button onClick={() => setView('list')}></Button><Button type="primary" onClick={submitPerson}></Button></Space>
</div>
</Card>
</div>
);
}
// ============ 视图 3:角色设置(角色列表 + 只读权限详情)============
if (view === 'roles') {
return (
<div>
<Space style={{ marginBottom: 16 }}><a onClick={() => setView('list')}> </a><b></b></Space>
<div>
<Button type="primary" style={{ marginBottom: 16 }} onClick={openCreateRole}>+ </Button>
<div style={{ display: 'flex', background: '#fff', border: '1px solid #e8ebf1', borderRadius: 10, boxShadow: '0 1px 3px rgba(16,24,40,.06)', overflow: 'hidden' }}>
<div style={{ width: 176, flexShrink: 0, borderRight: '1px solid #eff1f6', padding: 8 }}>
{roles.map((r) => (
<div
key={r.id}
onClick={() => setSelRoleId(r.id)}
style={{
padding: '10px 14px', borderRadius: 8, cursor: 'pointer', marginBottom: 2,
background: selRole?.id === r.id ? '#e6f0ff' : undefined,
color: selRole?.id === r.id ? '#1677ff' : undefined,
fontWeight: selRole?.id === r.id ? 600 : undefined,
}}
>
{r.label}{r.is_builtin && <Tag style={{ marginLeft: 6 }}></Tag>}
</div>
))}
</div>
<div style={{ flex: 1, minWidth: 0, padding: '18px 22px' }}>
{selRole && (
<>
<Space style={{ marginBottom: 16 }} size="middle">
<b style={{ fontSize: 16 }}>{selRole.label}</b>
{!selRole.is_builtin && <a onClick={() => openEditRole(selRole)}></a>}
{!selRole.is_builtin && <a style={{ color: '#cf1322' }} onClick={() => deleteRole(selRole)}></a>}
{selRole.in_use > 0 && <span style={{ color: '#999', fontSize: 12 }}>{selRole.in_use} </span>}
</Space>
<PermMatrix catalog={catalog} pages={selRole.pages} />
</>
)}
</div>
</div>
</div>
</div>
);
}
// ============ 视图 4:新增 / 编辑角色权限 ============
return (
<div>
<h2></h2>
<Button type="primary" style={{ marginBottom: 16 }} onClick={() => setCreateOpen(true)}>
</Button>
<Table rowKey="id" columns={columns} dataSource={admins} loading={loading} pagination={false} />
<Modal
title="新建管理员"
open={createOpen}
onOk={create}
onCancel={() => setCreateOpen(false)}
destroyOnClose
>
<Form form={form} layout="vertical" initialValues={{ role: 'operator' }}>
<Form.Item name="username" label="用户名" rules={[{ required: true, min: 3 }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="密码(≥8 位)" rules={[{ required: true, min: 8 }]}>
<Input.Password />
</Form.Item>
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
<Select options={ROLES} />
</Form.Item>
</Form>
</Modal>
<Space style={{ marginBottom: 16 }}><a onClick={() => setView('roles')}> </a></Space>
<Card style={{ maxWidth: 920, margin: '0 auto' }}>
<h2 style={{ fontSize: 19, fontWeight: 600, marginBottom: 22 }}>{roleEditing ? '编辑角色权限' : '新增角色权限'}</h2>
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 8 }}><span style={{ color: '#cf1322' }}> *</span></div>
<Input value={roleName} onChange={(e) => setRoleName(e.target.value)} placeholder="如:运营专员 / 财务 / 审核" maxLength={32} />
</div>
<div>
<div style={{ marginBottom: 8 }}>()<span style={{ color: '#cf1322' }}> *</span></div>
<PermEditor catalog={catalog} value={rolePages} onChange={setRolePages} />
</div>
<div style={{ marginTop: 24, textAlign: 'right' }}>
<Space><Button onClick={() => setView('roles')}></Button><Button type="primary" onClick={submitRole}></Button></Space>
</div>
</Card>
</div>
);
}
+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>
);
}
+455
View File
@@ -0,0 +1,455 @@
'use client';
import { useState } from 'react';
import type { CSSProperties, ReactNode } 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 成本估算:(input + output) token / 1e6 × 每百万 token 单价(元)。单价由页面输入框配置(localStorage)。
const calcCost = (inTok: number | null, outTok: number | null, price: number | null): number | null => {
if (price == null || Number.isNaN(price)) return null;
if (inTok == null && outTok == null) return null;
return (((inTok ?? 0) + (outTok ?? 0)) / 1_000_000) * price;
};
const fmtCost = (c: number | null) => (c == null ? '-' : `¥${c.toFixed(4)}`);
const fmtTok = (inTok: number | null, outTok: number | null) =>
inTok == null && outTok == null ? '-' : `${inTok ?? 0}/${outTok ?? 0}`;
// 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;
}
}
// 搜索命中高亮:把 text 里匹配 keyword 的子串套黄底荧光,方便审计快速定位。
// 大小写不敏感;用 indexOf 逐段切(非正则,keyword 含特殊字符也安全)。keyword 取「已应用」
// 的搜索词(applied.store / applied.product),不是输入框实时值——只高亮真正搜过的词。
function highlightMatch(text: string | null, keyword: unknown): ReactNode {
if (!text) return '-';
const kw = (typeof keyword === 'string' ? keyword : '').trim();
if (!kw) return text;
const parts: ReactNode[] = [];
const hay = text.toLowerCase();
const needle = kw.toLowerCase();
let from = 0;
let hit = hay.indexOf(needle);
let k = 0;
while (hit !== -1) {
if (hit > from) parts.push(text.slice(from, hit));
parts.push(
<mark key={k++} style={{ background: '#fff566', padding: 0 }}>
{text.slice(hit, hit + kw.length)}
</mark>,
);
from = hit + kw.length;
hit = hay.indexOf(needle, from);
}
if (from < text.length) parts.push(text.slice(from));
return parts;
}
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 [store, setStore] = useState('');
const [product, setProduct] = useState('');
const [applied, setApplied] = useState<Record<string, unknown>>({});
// LLM 单价(元/百万 token),本地持久化;仅用于前端估算成本,不入库、不影响其它页面
const [pricePerMTok, setPricePerMTok] = useState<number | null>(() => {
if (typeof window === 'undefined') return null;
const v = localStorage.getItem('llm_price_per_mtok');
return v != null && v !== '' ? Number(v) : null;
});
const onPriceChange = (v: number | null) => {
setPricePerMTok(v);
if (typeof window === 'undefined') return;
if (v == null) localStorage.removeItem('llm_price_per_mtok');
else localStorage.setItem('llm_price_per_mtok', String(v));
};
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,
store: store.trim() || undefined,
product: product.trim() || undefined,
});
const reset = () => {
setUserId(null);
setPhone('');
setStatus(undefined);
setStore('');
setProduct('');
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: 170,
// 审计要一眼看全:不截断、整格自动换行(去 ellipsis),而非靠 hover tooltip
onCell: () => ({ style: { whiteSpace: 'normal', wordBreak: 'break-word', verticalAlign: 'top' } }),
render: (v: string | null) => highlightMatch(v, applied.store),
},
{
title: '商品',
dataIndex: 'product_names',
width: 280,
onCell: () => ({ style: { whiteSpace: 'normal', wordBreak: 'break-word', verticalAlign: 'top' } }),
render: (v: string | null) => highlightMatch(v, applied.product),
},
{ 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: 'Token(入/出)',
key: 'tokens',
width: 110,
render: (_, r) => fmtTok(r.input_tokens, r.output_tokens),
},
{
title: '成本',
key: 'cost',
width: 90,
render: (_, r) => fmtCost(calcCost(r.input_tokens, r.output_tokens, pricePerMTok)),
},
{
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 }}
/>
<Input
placeholder="搜索店名"
value={store}
onChange={(e) => setStore(e.target.value)}
onPressEnter={search}
allowClear
style={{ width: 150 }}
/>
<Input
placeholder="搜索商品"
value={product}
onChange={(e) => setProduct(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>
<InputNumber
placeholder="LLM 单价"
value={pricePerMTok}
onChange={onPriceChange}
min={0}
step={0.1}
style={{ width: 210 }}
addonAfter="元/百万token"
/>
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={items}
loading={loading}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange,
}}
scroll={{ x: 1820 }}
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="Token(入/出/合计)">
{detail.input_tokens == null && detail.output_tokens == null
? '-'
: `${detail.input_tokens ?? 0} / ${detail.output_tokens ?? 0} / ${(detail.input_tokens ?? 0) + (detail.output_tokens ?? 0)}`}
</Descriptions.Item>
<Descriptions.Item label="估算成本">
{fmtCost(calcCost(detail.input_tokens, detail.output_tokens, pricePerMTok))}
{pricePerMTok == null && (detail.input_tokens != null || detail.output_tokens != null)
? <span style={{ color: '#999', fontSize: 12 }}> LLM </span>
: null}
</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>
);
}
+273
View File
@@ -0,0 +1,273 @@
'use client';
import { useEffect, useState } from 'react';
import { Button, Card, Image, Input, Space, Spin, Switch, Tag, Typography, Upload, message } from 'antd';
import { DeleteOutlined, UploadOutlined } from '@ant-design/icons';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { mediaUrl } from '@/lib/media';
import type { FeedbackQrConfig as QrConfig } from '@/lib/types';
const { Text } = Typography;
const ACCEPT = ['image/jpeg', 'image/png', 'image/webp'];
/** 反馈页「加群二维码」卡配置。改完 App 意见反馈页下次进入即同步。「系统配置 → 反馈二维码」tab 的一个区块。 */
export default function FeedbackQrConfig() {
const [cfg, setCfg] = useState<QrConfig | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
// 本地编辑态(文案 + 开关),保存时一次性 PATCH
const [enabled, setEnabled] = useState(true);
const [title, setTitle] = useState('');
const [groupName, setGroupName] = useState('');
const [subtitle, setSubtitle] = useState('');
const canEdit = canDo(['operator']);
const sync = (c: QrConfig) => {
setCfg(c);
setEnabled(c.enabled);
setTitle(c.title);
setGroupName(c.group_name);
setSubtitle(c.subtitle);
};
const load = async () => {
setLoading(true);
try {
const { data } = await api.get<QrConfig>('/admin/api/feedback-config');
sync(data);
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const save = async () => {
if (!title.trim() || !groupName.trim() || !subtitle.trim()) {
message.warning('三行文案都需填写');
return;
}
setSaving(true);
try {
const { data } = await api.patch<QrConfig>('/admin/api/feedback-config', {
enabled,
title: title.trim(),
group_name: groupName.trim(),
subtitle: subtitle.trim(),
});
sync(data);
message.success('已保存,App 反馈页下次进入即可见');
} catch (e) {
message.error(errMsg(e));
} finally {
setSaving(false);
}
};
// antd Upload:beforeUpload 里自行 POST(multipart),返回 false 阻止其默认上传。
const beforeUpload = (file: File) => {
if (!ACCEPT.includes(file.type)) {
message.error('仅支持 JPEG / PNG / WebP 图片');
return Upload.LIST_IGNORE;
}
if (file.size > 5 * 1024 * 1024) {
message.error('图片不能超过 5MB');
return Upload.LIST_IGNORE;
}
void uploadImage(file);
return false;
};
const uploadImage = async (file: File) => {
const form = new FormData();
form.append('file', file);
setUploading(true);
try {
const { data } = await api.post<QrConfig>('/admin/api/feedback-config/image', form);
sync(data);
message.success('二维码已更新,App 反馈页下次进入即可见');
} catch (e) {
message.error(errMsg(e));
} finally {
setUploading(false);
}
};
const removeImage = async () => {
setUploading(true);
try {
const { data } = await api.delete<QrConfig>('/admin/api/feedback-config/image');
sync(data);
message.success('已移除二维码,App 将显示本地兜底图');
} catch (e) {
message.error(errMsg(e));
} finally {
setUploading(false);
}
};
return (
<Card
size="small"
title="反馈页二维码卡(App 意见反馈页底部)"
style={{ marginBottom: 16 }}
extra={
cfg?.updated_at ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{new Date(cfg.updated_at).toLocaleString('zh-CN')}
</Text>
) : null
}
>
<p style={{ color: '#999', marginTop: 0 }}>
App
</p>
{loading || !cfg ? (
<Spin style={{ display: 'block', margin: '24px 0' }} />
) : (
<Space align="start" size={32} wrap>
{/* 左:所见即所得预览(对齐 App 卡片布局) */}
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
App
</Text>
<div
style={{
marginTop: 8,
width: 320,
display: 'flex',
gap: 14,
alignItems: 'center',
padding: 14,
border: '1px solid #f0f0f0',
borderRadius: 14,
boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
opacity: enabled ? 1 : 0.4,
}}
>
{cfg.image_url ? (
<Image
src={mediaUrl(cfg.image_url)}
width={88}
height={88}
style={{ objectFit: 'cover', borderRadius: 8, border: '1px solid #f0f0f0' }}
/>
) : (
<div
style={{
width: 88,
height: 88,
borderRadius: 8,
border: '1px dashed #d9d9d9',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#bbb',
fontSize: 12,
textAlign: 'center',
flexShrink: 0,
}}
>
</div>
)}
<div style={{ fontSize: 13, color: '#1a1a1a', lineHeight: '21px' }}>
<div>{title || '(标题)'}</div>
<div>
<b>{groupName || '群名'}</b>
</div>
<div>{subtitle || '(标语)'}</div>
</div>
</div>
{!enabled && (
<div style={{ color: '#fa8c16', fontSize: 12, marginTop: 6 }}>
App
</div>
)}
</div>
{/* 右:编辑控件 */}
<Space direction="vertical" size="middle" style={{ minWidth: 320 }}>
<Space>
<span></span>
<Switch checked={enabled} disabled={!canEdit} onChange={setEnabled} />
{!enabled && <Tag color="orange"></Tag>}
</Space>
<Space wrap>
<Upload accept="image/*" showUploadList={false} beforeUpload={beforeUpload} disabled={!canEdit}>
<Button icon={<UploadOutlined />} loading={uploading} disabled={!canEdit}>
{cfg.image_url ? '更换二维码' : '上传二维码'}
</Button>
</Upload>
{cfg.image_url && (
<Button
icon={<DeleteOutlined />}
danger
loading={uploading}
disabled={!canEdit}
onClick={removeImage}
>
</Button>
)}
<Text type="secondary" style={{ fontSize: 12 }}>
JPEG/PNG/WebP5MB
</Text>
</Space>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
<Input
value={title}
maxLength={40}
disabled={!canEdit}
onChange={(e) => setTitle(e.target.value)}
placeholder="如:长按图片保存二维码"
/>
</div>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
App
</Text>
<Input
value={groupName}
maxLength={40}
disabled={!canEdit}
onChange={(e) => setGroupName(e.target.value)}
placeholder="如:傻瓜比价官方群"
/>
</div>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
<Input
value={subtitle}
maxLength={60}
disabled={!canEdit}
onChange={(e) => setSubtitle(e.target.value)}
placeholder="如:一起唠嗑共创、解锁新玩法"
/>
</div>
<Button type="primary" loading={saving} disabled={!canEdit} onClick={save}>
/
</Button>
{!canEdit && <Text type="secondary"> operator / super_admin </Text>}
</Space>
</Space>
)}
</Card>
);
}
@@ -2,17 +2,18 @@
import { useEffect, useState } from 'react';
import {
App,
Button,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Segmented,
Space,
Spin,
Switch,
Table,
message,
} from 'antd';
import { api, errMsg } from '@/lib/api';
@@ -41,18 +42,24 @@ interface BulkVals {
enabled: boolean;
}
interface PreviewItem {
interface RealRecord {
masked_user: string;
saved_amount_cents: number;
time: string;
created_at: string;
user_id: number;
}
const yuan = (cents: number) => (cents / 100).toFixed(2);
const MODE_LABEL: Record<string, string> = { mixed: '混播', real: '只真实', seed: '只种子' };
/** 首页轮播「种子」管理(真实记录不足时的兜底假数据,现为「生成规则」)。「数据大盘」页的一个区块。 */
/** 首页轮播「种子」管理(真实记录不足时的兜底假数据,现为「生成规则」)。「系统配置 / 首页」里的一个区块。 */
export default function HomeMarqueeSeeds() {
const { message } = App.useApp();
const [seeds, setSeeds] = useState<Seed[]>([]);
const [loading, setLoading] = useState(true);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]); // 批量操作选中行
const [mode, setMode] = useState<string>('mixed'); // 轮播数据源 mixed/real/seed
const [modeSaving, setModeSaving] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Seed | null>(null);
@@ -61,23 +68,50 @@ export default function HomeMarqueeSeeds() {
const [bulkOpen, setBulkOpen] = useState(false);
const [bulkForm] = Form.useForm<BulkVals>();
const [previewOpen, setPreviewOpen] = useState(false);
const [previewItems, setPreviewItems] = useState<PreviewItem[]>([]);
const [previewLoading, setPreviewLoading] = useState(false);
// 「可展示的真实记录」分页浏览(替代原随机预览):翻遍所有 success+省>0 的真实记录
const REC_PAGE_SIZE = 8;
const [recItems, setRecItems] = useState<RealRecord[]>([]);
const [recTotal, setRecTotal] = useState(0);
const [recPage, setRecPage] = useState(0); // 0-based
const [recLoading, setRecLoading] = useState(false);
const load = async () => {
setLoading(true);
try {
const { data } = await api.get<Seed[]>('/admin/api/marquee-seeds');
setSeeds(data);
// 列表刷新后清掉已不存在的选中 id(单行删除/外部变更后防悬空,顶部计数与批量操作才准)
setSelectedRowKeys((prev) => prev.filter((k) => data.some((s) => s.id === k)));
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
// 轮播数据源模式(mixed/real/seed):单独拉,与种子列表解耦
api
.get<{ mode: string }>('/admin/api/marquee-seeds/mode')
.then(({ data }) => setMode(data.mode))
.catch(() => {});
}, []);
// 切换数据源:乐观更新 + 失败回滚
const changeMode = async (m: string) => {
const prev = mode;
setMode(m);
setRecPage(0); // 切模式回第 1 页(不同模式条数不同,避免停在越界页)
setModeSaving(true);
try {
await api.patch('/admin/api/marquee-seeds/mode', { mode: m });
message.success('已切换,客户端下次进首页生效');
} catch (e) {
setMode(prev);
message.error(errMsg(e));
} finally {
setModeSaving(false);
}
};
// ===== 单条 新增 / 编辑 =====
const openAdd = () => {
setEditing(null);
@@ -140,22 +174,29 @@ export default function HomeMarqueeSeeds() {
}
};
// ===== 预览实际 feed =====
const fetchPreview = async () => {
setPreviewLoading(true);
// ===== 可展示的记录:按「当前模式」分页浏览全部(不去重)=====
// 只真实=真实记录;只种子=各启用种子按生成逻辑各出一行;混播=真实+种子。与 app 同口径洗牌+去连簇,
// 后端固定种子 → 翻页稳定、能翻遍全部。
const fetchRecords = async (page: number, m: string) => {
setRecLoading(true);
try {
const { data } = await api.get<{ items: PreviewItem[] }>('/admin/api/marquee-seeds/preview?limit=8');
setPreviewItems(data.items);
const { data } = await api.get<{ items: RealRecord[]; total: number }>(
'/admin/api/marquee-seeds/real-records',
{ params: { offset: page * REC_PAGE_SIZE, limit: REC_PAGE_SIZE, mode: m } },
);
setRecItems(data.items);
setRecTotal(data.total);
} catch (e) {
message.error(errMsg(e));
} finally {
setPreviewLoading(false);
setRecLoading(false);
}
};
const openPreview = () => {
setPreviewOpen(true);
fetchPreview();
};
// 页码或模式变化都重拉(切模式时 changeMode 会把页码重置到 0)。
useEffect(() => {
fetchRecords(recPage, mode);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recPage, mode]);
const toggle = async (s: Seed, enabled: boolean) => {
try {
@@ -175,6 +216,33 @@ export default function HomeMarqueeSeeds() {
}
};
// ===== 批量操作(对选中多行)=====
const batchDelete = async () => {
try {
const { data } = await api.post<{ deleted: number }>('/admin/api/marquee-seeds/batch-delete', {
ids: selectedRowKeys,
});
message.success(`已删除 ${data.deleted}`);
setSelectedRowKeys([]);
load();
} catch (e) {
message.error(errMsg(e));
}
};
const batchSetEnabled = async (enabled: boolean) => {
try {
const { data } = await api.post<{ updated: number }>('/admin/api/marquee-seeds/batch-enable', {
ids: selectedRowKeys,
enabled,
});
message.success(`${enabled ? '启用' : '停用'} ${data.updated}`);
setSelectedRowKeys([]);
load();
} catch (e) {
message.error(errMsg(e));
}
};
const columns = [
{
title: '用户名',
@@ -229,21 +297,135 @@ export default function HomeMarqueeSeeds() {
xxx xx元;
;
</p>
<Space style={{ marginBottom: 12 }}>
<Space style={{ marginBottom: 12 }} align="center" wrap>
<span style={{ fontSize: 13 }}></span>
<Segmented
value={mode}
disabled={modeSaving}
onChange={(v) => changeMode(v as string)}
options={[
{ label: '混播(默认)', value: 'mixed' },
{ label: '只真实', value: 'real' },
{ label: '只种子', value: 'seed' },
]}
/>
<span style={{ color: '#999', fontSize: 12 }}>
=+;=();=/()
</span>
</Space>
{/* :(success+>0,);app
*/}
<div
style={{
marginBottom: 16,
border: '1px solid #f0f0f0',
borderRadius: 8,
padding: '10px 12px',
background: '#fafafa',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
flexWrap: 'wrap',
gap: 8,
}}
>
<span style={{ fontSize: 13, fontWeight: 500 }}>
· {MODE_LABEL[mode] ?? mode} · {recTotal}
<span style={{ color: '#999', fontWeight: 400, fontSize: 12, marginLeft: 8 }}>
app ():===+;
app +,便
</span>
</span>
<Space size="small">
<Button size="small" disabled={recPage <= 0} onClick={() => setRecPage((p) => Math.max(0, p - 1))}>
</Button>
<span style={{ fontSize: 12, color: '#666' }}>
{recTotal === 0 ? 0 : recPage + 1}/{Math.max(1, Math.ceil(recTotal / REC_PAGE_SIZE))}
</span>
<Button
size="small"
disabled={(recPage + 1) * REC_PAGE_SIZE >= recTotal}
onClick={() => setRecPage((p) => p + 1)}
>
</Button>
</Space>
</div>
<Spin spinning={recLoading}>
<div style={{ minHeight: 120 }}>
{recItems.length === 0 && !recLoading ? (
<div style={{ color: '#bbb', textAlign: 'center', padding: '36px 0', fontSize: 13 }}>
</div>
) : (
recItems.map((it, i) => (
<div
key={i}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '6px 4px',
borderBottom: '1px solid #f0f0f0',
}}
>
<span>
{it.masked_user} {' '}
<b style={{ color: '#fa541c' }}>{yuan(it.saved_amount_cents)} </b>
<span style={{ color: '#ccc', fontSize: 12, marginLeft: 8 }}>
{it.user_id > 0 ? `u${it.user_id}` : '种子'}
</span>
</span>
<span style={{ color: '#999', fontSize: 12 }}>{it.created_at}</span>
</div>
))
)}
</div>
</Spin>
</div>
<Space style={{ marginBottom: 12 }} wrap>
<Button type="primary" size="small" onClick={openAdd}>
</Button>
<Button size="small" onClick={openBulk}>
</Button>
<Button size="small" onClick={openPreview}>
<span style={{ borderLeft: '1px solid #eee', height: 18 }} />
<Button size="small" disabled={!selectedRowKeys.length} onClick={() => batchSetEnabled(true)}>
</Button>
<Button size="small" disabled={!selectedRowKeys.length} onClick={() => batchSetEnabled(false)}>
</Button>
<Popconfirm
title={`确认删除选中的 ${selectedRowKeys.length} 条种子?`}
onConfirm={batchDelete}
disabled={!selectedRowKeys.length}
>
<Button size="small" danger disabled={!selectedRowKeys.length}>
</Button>
</Popconfirm>
{selectedRowKeys.length > 0 && (
<span style={{ color: '#999', fontSize: 12 }}> {selectedRowKeys.length} </span>
)}
</Space>
<Table
rowKey="id"
size="small"
loading={loading}
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys.map(Number)),
}}
columns={columns}
dataSource={seeds}
pagination={false}
@@ -319,48 +501,6 @@ export default function HomeMarqueeSeeds() {
</Form.Item>
</Form>
</Modal>
{/* 预览实际 feed */}
<Modal
title="预览轮播效果"
open={previewOpen}
onCancel={() => setPreviewOpen(false)}
footer={
<Space>
<Button size="small" onClick={fetchPreview}>
</Button>
<Button size="small" type="primary" onClick={() => setPreviewOpen(false)}>
</Button>
</Space>
}
>
<p style={{ color: '#999', marginTop: 0 }}>
客户端实际会看到的混播结果:真实记录会插队,/
</p>
<Spin spinning={previewLoading}>
<div style={{ minHeight: 120 }}>
{previewItems.map((it, i) => (
<div
key={i}
style={{
display: 'flex',
justifyContent: 'space-between',
padding: '7px 4px',
borderBottom: '1px solid #f0f0f0',
}}
>
<span>
{it.masked_user} {' '}
<b style={{ color: '#fa541c' }}>{yuan(it.saved_amount_cents)} </b>
</span>
<span style={{ color: '#999' }}>{it.time}</span>
</div>
))}
</div>
</Spin>
</Modal>
</div>
);
}
@@ -2,12 +2,12 @@
import { useEffect, useState } from 'react';
import {
App,
Button,
Card,
Checkbox,
Col,
InputNumber,
Popconfirm,
Radio,
Row,
Select,
@@ -15,9 +15,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
@@ -32,7 +32,7 @@ interface StatItem {
random_kind: 'mult' | 'add'; // 自增长方式:×倍率 / +绝对增量
random_step_min: number; // 绝对增量区间(基础单位)
random_step_max: number;
real_offset: number; // 真实值基数偏移(基础单位)
real_offset: number; // 真实值保底值(基础单位):展示=max(真实,保底)。沿用 real_offset 列名(语义已改)
allow_decrease: boolean; // 是否允许展示值下降
random_current: number | null;
random_last_tick_at: string | null;
@@ -54,7 +54,7 @@ function decomposeInterval(sec: number): { num: number; unit: IntervalUnit } {
interface Edit {
mode: 'real' | 'manual' | 'random';
manual: number | null; // 展示单位
realOffset: number; // 真实值偏移(展示单位)
realOffset: number; // 真实值保底值(展示单位)
growthKind: 'mult' | 'add'; // 自增长方式
multMin: number; // 1.0 ~
multMax: number;
@@ -127,7 +127,7 @@ function predictNext(it: StatItem): string | null {
const hi = Math.floor((cur * it.random_mult_max) / 1000);
return `${d(lo)} ~ ${d(hi)} ${u}`;
}
const target = it.mode === 'real' ? it.real_value + it.real_offset : it.manual_value ?? cur;
const target = it.mode === 'real' ? Math.max(it.real_value, it.real_offset) : it.manual_value ?? cur;
const eff = !it.allow_decrease && target < cur ? cur : target;
return `${it.mode === 'real' ? '≈ ' : ''}${d(eff)} ${u}`;
}
@@ -150,8 +150,104 @@ function nextRefreshLabel(tickSeconds: number, anchorMinutes: number): string {
});
}
/** 首页三门面数字(帮助用户/完成比价/累计节省)的展示模式配置。作为「数据大盘」页的一个区块。 */
// ===== 三者关系「智能校验」(劝阻式,非硬拦)=====
// 触发阈值比「理想区间」宽,只在明显离谱时弹窗;理想区间在弹窗里作为建议展示。
const REL_PER_USER = { min: 2, max: 20, ideal: '4~8 次/人' }; // 人均比价 = 完成比价 / 帮助用户
const REL_PER_COMPARE = { min: 2, max: 30, ideal: '5~12 元/次' }; // 每次省额 = 累计节省(元) / 完成比价
function fmtNum(n: number): string {
return n >= 10000 ? `${(n / 10000).toFixed(2).replace(/\.?0+$/, '')}` : String(Math.round(n));
}
// 只增不减护栏的前端镜像:目标低于现值且未允许下降时,实际仍显示现值。
function guardedBase(allowDecrease: boolean, current: number | null, target: number | null): number | null {
if (target == null) return current;
if (!allowDecrease && current != null && target < current) return current;
return target;
}
// 本次保存后,该指标「大概会显示」的值(基础单位:total_saved 为分)。
function intendedBase(it: StatItem, e: Edit): number | null {
const cur = it.random_current;
if (e.mode === 'manual') {
const t = e.manual != null ? (dispToBase(it.metric, e.manual) as number) : cur;
return guardedBase(e.allowDecrease, cur, t);
}
if (e.mode === 'real') {
const t = Math.max(it.real_value, (dispToBase(it.metric, e.realOffset) as number) ?? 0);
return guardedBase(e.allowDecrease, cur, t);
}
// random:填了初始基数=设起点(过护栏);留空=保持现展示值
if (e.initial != null) return guardedBase(e.allowDecrease, cur, dispToBase(it.metric, e.initial) as number);
return cur;
}
// 校验三者关系。返回 null=数据不全无法判断(跳过校验);否则 warnings 为空=合理。
// 被改的指标用「本次要设的目标值」,另两个用各自当前展示值 random_current。
function checkRelations(
items: StatItem[],
edits: Record<string, Edit>,
): { warnings: string[]; users: number; compares: number; savedYuan: number } | null {
const valBase = (metric: string): number | null => {
const it = items.find((x) => x.metric === metric);
const e = it ? edits[metric] : undefined;
return it && e ? intendedBase(it, e) : null;
};
const users = valBase('help_users');
const compares = valBase('total_compares');
const savedBase = valBase('total_saved');
if (users == null || compares == null || savedBase == null) return null;
if (users <= 0 || compares <= 0 || savedBase <= 0) return null;
const savedYuan = savedBase / 100;
const warnings: string[] = [];
if (compares < users) {
warnings.push(`完成比价(${fmtNum(compares)})少于帮助用户(${fmtNum(users)}),正常应「次数 ≥ 人数」`);
}
const perUser = compares / users;
if (perUser < REL_PER_USER.min || perUser > REL_PER_USER.max) {
warnings.push(
`人均比价 ${perUser.toFixed(1)} 次/人,${perUser < REL_PER_USER.min ? '偏低' : '偏高'}(建议 ${REL_PER_USER.ideal})`,
);
}
const perCompare = savedYuan / compares;
if (perCompare < REL_PER_COMPARE.min || perCompare > REL_PER_COMPARE.max) {
warnings.push(
`每次省 ${perCompare.toFixed(1)} 元/次,${perCompare < REL_PER_COMPARE.min ? '偏低' : '偏高'}(建议 ${REL_PER_COMPARE.ideal})`,
);
}
return { warnings, users, compares, savedYuan };
}
// 把某指标的编辑态打包成 PATCH body(三项共用,逐个指标各打一份)。
function buildBody(metric: string, e: Edit, immediate: boolean): Record<string, number | string | boolean> {
// 自定义不暴露间隔,固定为每天(每日「更新时间」刷新一次);其余模式用所选间隔。
const intervalSec =
e.mode === 'manual'
? 86400
: Math.max(60, Math.round(e.intervalNum) * UNIT_SECONDS[e.intervalUnit]);
const anchorMin = e.anchorHour * 60 + e.anchorMinute; // 更新时间(全天 HH:MM),后端按间隔取相位
const body: Record<string, number | string | boolean> = {
mode: e.mode,
random_mult_min: Math.round(e.multMin * 1000),
random_mult_max: Math.round(e.multMax * 1000),
random_tick_seconds: intervalSec,
random_anchor_minutes: anchorMin,
random_kind: e.growthKind,
random_step_min: (dispToBase(metric, e.stepMin) as number) ?? 0,
random_step_max: (dispToBase(metric, e.stepMax) as number) ?? 0,
real_offset: (dispToBase(metric, e.realOffset) as number) ?? 0,
allow_decrease: e.allowDecrease,
};
if (e.manual != null) body.manual_value = dispToBase(metric, e.manual) as number;
if (e.initial != null) body.random_initial = dispToBase(metric, e.initial) as number;
if (immediate) body.apply_now = true;
return body;
}
/** 首页三门面数字(帮助用户/完成比价/累计节省)的展示模式配置。「系统配置 / 首页」里的一个区块。 */
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>>({});
@@ -188,52 +284,67 @@ export default function HomeStatsConfig() {
const patch = (metric: string, p: Partial<Edit>) =>
setEdits((s) => ({ ...s, [metric]: { ...s[metric], ...p } }));
// immediate=true:点「立即更新」,保存配置 + 马上刷新展示值(不等更新钟点)。
const save = async (it: StatItem, immediate = false) => {
const e = edits[it.metric];
// 自定义不暴露间隔,固定为每天(每日「更新时间」刷新一次);其余模式用所选间隔。
const intervalSec =
e.mode === 'manual'
? 86400
: Math.max(60, Math.round(e.intervalNum) * UNIT_SECONDS[e.intervalUnit]);
const anchorMin = e.anchorHour * 60 + e.anchorMinute; // 更新时间(全天 HH:MM),后端按间隔取相位
const body: Record<string, number | string | boolean> = {
mode: e.mode,
random_mult_min: Math.round(e.multMin * 1000),
random_mult_max: Math.round(e.multMax * 1000),
random_tick_seconds: intervalSec,
random_anchor_minutes: anchorMin,
random_kind: e.growthKind,
random_step_min: (dispToBase(it.metric, e.stepMin) as number) ?? 0,
random_step_max: (dispToBase(it.metric, e.stepMax) as number) ?? 0,
real_offset: (dispToBase(it.metric, e.realOffset) as number) ?? 0,
allow_decrease: e.allowDecrease,
};
if (e.manual != null) body.manual_value = dispToBase(it.metric, e.manual) as number;
if (e.initial != null) body.random_initial = dispToBase(it.metric, e.initial) as number;
if (immediate) body.apply_now = true;
setSaving(it.metric);
try {
// PATCH 返回更新后的该指标;只就地替换这一项 + 重置它的输入态,不整页 reload(消除闪烁)。
const { data } = await api.patch<StatItem>(`/admin/api/dashboard-display/${it.metric}`, body);
setItems((prev) => prev.map((x) => (x.metric === it.metric ? data : x)));
setEdits((s) => ({ ...s, [it.metric]: toEdit(data) }));
if (immediate) {
message.success('已立即更新,客户端下次进首页即可见');
} else {
const lastTickMs = data.random_last_tick_at ? new Date(data.random_last_tick_at).getTime() : 0;
const effectiveNow = Date.now() - lastTickMs < 10_000;
// 三项共享:一次保存 / 立即更新全部三个指标(各打一份 PATCH)。
// 校验三者最终关系,不合理则弹窗劝阻、确认才提交。
const saveAll = async (immediate = false) => {
const proceed = async () => {
setSaving('__all__');
// 逐个 PATCH(顺序提交,各自 commit 一行)。results 在 try 外声明,半失败时也能把已成功的合并进 state。
const results: StatItem[] = [];
try {
for (const it of items) {
const { data } = await api.patch<StatItem>(
`/admin/api/dashboard-display/${it.metric}`,
buildBody(it.metric, edits[it.metric], immediate),
);
results.push(data);
}
message.success(
effectiveNow
? '已保存,客户端下次进首页即可见'
: `已保存,展示值将于北京时间 ${nextRefreshLabel(data.random_tick_seconds, data.random_anchor_minutes)} 刷新后生效`,
immediate
? '三项已立即更新,客户端下次进首页即可见'
: '三项配置已保存,展示值将按各自「更新时间」刷新后生效(自定义/立即更新即时生效)',
);
} catch (err) {
// 前 results.length 项后端已 commit,失败的是第 results.length+1 项
message.error(`${results.length + 1} 项保存失败(前 ${results.length} 项已成功): ${errMsg(err)}`);
} finally {
// 半失败也把已成功的就地合并进 state,避免前端显旧值与后端撕裂
if (results.length > 0) {
setItems((prev) => prev.map((x) => results.find((r) => r.metric === x.metric) ?? x));
setEdits((s) => ({ ...s, ...Object.fromEntries(results.map((d) => [d.metric, toEdit(d)])) }));
}
setSaving(null);
}
} catch (err) {
message.error(errMsg(err));
} finally {
setSaving(null);
};
// 智能校验:按下后三者大概会呈现的关系是否合理,不合理则弹窗劝阻、确认才提交。
const rel = checkRelations(items, edits);
if (rel && rel.warnings.length > 0) {
modal.confirm({
title: '数据关系校验',
width: 480,
okText: '仍要这样设置',
cancelText: '返回修改',
content: (
<div style={{ fontSize: 13 }}>
<div style={{ marginBottom: 8 }}>:</div>
<ul style={{ paddingLeft: 18, margin: '0 0 8px' }}>
{rel.warnings.map((w, i) => (
<li key={i} style={{ color: '#d4380d', marginBottom: 2 }}>
{w}
</li>
))}
</ul>
<div style={{ color: '#888' }}>
当前预计:帮助用户 {fmtNum(rel.users)} · {fmtNum(rel.compares)} · {' '}
{fmtNum(rel.savedYuan)}
</div>
</div>
),
onOk: proceed,
});
} else {
await proceed();
}
};
@@ -241,7 +352,7 @@ export default function HomeStatsConfig() {
<div style={{ marginTop: 24 }}>
<h3 style={{ marginBottom: 4 }}></h3>
<p style={{ color: '#999', marginTop: 0 }}>
:<b></b>(,)/{' '}
:<b></b>(,)/{' '}
<b></b>()/ <b></b>()
<b></b>();
</p>
@@ -260,6 +371,20 @@ export default function HomeStatsConfig() {
e.manual != null &&
it.random_current != null &&
(dispToBase(it.metric, e.manual) as number) < it.random_current;
// real 同理:max(真实值,保底值) 低于当前展示值且未允许下降 → 立即更新会被只增不减护栏挡住
const realTargetBase = Math.max(it.real_value, (dispToBase(it.metric, e.realOffset) as number) ?? 0);
const realBlocked =
e.mode === 'real' &&
!e.allowDecrease &&
it.random_current != null &&
realTargetBase < it.random_current;
// 自增长:填的初始基数低于当前展示值且未允许下降 → 会被只增不减护栏挡住(保持现值)
const initialBlocked =
e.mode === 'random' &&
!e.allowDecrease &&
e.initial != null &&
it.random_current != null &&
(dispToBase(it.metric, e.initial) as number) < it.random_current;
return (
<Col key={it.metric} xs={24} md={12} xl={8}>
<Card
@@ -307,19 +432,26 @@ export default function HomeStatsConfig() {
)}
{e.mode === 'real' && (
<Space wrap>
<span>:</span>
<InputNumber
value={e.realOffset}
min={0}
onChange={(v) => patch(it.metric, { realOffset: v ?? 0 })}
style={{ width: 160 }}
/>
<span style={{ color: '#666' }}>{u}</span>
<Tooltip title="展示值 = 真实值 + 此基数。冷启动期真实数字小,加个体面的基数,且仍随真实增长。">
<span style={{ color: '#999', cursor: 'help' }}></span>
</Tooltip>
</Space>
<div>
<Space wrap>
<span>:</span>
<InputNumber
value={e.realOffset}
min={0}
onChange={(v) => patch(it.metric, { realOffset: v ?? 0 })}
style={{ width: 160 }}
/>
<span style={{ color: '#666' }}>{u}</span>
<Tooltip title="展示值 = max(真实值, 保底值)。冷启动期真实数字小,显示保底撑场面;真实值涨过保底后显示纯真实、零差距。留 0 = 纯真实值。">
<span style={{ color: '#999', cursor: 'help' }}></span>
</Tooltip>
</Space>
{realBlocked && (
<div style={{ color: '#fa8c16', fontSize: 12, marginTop: 4 }}>
,()
</div>
)}
</div>
)}
{e.mode === 'random' && (
@@ -431,17 +563,26 @@ export default function HomeStatsConfig() {
</Space>
{e.mode === 'random' && (
<Space wrap>
<span>:</span>
<InputNumber
value={e.initial}
min={0}
onChange={(v) => patch(it.metric, { initial: v })}
style={{ width: 200 }}
placeholder="留空=用真实值播种"
/>
<span style={{ color: '#666' }}>{u}</span>
</Space>
<div>
<Space wrap>
<span>:</span>
<InputNumber
value={e.initial}
min={0}
onChange={(v) => patch(it.metric, { initial: v })}
style={{ width: 200 }}
/>
<span style={{ color: '#666' }}>{u}</span>
<Tooltip title="填值=把展示值设为该数作为增长起点(受『只增不减』限制:低于当前展示值需先勾「允许数字下降」)。留空=保持现有展示值不变、继续按增量增长;仅当该指标从未启用过(还没有展示值)时,留空才用真实值播种。">
<span style={{ color: '#999', cursor: 'help' }}></span>
</Tooltip>
</Space>
{initialBlocked && (
<div style={{ color: '#fa8c16', fontSize: 12, marginTop: 4 }}>
,()
</div>
)}
</div>
)}
<Checkbox
@@ -460,7 +601,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>
@@ -474,21 +615,10 @@ export default function HomeStatsConfig() {
)}
</Space>
<Space>
<Button type="primary" loading={saving === it.metric} onClick={() => save(it)}>
</Button>
<Popconfirm
title="立即更新会马上推进一次(自增长走一档),不等更新时间。确定?"
onConfirm={() => save(it, true)}
>
<Button loading={saving === it.metric}></Button>
</Popconfirm>
<Tag color={it.mode === 'real' ? 'green' : it.mode === 'manual' ? 'orange' : 'blue'}>
线:
{it.mode === 'real' ? '真实值' : it.mode === 'manual' ? '自定义' : '自增长'}
</Tag>
</Space>
<Tag color={it.mode === 'real' ? 'green' : it.mode === 'manual' ? 'orange' : 'blue'}>
线:
{it.mode === 'real' ? '真实值' : it.mode === 'manual' ? '自定义' : '自增长'}
</Tag>
</Space>
</Card>
</Col>
@@ -496,6 +626,20 @@ export default function HomeStatsConfig() {
})}
</Row>
)}
{!loading && (
<div style={{ marginTop: 16 }}>
<Space>
{/* :PATCH apply_now, tick( app )
, */}
<Button type="primary" loading={saving != null} onClick={() => saveAll(true)}>
()
</Button>
<span style={{ color: '#999', fontSize: 12 }}>
,,
</span>
</Space>
</div>
)}
</div>
);
}
+82 -57
View File
@@ -1,14 +1,17 @@
'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, Tabs, Tag, Tooltip } from 'antd';
import { api, errMsg } from '@/lib/api';
import HomeMarqueeSeeds from './HomeMarqueeSeeds';
import HomeStatsConfig from './HomeStatsConfig';
import FeedbackQrConfig from './FeedbackQrConfig';
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 +31,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 +42,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
@@ -78,9 +83,66 @@ export default function ConfigPage() {
}
};
if (loading) return <Spin style={{ display: 'block', marginTop: 80 }} />;
const groups = [...new Set(items.map((i) => i.group))];
const welfareConfig = loading ? (
<Spin style={{ display: 'block', marginTop: 24 }} />
) : (
groups.map((g) => (
<Card key={g} title={g} size="small" style={{ marginBottom: 16 }}>
{items
.filter((i) => i.group === g)
.map((item) => (
<div
key={item.key}
style={{ marginBottom: 12, paddingBottom: 12, borderBottom: '1px solid #f0f0f0' }}
>
<div style={{ marginBottom: 4 }}>
<b>{item.label}</b> {item.overridden ? <Tag color="blue"></Tag> : <Tag></Tag>}
{item.help && (
<Tooltip title={item.help}>
<span style={{ color: '#999', marginLeft: 6, fontSize: 12, cursor: 'help' }}>
</span>
</Tooltip>
)}
</div>
<Space wrap>
{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 })}
style={{ width: 180 }}
/>
) : (
<Input
value={edits[item.key]}
onChange={(e) => setEdits({ ...edits, [item.key]: e.target.value })}
style={{ width: 380 }}
placeholder={
item.type === 'int_list' ? '逗号分隔,如 10, 20, 30' : 'JSON,如 {"key": 100}'
}
/>
)}
<Button
type="primary"
size="small"
loading={saving === item.key}
onClick={() => save(item)}
>
</Button>
<span style={{ color: '#bbb', fontSize: 12 }}> {JSON.stringify(item.default)}</span>
</Space>
</div>
))}
</Card>
))
);
return (
<div>
@@ -88,59 +150,22 @@ export default function ConfigPage() {
<p style={{ color: '#999' }}>
() / ,
</p>
{groups.map((g) => (
<Card key={g} title={g} size="small" style={{ marginBottom: 16 }}>
{items
.filter((i) => i.group === g)
.map((item) => (
<div
key={item.key}
style={{ marginBottom: 12, paddingBottom: 12, borderBottom: '1px solid #f0f0f0' }}
>
<div style={{ marginBottom: 4 }}>
<b>{item.label}</b>{' '}
{item.overridden ? <Tag color="blue"></Tag> : <Tag></Tag>}
{item.help && (
<Tooltip title={item.help}>
<span style={{ color: '#999', marginLeft: 6, fontSize: 12, cursor: 'help' }}>
</span>
</Tooltip>
)}
</div>
<Space wrap>
{item.type === 'int' ? (
<InputNumber
value={edits[item.key]}
onChange={(v) => setEdits({ ...edits, [item.key]: v })}
style={{ width: 180 }}
/>
) : (
<Input
value={edits[item.key]}
onChange={(e) => setEdits({ ...edits, [item.key]: e.target.value })}
style={{ width: 380 }}
placeholder={
item.type === 'int_list' ? '逗号分隔,如 10, 20, 30' : 'JSON,如 {"key": 100}'
}
/>
)}
<Button
type="primary"
size="small"
loading={saving === item.key}
onClick={() => save(item)}
>
</Button>
<span style={{ color: '#bbb', fontSize: 12 }}>
{JSON.stringify(item.default)}
</span>
</Space>
</div>
))}
</Card>
))}
<Tabs
items={[
{
key: 'home',
label: '首页',
children: (
<>
<HomeStatsConfig />
<HomeMarqueeSeeds />
</>
),
},
{ key: 'welfare', label: '福利页', children: welfareConfig },
{ key: 'feedback-qr', label: '反馈二维码', children: <FeedbackQrConfig /> },
]}
/>
</div>
);
}
+677
View File
@@ -0,0 +1,677 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import {
App,
Button,
Card,
Col,
DatePicker,
Divider,
Input,
Row,
Select,
Space,
Statistic,
Table,
Tag,
Typography,
} from 'antd';
import dayjs, { type Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format';
import UserRecordsDrawer from '@/components/UserRecordsDrawer';
const { RangePicker } = DatePicker;
// ── 领券数据看板类型(仅本页用,内联定义)──
interface CouponDataSummary {
started_count: number;
completed_count: number;
avg_elapsed_ms: number | null;
p5_ms: number | null;
p50_ms: number | null;
p95_ms: number | null;
p99_ms: number | null;
}
interface CouponDataDaily {
date: string;
started_count: number;
completed_count: number;
avg_elapsed_ms: number | null;
}
interface CouponDataHourly {
hour: number;
started_count: number;
completed_count: number;
avg_elapsed_ms: number | null;
}
interface CouponDataRow {
id: number;
trace_id: string;
user_id: number | null;
user_phone: string | null;
user_nickname: string | null;
status: string;
platforms: string[] | null;
origin_package: string | null;
elapsed_ms: number | null;
platform_elapsed: Record<string, number> | null;
device_model: string | null;
rom: string | null;
app_env: string | null;
started_at: string;
claimed_count: number | null;
trace_url: string | null;
}
interface CouponDataReport {
date_from: string;
date_to: string;
summary: CouponDataSummary;
daily: CouponDataDaily[];
hourly: CouponDataHourly[];
total: number;
items: CouponDataRow[];
}
// 发起来源 App 包名 → 中文(「发起平台」列):空=傻瓜比价首页发起,外卖 App 包名→对应平台。
function originLabel(pkg: string | null): string {
if (!pkg) return '傻瓜比价';
if (pkg.includes('meituan') || pkg.includes('sankuai')) return '美团';
if (pkg.includes('taobao') || pkg.includes('ele')) return '淘宝';
if (pkg.includes('jingdong') || pkg.includes('jd')) return '京东';
return pkg;
}
// 领券状态 → 颜色 + 中文(started 无终态 = 中途流失)
const STATUS_TAG: Record<string, { color: string; label: string }> = {
started: { color: 'default', label: '未完成' },
completed: { color: 'green', label: '完成' },
failed: { color: 'red', label: '失败' },
abandoned: { color: 'orange', label: '中途退出' },
};
// ms → "1.5s"(空值显示 -)
const fmtSec = (ms: number | null | undefined): string =>
ms == null ? '-' : `${(ms / 1000).toFixed(1)}s`;
// 点手机号弹出的「该用户全部领券」抽屉列(精简版:单用户,不含用户/手机号列)。
const RECORD_COLUMNS: ColumnsType<CouponDataRow> = [
{ title: '时间', dataIndex: 'started_at', width: 160, render: (v: string) => formatUtcTime(v) },
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s: string) => {
const t = STATUS_TAG[s] ?? { color: 'default', label: s };
return <Tag color={t.color}>{t.label}</Tag>;
},
},
{ title: '发起平台', dataIndex: 'origin_package', width: 90, render: (pkg: string | null) => originLabel(pkg) },
{ title: '耗时', dataIndex: 'elapsed_ms', width: 80, align: 'right', render: (v: number | null) => fmtSec(v) },
{ title: '美团', key: 'mt', width: 70, align: 'right', render: (_: unknown, r: CouponDataRow) => fmtSec(r.platform_elapsed?.['meituan-waimai']) },
{ title: '淘宝', key: 'tb', width: 70, align: 'right', render: (_: unknown, r: CouponDataRow) => fmtSec(r.platform_elapsed?.['taobao-shanguang']) },
{ title: '京东', key: 'jd', width: 70, align: 'right', render: (_: unknown, r: CouponDataRow) => fmtSec(r.platform_elapsed?.['jd-waimai']) },
{
title: 'trace',
dataIndex: 'trace_id',
width: 90,
render: (v: string, r: CouponDataRow) =>
r.trace_url ? (
<a href={r.trace_url} target="_blank" rel="noreferrer" title={v}>trace</a>
) : (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{v.slice(0, 6)}</Typography.Text>
),
},
];
// ── 趋势图(纯 SVG,零依赖,复用广告报表同款):浅蓝柱=发起数、深蓝柱=完成数(左轴 次数),橙线=平均耗时(右轴 秒)──
const CHART_BAR_STARTED = '#bae0ff';
const CHART_BAR_COMPLETED = '#1677ff';
const CHART_LINE = '#fa8c16';
interface TrendPoint {
label: string; // x 轴刻度(小时数 / MM-DD)
tip: string; // hover 原生 tooltip
started: number;
completed: number;
avgSec: number | null; // 平均耗时(秒);该桶无完成为 null(不画线点)
}
// 按天聚合:用 from..to 补齐空缺日为 0,轴连续。
function aggregateDaily(dateFrom: string, dateTo: string, daily: CouponDataDaily[]): 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 started = d?.started_count ?? 0;
const completed = d?.completed_count ?? 0;
const avgSec = d?.avg_elapsed_ms != null ? d.avg_elapsed_ms / 1000 : null;
out.push({
label: ds.slice(5),
tip: `${ds} 发起 ${started} · 完成 ${completed} · 平均耗时 ${avgSec == null ? '-' : avgSec.toFixed(1) + 's'}`,
started,
completed,
avgSec,
});
cur = cur.add(1, 'day');
guard += 1;
}
return out;
}
// 按小时聚合(023)。
function aggregateHourly(rows: CouponDataHourly[]): TrendPoint[] {
const byHour = new Map(rows.map((r) => [r.hour, r]));
return Array.from({ length: 24 }, (_, h) => {
const r = byHour.get(h);
const started = r?.started_count ?? 0;
const completed = r?.completed_count ?? 0;
const avgSec = r?.avg_elapsed_ms != null ? r.avg_elapsed_ms / 1000 : null;
return {
label: String(h),
tip: `${String(h).padStart(2, '0')}:00 发起 ${started} · 完成 ${completed} · 平均耗时 ${avgSec == null ? '-' : avgSec.toFixed(1) + 's'}`,
started,
completed,
avgSec,
};
});
}
function TrendChart({ points }: { points: TrendPoint[] }) {
const n = points.length;
const maxBar = Math.max(1, ...points.map((p) => p.started));
const maxSec = Math.max(1e-9, ...points.map((p) => p.avgSec ?? 0));
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(24, step * 0.5);
const yBase = padT + plotH;
const barX = (i: number) => padL + i * step + (step - barW) / 2;
const cx = (i: number) => padL + i * step + step / 2;
const barH = (v: number) => (v / maxBar) * plotH;
const secY = (v: number) => yBase - (v / maxSec) * plotH;
// X 轴刻度按天/按小时尽量每格都标(≤31 个点每个都标,超出才抽稀),避免跨度稍大就隔天显示。
const labelEvery = Math.max(1, Math.ceil(n / 31));
// 平均耗时线:仅 avgSec 非 null 的点连线(无完成的桶断开)。
const linePts = points
.map((p, i) => (p.avgSec == null ? null : `${cx(i)},${secY(p.avgSec)}`))
.filter((x): x is string => x !== null)
.join(' ');
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_COMPLETED}>
{Math.round(maxBar * t)}
</text>
<text x={W - padR + 8} y={y + 4} textAnchor="start" fontSize={11} fill={CHART_LINE}>
{(maxSec * t).toFixed(1)}
</text>
</g>
);
})}
{/* 发起数柱(浅) */}
{points.map((p, i) => (
<rect
key={`s-${i}`}
x={barX(i)}
y={yBase - barH(p.started)}
width={barW}
height={barH(p.started)}
fill={CHART_BAR_STARTED}
rx={2}
>
<title>{p.tip}</title>
</rect>
))}
{/* 完成数柱(深,窄,叠中间) */}
{points.map((p, i) => {
const cw = barW * 0.55;
return (
<rect
key={`c-${i}`}
x={cx(i) - cw / 2}
y={yBase - barH(p.completed)}
width={cw}
height={barH(p.completed)}
fill={CHART_BAR_COMPLETED}
rx={1}
>
<title>{p.tip}</title>
</rect>
);
})}
{/* 平均耗时线(橙,右轴) */}
<polyline points={linePts} fill="none" stroke={CHART_LINE} strokeWidth={2} />
{points.map((p, i) =>
p.avgSec == null ? null : (
<circle key={`p-${i}`} cx={cx(i)} cy={secY(p.avgSec)} r={2.5} fill={CHART_LINE}>
<title>{p.tip}</title>
</circle>
),
)}
{points.map((p, i) =>
i % labelEvery === 0 || i === n - 1 ? (
<text key={`x-${i}`} x={cx(i)} y={H - 14} textAnchor="middle" fontSize={11} fill="#999">
{p.label}
</text>
) : null,
)}
</svg>
);
}
// 领券数据看板:上半汇总卡 + 按天/小时趋势(参考广告收益大盘),下半逐条领券明细。
// 数据源 coupon_session(客户端 /api/v1/coupon/session 两段上报);耗时统计只算 completed。
export default function CouponDataPage() {
const { message } = App.useApp();
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(6, 'day'), dayjs()]);
const [user, setUser] = useState<string>('');
const [appEnv, setAppEnv] = useState<string>('prod');
const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
const [sortBy, setSortBy] = useState<'time' | 'elapsed'>('time');
const [limit, setLimit] = useState<number>(100);
const [page, setPage] = useState<number>(1);
const [queriedGranularity, setQueriedGranularity] = useState<'day' | 'hour'>('day');
const [queriedMultiDay, setQueriedMultiDay] = useState(false);
const [queriedLimit, setQueriedLimit] = useState<number>(100);
const [data, setData] = useState<CouponDataReport | null>(null);
const [loading, setLoading] = useState(false);
// 点手机号:抽屉看该用户全部领券(总次数 + 记录)
const [recordsUser, setRecordsUser] = useState<{ userId: number; phone: string | null } | null>(null);
const openUserRecords = (r: CouponDataRow) => {
if (r.user_id != null) setRecordsUser({ userId: r.user_id, phone: r.user_phone });
};
// 跨多天时「按小时」无意义,粒度强制按天(同广告报表)。
const rangeMultiDay = range[0].format('YYYY-MM-DD') !== range[1].format('YYYY-MM-DD');
const load = useCallback(
async (targetPage = 1, targetLimit = limit, targetSort = sortBy) => {
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<CouponDataReport>('/admin/api/coupon-data', {
params: {
date_from: from,
date_to: to,
user: user.trim() || undefined,
app_env: appEnv,
granularity: gran,
limit: targetLimit,
offset: (targetPage - 1) * targetLimit,
sort: targetSort,
},
});
setData(res.data);
setPage(targetPage);
setQueriedLimit(targetLimit);
setQueriedGranularity(gran);
setQueriedMultiDay(multiDay);
} catch (e) {
message.error(errMsg(e));
} finally {
setLoading(false);
}
},
[range, user, appEnv, granularity, limit, sortBy, message],
);
useEffect(() => {
load();
// 仅首次自动拉近 7 天;之后由「查询」按钮触发
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const columns: ColumnsType<CouponDataRow> = [
{
title: '用户',
dataIndex: 'user_nickname',
width: 140,
render: (_: unknown, r: CouponDataRow) => {
if (r.user_nickname) {
return (
<span>
{r.user_nickname}
{r.user_id != null && (
<Typography.Text type="secondary" style={{ fontSize: 11, marginLeft: 6 }}>
#{r.user_id}
</Typography.Text>
)}
</span>
);
}
if (r.user_id != null) return <Typography.Text type="secondary">#{r.user_id}</Typography.Text>;
return <Typography.Text type="secondary"></Typography.Text>;
},
},
{
title: '手机号',
dataIndex: 'user_phone',
width: 130,
// 点手机号 → 抽屉看该用户全部领券(参考低价审核/反馈页)。游客(无 user_id)不可点。
render: (phone: string | null, r: CouponDataRow) =>
r.user_id != null ? (
<a onClick={() => openUserRecords(r)}>{phone || `#${r.user_id}`}</a>
) : (
<Typography.Text type="secondary"></Typography.Text>
),
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s: string) => {
const t = STATUS_TAG[s] ?? { color: 'default', label: s };
return <Tag color={t.color}>{t.label}</Tag>;
},
},
{
title: '发起平台',
dataIndex: 'origin_package',
width: 110,
render: (pkg: string | null) => originLabel(pkg),
},
{
title: '耗时',
dataIndex: 'elapsed_ms',
width: 90,
align: 'right',
render: (v: number | null) => fmtSec(v),
},
{
title: '美团耗时',
key: 'mt_elapsed',
width: 90,
align: 'right',
render: (_: unknown, r: CouponDataRow) => fmtSec(r.platform_elapsed?.['meituan-waimai']),
},
{
title: '淘宝耗时',
key: 'tb_elapsed',
width: 90,
align: 'right',
render: (_: unknown, r: CouponDataRow) => fmtSec(r.platform_elapsed?.['taobao-shanguang']),
},
{
title: '京东耗时',
key: 'jd_elapsed',
width: 90,
align: 'right',
render: (_: unknown, r: CouponDataRow) => fmtSec(r.platform_elapsed?.['jd-waimai']),
},
{
title: '机型/ROM',
key: 'device',
width: 200,
render: (_: unknown, r: CouponDataRow) => {
const parts = [r.device_model, r.rom].filter(Boolean);
return parts.length ? parts.join(' / ') : <Typography.Text type="secondary">-</Typography.Text>;
},
},
{
title: '时间',
dataIndex: 'started_at',
width: 165,
render: (v: string) => formatUtcTime(v),
},
{
title: '领券 trace',
dataIndex: 'trace_id',
width: 150,
// 有 trace_url(pricebot 上云,仅 completed)→ 可点链接打开完整现场;否则降级为可复制 trace_id。
render: (v: string, r: CouponDataRow) =>
r.trace_url ? (
<a href={r.trace_url} target="_blank" rel="noreferrer" title={v}>
trace{v.slice(0, 8)}
</a>
) : (
<Typography.Text copyable={{ text: v }} style={{ fontSize: 12 }}>
{v.slice(0, 8)}
</Typography.Text>
),
},
];
const summary = data?.summary;
const items = data?.items ?? [];
return (
<div>
<Space align="center" style={{ display: 'flex', marginBottom: 16 }}>
<h2 style={{ margin: 0 }}></h2>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
;/
</Typography.Text>
</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>
<Input
placeholder="手机号/昵称"
value={user}
onChange={(e) => setUser(e.target.value)}
onPressEnter={() => load(1)}
allowClear
style={{ width: 150 }}
/>
</Space>
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<Select
value={appEnv}
onChange={setAppEnv}
style={{ width: 120 }}
options={[
{ value: 'prod', label: '正式(prod)' },
{ value: 'dev', label: '测试(dev)' },
{ value: 'all', label: '全部' },
]}
/>
</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={sortBy}
onChange={(v) => {
setSortBy(v);
load(1, limit, v);
}}
style={{ width: 130 }}
options={[
{ value: 'time', label: '时间倒序' },
{ value: 'elapsed', label: '耗时倒序' },
]}
/>
</Space>
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<Select
value={limit}
onChange={(v) => {
setLimit(v);
load(1, v);
}}
style={{ width: 120 }}
options={[20, 50, 100, 200, 500].map((nn) => ({ value: nn, label: `${nn} 条/页` }))}
/>
</Space>
<Button type="primary" onClick={() => load(1)} loading={loading}>
</Button>
</Space>
</Card>
{summary && (
<Card size="small" style={{ marginBottom: 16 }}>
<Row gutter={[16, 12]}>
<Col flex="1 1 0">
<Statistic title="领券发起数" value={summary.started_count} />
</Col>
<Col flex="1 1 0">
<Statistic title="领券完成数" value={summary.completed_count} />
</Col>
<Col flex="1 1 0">
<Statistic title="平均耗时" value={fmtSec(summary.avg_elapsed_ms)} />
</Col>
</Row>
<Divider style={{ marginTop: 16, marginBottom: 16 }} />
<Row gutter={[16, 12]}>
<Col flex="1 1 0">
<Statistic title="耗时 5 分位" value={fmtSec(summary.p5_ms)} />
</Col>
<Col flex="1 1 0">
<Statistic title="耗时 50 分位" value={fmtSec(summary.p50_ms)} />
</Col>
<Col flex="1 1 0">
<Statistic title="耗时 95 分位" value={fmtSec(summary.p95_ms)} />
</Col>
<Col flex="1 1 0">
<Statistic title="耗时 99 分位" value={fmtSec(summary.p99_ms)} />
</Col>
</Row>
</Card>
)}
{data &&
(queriedMultiDay
? (data.daily?.length ?? 0) > 0
: queriedGranularity === 'hour' && (data.hourly?.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_STARTED,
borderRadius: 2,
marginRight: 4,
}}
/>
</span>
<span style={{ fontSize: 12, color: '#666' }}>
<span
style={{
display: 'inline-block',
width: 10,
height: 10,
background: CHART_BAR_COMPLETED,
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>
}
>
{queriedMultiDay ? (
<TrendChart points={aggregateDaily(data.date_from, data.date_to, data.daily)} />
) : (
<TrendChart points={aggregateHourly(data.hourly)} />
)}
</Card>
)}
<Table
rowKey="trace_id"
columns={columns}
dataSource={items}
loading={loading}
pagination={{
current: page,
pageSize: queriedLimit,
total: data?.total ?? 0,
showSizeChanger: false,
showTotal: (t) => `${t}`,
onChange: (p) => load(p, queriedLimit),
}}
size="small"
scroll={{ x: 1450 }}
/>
<UserRecordsDrawer<CouponDataRow>
open={recordsUser != null}
onClose={() => setRecordsUser(null)}
userId={recordsUser?.userId ?? null}
phone={recordsUser?.phone ?? null}
endpoint="/admin/api/coupon-data/user-records"
columns={RECORD_COLUMNS}
recordLabel="领券"
countLabel="领券次数"
rewardLabel="领到券张数"
rewardSuffix="张"
rewardOf={(r) => r.claimed_count ?? 0}
/>
</div>
);
}
+455
View File
@@ -0,0 +1,455 @@
'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, Modal, Row, Segmented, Space, Spin, Statistic, Table, Tooltip, message } from 'antd';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime, 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[];
}
// 群内微信用户(落地页授权拿到;nickname/headimgurl 仅 userinfo 授权后有)
interface WxUser {
openid: string;
nickname: string | null;
headimgurl: string | null;
first_seen: string;
visit_count: number;
copy_count: number;
}
// 某天该群按用户领券下钻(/day-users)
interface DayCoupon {
name: string;
count: number;
}
interface DayUser {
openid: string;
nickname: string | null;
headimgurl: string | null;
copy_count: number;
visit_count: number;
coupons: DayCoupon[];
}
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 [wxUsers, setWxUsers] = useState<WxUser[]>([]);
const loadWxUsers = useCallback(async () => {
try {
const r = await api.get<{ users: WxUser[] }>(`/admin/api/cps/groups/${id}/wx-users`);
setWxUsers(r.data.users || []);
} catch (e) {
message.error(errMsg(e, '用户列表加载失败'));
}
}, [id]);
useEffect(() => {
loadWxUsers();
}, [loadWxUsers]);
// 某天领券下钻弹窗
const [dayDetail, setDayDetail] = useState<{
open: boolean;
date: string;
loading: boolean;
users: DayUser[];
}>({ open: false, date: '', loading: false, users: [] });
const openDayDetail = useCallback(
async (date: string) => {
setDayDetail({ open: true, date, loading: true, users: [] });
try {
const r = await api.get<{ users: DayUser[] }>(
`/admin/api/cps/groups/${id}/day-users?date=${date}`,
);
setDayDetail((s) => ({ ...s, loading: false, users: r.data.users || [] }));
} catch (e) {
message.error(errMsg(e, '明细加载失败'));
setDayDetail((s) => ({ ...s, loading: false }));
}
},
[id],
);
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: 96, 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>),
},
{
title: '明细',
key: 'drill',
width: 64,
fixed: 'right',
render: (_, r) => (
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => openDayDetail(r.date)}>
</Button>
),
},
];
const wxColumns: ColumnsType<WxUser> = [
{
title: '用户',
key: 'user',
render: (_, u) => (
<Space>
{u.headimgurl ? (
<img
src={u.headimgurl}
alt=""
style={{ width: 32, height: 32, borderRadius: '50%', objectFit: 'cover' }}
/>
) : (
<span
style={{ width: 32, height: 32, borderRadius: '50%', background: '#f0f0f0', display: 'inline-block' }}
/>
)}
<span>{u.nickname || '(未授权昵称)'}</span>
</Space>
),
},
{
title: '领券次数',
dataIndex: 'copy_count',
width: 90,
sorter: (a, b) => a.copy_count - b.copy_count,
render: (v: number) => (v ? <b style={{ color: '#ff4d4f' }}>{v}</b> : 0),
},
{ title: '点击次数', dataIndex: 'visit_count', width: 90 },
{ title: '首次进入', dataIndex: 'first_seen', width: 160, render: (v: string) => formatUtcTime(v) },
];
const dayUserColumns: ColumnsType<DayUser> = [
{
title: '用户',
key: 'user',
render: (_, u) => (
<Space>
{u.headimgurl ? (
<img
src={u.headimgurl}
alt=""
style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }}
/>
) : (
<span
style={{ width: 28, height: 28, borderRadius: '50%', background: '#f0f0f0', display: 'inline-block' }}
/>
)}
<span>{u.nickname || '(未授权昵称)'}</span>
</Space>
),
},
{
title: '领券次数',
dataIndex: 'copy_count',
width: 100,
sorter: (a, b) => a.copy_count - b.copy_count,
render: (v: number) => (v ? <b style={{ color: '#ff4d4f' }}>{v}</b> : 0),
},
{
title: '点击次数',
dataIndex: 'visit_count',
width: 100,
render: (v: number, u) =>
u.coupons.length ? (
<Tooltip
title={
<div>
{u.coupons.map((c) => (
<div key={c.name}>
{c.name} ×{c.count}
</div>
))}
</div>
}
>
<span style={{ cursor: 'help', textDecoration: 'underline dotted' }}>{v}</span>
</Tooltip>
) : (
v
),
},
];
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: 1000 }}
/>
</Card>
)}
<Card title={`群内微信用户(${wxUsers.length})`} style={{ marginTop: 16 }}>
<div style={{ fontSize: 12, color: '#999', marginBottom: 8 }}>
= ;/
</div>
<Table<WxUser>
rowKey="openid"
size="small"
columns={wxColumns}
dataSource={wxUsers}
pagination={false}
scroll={{ x: 560 }}
locale={{ emptyText: '暂无授权用户' }}
/>
</Card>
<Modal
open={dayDetail.open}
title={`群「${data?.group_name ?? ''}${dayDetail.date} · 用户领券明细`}
footer={null}
width={640}
height={600}
onCancel={() => setDayDetail((s) => ({ ...s, open: false }))}
>
<div style={{ fontSize: 12, color: '#999', marginBottom: 8 }}>
(openid);/,
</div>
<Table<DayUser>
rowKey="openid"
size="small"
loading={dayDetail.loading}
columns={dayUserColumns}
dataSource={dayDetail.users}
pagination={false}
locale={{ emptyText: '当天无授权用户领券/点击' }}
/>
</Modal>
</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>
);
}
File diff suppressed because it is too large Load Diff
+253
View File
@@ -0,0 +1,253 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import type { SorterResult } from 'antd/es/table/interface';
import { ReloadOutlined } from '@ant-design/icons';
import { Button, Card, Col, Input, Row, Select, Space, Statistic, Table, Tag, Tooltip } from 'antd';
import { api } from '@/lib/api';
import { formatUtcTime, utcFromNow } from '@/lib/format';
import { usePagedList } from '@/lib/usePagedList';
import type { DeviceLivenessItem, DeviceLivenessStats } from '@/lib/types';
type SortField = 'status' | 'last_heartbeat_at' | 'created_at';
// 存活状态(后端按心跳阈值派生 display_state)→ 标签。operator 口径,不暴露内部 alive/notified/silent。
const STATUS_TAG: Record<string, { color: string; label: string }> = {
online: { color: 'green', label: '在线' },
offline: { color: 'red', label: '已掉线' },
never: { color: 'default', label: '未启用' },
};
/** 秒 → 人话时长(掉线时长用)。 */
function fmtDuration(sec: number | null): string {
if (sec == null) return '';
if (sec < 60) return `${sec}`;
const m = Math.floor(sec / 60);
if (m < 60) return `${m} 分钟`;
const h = Math.floor(m / 60);
if (h < 24) return `${h} 小时 ${m % 60}`;
const d = Math.floor(h / 24);
return `${d}${h % 24} 小时`;
}
// 暂无数据的字段统一占位(待客户端上报 / 待加字段)。
const pending = (hint: string) => (
<Tooltip title={hint}>
<Tag color="default"></Tag>
</Tooltip>
);
export default function DeviceLivenessPage() {
// 筛选草稿:点「查询」才应用
const [deviceId, setDeviceId] = useState('');
const [phone, setPhone] = useState('');
const [status, setStatus] = useState<string | undefined>();
const [applied, setApplied] = useState<Record<string, unknown>>({});
// 排序:默认 status = 掉线置顶;点「最近心跳」列头切换
const [sortBy, setSortBy] = useState<SortField>('status');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const filters = { ...applied, sort_by: sortBy, sort_order: sortOrder };
const { items, total, page, pageSize, loading, onChange, reload } = usePagedList<DeviceLivenessItem>(
'/admin/api/device-liveness',
filters,
);
// 顶部卡片统计(全局,不随筛选)
const [stats, setStats] = useState<DeviceLivenessStats | null>(null);
const loadStats = useCallback(() => {
api.get<DeviceLivenessStats>('/admin/api/device-liveness/stats').then((r) => setStats(r.data));
}, []);
useEffect(() => {
loadStats();
}, [loadStats]);
const search = () =>
setApplied({
status,
device_id: deviceId || undefined,
phone: phone || undefined,
});
const resetFilters = () => {
setDeviceId('');
setPhone('');
setStatus(undefined);
setSortBy('status');
setSortOrder('desc');
setApplied({});
};
const refreshAll = () => {
reload();
loadStats();
};
const onTableChange = (
_pagination: unknown,
_filters: unknown,
sorter: SorterResult<DeviceLivenessItem> | SorterResult<DeviceLivenessItem>[],
) => {
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('status');
setSortOrder('desc');
}
};
const sortOrderOf = (field: SortField) =>
sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null;
const cards = stats
? [
{ title: '总设备数', value: stats.total, color: undefined as string | undefined },
{ title: '在线', value: stats.online, color: '#3f8600' },
{ title: '已掉线', value: stats.offline, color: stats.offline > 0 ? '#cf1322' : undefined },
{ title: '未启用', value: stats.never, color: undefined },
]
: [];
// 列顺序对齐产品给的字段清单
const columns: ColumnsType<DeviceLivenessItem> = [
{ title: '设备 ID', dataIndex: 'device_id', fixed: 'left', width: 210, ellipsis: true },
{
title: '归属用户',
key: 'user',
width: 150,
render: (_: unknown, d: DeviceLivenessItem) => (
<div>
<div>{d.phone || `user#${d.user_id}`}</div>
{d.nickname && <div style={{ color: '#888', fontSize: 12 }}>{d.nickname}</div>}
</div>
),
},
{ title: '机型', dataIndex: 'device_model', width: 130, render: (v: string | null) => v || '-' },
{
title: '系统&系统版本',
key: 'os',
width: 130,
render: () => pending('设备未上报系统版本,需客户端在注册/心跳里带上 + 后端加列'),
},
{ title: 'APP 版本', dataIndex: 'app_version', width: 100, render: (v: string | null) => v || '-' },
{
title: '最近一次心跳',
dataIndex: 'last_heartbeat_at',
width: 150,
sorter: true,
sortOrder: sortOrderOf('last_heartbeat_at'),
render: (v: string | null) =>
v ? <Tooltip title={formatUtcTime(v, 'YYYY-MM-DD HH:mm:ss')}>{utcFromNow(v)}</Tooltip> : '-',
},
{
title: '存活状态',
dataIndex: 'display_state',
width: 130,
render: (s: string, d: DeviceLivenessItem) => {
const t = STATUS_TAG[s] ?? { color: 'default', label: s };
return (
<div>
<Tag color={t.color}>{t.label}</Tag>
{s === 'offline' && d.offline_seconds != null && (
<div style={{ color: '#cf1322', fontSize: 12 }}> {fmtDuration(d.offline_seconds)}</div>
)}
</div>
);
},
},
{
title: '首次无障碍开启时间',
dataIndex: 'first_protected_at',
width: 150,
render: (v: string | null) =>
v ? (
formatUtcTime(v)
) : (
<Tooltip title="迁移前已开过无障碍的老设备无此记录;之后首次开无障碍的设备会记录">
<span style={{ color: '#bbb' }}>-</span>
</Tooltip>
),
},
];
return (
<div>
<h2></h2>
{stats && (
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
{cards.map((c) => (
<Col key={c.title} xs={12} sm={6} md={6} xl={4}>
<Card>
<Statistic
title={c.title}
value={c.value}
valueStyle={c.color ? { color: c.color } : undefined}
/>
</Card>
</Col>
))}
</Row>
)}
<Space style={{ marginBottom: 16 }} wrap>
<Select
placeholder="存活状态"
value={status}
onChange={setStatus}
allowClear
style={{ width: 140 }}
options={[
{ value: 'offline', label: '已掉线' },
{ value: 'online', label: '在线' },
{ value: 'never', label: '未启用' },
]}
/>
<Input
placeholder="设备ID(包含)"
value={deviceId}
onChange={(e) => setDeviceId(e.target.value)}
onPressEnter={search}
allowClear
style={{ width: 200 }}
/>
<Input
placeholder="归属用户手机号(前缀)"
value={phone}
onChange={(e) => setPhone(e.target.value)}
onPressEnter={search}
allowClear
style={{ width: 180 }}
/>
<Button type="primary" onClick={search}>
</Button>
<Button onClick={resetFilters}></Button>
<Button icon={<ReloadOutlined />} onClick={refreshAll}>
</Button>
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={items}
loading={loading}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t} 台设备`,
onChange,
}}
scroll={{ x: 1300 }}
onChange={onTableChange}
/>
</div>
);
}
+102
View File
@@ -0,0 +1,102 @@
'use client';
import type { ColumnsType } from 'antd/es/table';
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';
// 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',
EMPTY,
);
const canReset = canDo(['operator']);
const resetDevice = async (d: DeviceOnboardingItem) => {
try {
await api.post(`/admin/api/onboarding/devices/${encodeURIComponent(d.device_id)}/reset`);
message.success('已重置:该设备所有账号下次打开 App 重走引导');
reload();
} catch (e) {
message.error(errMsg(e));
}
};
const resetAll = () => {
modal.confirm({
title: '确认全部重设新手引导?',
content:
'清空所有设备的引导完成记录,库里所有用户下次打开 App 都会重走一遍新手引导。此操作不可逆。',
okText: '确认全部重设',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await api.post('/admin/api/onboarding/reset-all', { confirm: true });
message.success('已全部重设:所有用户下次打开 App 会重走引导');
reload();
} catch (e) {
message.error(errMsg(e));
}
},
});
};
const columns: ColumnsType<DeviceOnboardingItem> = [
{ title: '设备(ANDROID_ID)', dataIndex: 'device_id' },
{ title: '走过引导的账号数', dataIndex: 'account_count', width: 160 },
{ title: '最近完成引导', dataIndex: 'last_completed_at', render: dt, width: 200 },
{
title: '操作',
key: 'op',
width: 120,
render: (_: unknown, d: DeviceOnboardingItem) =>
canReset ? (
<Popconfirm
title="重走引导?"
description="该设备上所有账号下次打开 App 会重看一次。"
onConfirm={() => resetDevice(d)}
okText="确认"
cancelText="取消"
>
<a></a>
</Popconfirm>
) : (
<span style={{ color: '#ccc' }}>-</span>
),
},
];
return (
<div>
<h2></h2>
<p style={{ color: '#888', marginBottom: 16 }}>
( ANDROID_ID ),
使 App ,
</p>
{canReset && (
<Space style={{ marginBottom: 16 }}>
<Button type="primary" danger onClick={resetAll}>
</Button>
</Space>
)}
<Table
rowKey="device_id"
columns={columns}
dataSource={items}
loading={loading}
pagination={false}
/>
</div>
);
}
+245
View File
@@ -0,0 +1,245 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import type { SorterResult } from 'antd/es/table/interface';
import { Button, DatePicker, Input, Space, Switch, Table, Tag, Typography } from 'antd';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import { formatUtcTime } from '@/lib/format';
import { usePagedList } from '@/lib/usePagedList';
const { Text } = Typography;
const { RangePicker } = DatePicker;
/** 一条埋点日志(对应后端 analytics_event,五维)。 */
type EventLog = {
id: number;
event: string;
device_id: string;
user_id: number | null;
session_id: string | null;
client_ts: number;
sent_at: number | null;
created_at: string;
page: string | null;
client_ip: string | null;
oem: string | null;
os: string | null;
model: string | null;
app_ver: string | null;
network: string | null;
channel: string | null;
props: Record<string, string> | null;
};
type SortField = 'id' | 'created_at';
// 端时间是 epoch ms;格式化成「月-日 时:分:秒.毫秒」便于核对节流/顺序
const fmtMs = (ms: number | null) => (ms ? dayjs(ms).format('MM-DD HH:mm:ss.SSS') : '-');
export default function EventLogsPage() {
// 筛选草稿:点「查询」才应用
const [event, setEvent] = useState('');
const [deviceId, setDeviceId] = useState('');
const [userId, setUserId] = useState('');
const [sessionId, setSessionId] = 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 [autoRefresh, setAutoRefresh] = useState(false);
const filters: Record<string, unknown> = { ...applied, sort_by: sortBy, sort_order: sortOrder };
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
usePagedList<EventLog>('/admin/api/event-logs', filters);
// 开自动刷新后每 5s 拉当前页;reload 闭包随渲染更新,用 ref 取最新,避免 interval captured 过期闭包。
const reloadRef = useRef(reload);
reloadRef.current = reload;
useEffect(() => {
if (!autoRefresh) return;
const t = setInterval(() => reloadRef.current(), 5000);
return () => clearInterval(t);
}, [autoRefresh]);
const search = () =>
setApplied({
event: event.trim() || undefined,
device_id: deviceId.trim() || undefined,
user_id: userId.trim() ? Number(userId.trim()) : undefined,
session_id: sessionId.trim() || undefined,
created_from: createdRange?.[0] ? createdRange[0].startOf('day').toISOString() : undefined,
created_to: createdRange?.[1] ? createdRange[1].endOf('day').toISOString() : undefined,
});
const resetFilters = () => {
setEvent('');
setDeviceId('');
setUserId('');
setSessionId('');
setCreatedRange(null);
setSortBy('id');
setSortOrder('desc');
setApplied({});
};
const onTableChange = (
_p: unknown,
_f: unknown,
sorter: SorterResult<EventLog> | SorterResult<EventLog>[],
) => {
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 columns: ColumnsType<EventLog> = [
{
title: '接收时间',
dataIndex: 'created_at',
width: 170,
sorter: true,
sortOrder: sortOrderOf('created_at'),
render: (v: string) => formatUtcTime(v),
},
{ title: 'ID', dataIndex: 'id', width: 70, sorter: true, sortOrder: sortOrderOf('id') },
{
title: '事件',
dataIndex: 'event',
width: 170,
render: (v: string) => <Tag color="blue">{v}</Tag>,
},
{
title: '页面',
dataIndex: 'page',
width: 100,
render: (v: string | null) => v || <Text type="secondary">-</Text>,
},
{
title: '用户',
dataIndex: 'user_id',
width: 80,
render: (v: number | null) => v ?? <Text type="secondary"></Text>,
},
{
title: '设备',
dataIndex: 'device_id',
width: 150,
ellipsis: true,
render: (v: string) => (
<Text style={{ fontSize: 12 }} copyable={{ text: v }}>
{v}
</Text>
),
},
{ title: '网络', dataIndex: 'network', width: 70, render: (v: string | null) => v || '-' },
{
title: '机型',
key: 'oem',
width: 130,
render: (_: unknown, r: EventLog) => (
<Text style={{ fontSize: 12 }}>{[r.oem, r.os].filter(Boolean).join(' / ') || '-'}</Text>
),
},
{ title: '版本', dataIndex: 'app_ver', width: 100, render: (v: string | null) => v || '-' },
];
return (
<div>
<h2></h2>
<Space style={{ marginBottom: 16 }} wrap>
<Input
placeholder="事件名 (如 video_play)"
value={event}
onChange={(e) => setEvent(e.target.value)}
onPressEnter={search}
allowClear
style={{ width: 180 }}
/>
<Input
placeholder="设备ID(前缀)"
value={deviceId}
onChange={(e) => setDeviceId(e.target.value)}
onPressEnter={search}
allowClear
style={{ width: 160 }}
/>
<Input
placeholder="用户ID"
value={userId}
onChange={(e) => setUserId(e.target.value.replace(/\D/g, ''))}
onPressEnter={search}
allowClear
style={{ width: 100 }}
/>
<Input
placeholder="会话ID"
value={sessionId}
onChange={(e) => setSessionId(e.target.value)}
onPressEnter={search}
allowClear
style={{ width: 160 }}
/>
<RangePicker
placeholder={['接收起', '接收止']}
value={createdRange}
onChange={(v) => setCreatedRange(v as [Dayjs, Dayjs] | null)}
allowClear
/>
<Button type="primary" onClick={search}>
</Button>
<Button onClick={resetFilters}></Button>
<Button onClick={reload}></Button>
<Space size={4}>
<Switch checked={autoRefresh} onChange={setAutoRefresh} />
<Text type="secondary">(5s)</Text>
</Space>
</Space>
<Table
rowKey="id"
size="small"
columns={columns}
dataSource={items}
loading={loading}
expandable={{
expandedRowRender: (r) => (
<Space direction="vertical" size={2} style={{ fontSize: 12 }}>
<Text type="secondary">
: {r.session_id || '-'} : {r.model || '-'} : {r.channel || '-'} IP:{' '}
{r.client_ip || '-'}
</Text>
<Text type="secondary">
: {fmtMs(r.client_ts)} : {fmtMs(r.sent_at)}
</Text>
<Text type="secondary">
: {r.props && Object.keys(r.props).length ? JSON.stringify(r.props) : '无'}
</Text>
</Space>
),
}}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: onPageChange,
}}
onChange={onTableChange}
/>
</div>
);
}
@@ -0,0 +1,380 @@
'use client';
// 反馈审核抽屉:展示本条反馈 + 该用户历史反馈(就近参考),待审核态可采纳(发金币)或拒绝(填用户可见原因)。
// 已审核态只读回显。对接同事 PR#66 接口:POST .../approve、POST .../reject(.../handle 已废弃)。
import { useEffect, useState } from 'react';
import {
App,
Button,
Drawer,
Form,
Image,
Input,
InputNumber,
Segmented,
Space,
Spin,
Tag,
Typography,
} from 'antd';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { formatWallTime } from '@/lib/format';
import { mediaUrl } from '@/lib/media';
import type { CursorPage, Feedback } from '@/lib/types';
const { Text, Paragraph } = Typography;
const REWARD_MAX = 10000; // 对齐后端 FEEDBACK_REWARD_MAX_COINS
interface Props {
feedback: Feedback | null;
open: boolean;
onClose: () => void;
onDone: () => void; // 审核成功后刷新列表
}
// 待审核(同事后端初始态 pending;兼容历史 new 数据)
const isPending = (s: string) => s === 'pending' || s === 'new';
const STATUS_META: Record<string, { label: string; color: string }> = {
pending: { label: '审核中', color: 'orange' },
new: { label: '待审核', color: 'orange' },
adopted: { label: '已采纳', color: 'green' },
rejected: { label: '未采纳', color: 'red' },
};
const statusTag = (s: string) => {
const m = STATUS_META[s] ?? { label: s, color: 'default' };
return <Tag color={m.color}>{m.label}</Tag>;
};
// 反馈类型:comparison=比价反馈 / profile(及旧数据)=普通反馈
const SOURCE_META: Record<string, { label: string; color: string }> = {
comparison: { label: '比价反馈', color: 'geekblue' },
profile: { label: '普通反馈', color: 'default' },
};
const sourceTag = (s: string) => {
const m = SOURCE_META[s] ?? { label: '普通反馈', color: 'default' };
return <Tag color={m.color}>{m.label}</Tag>;
};
function FeedbackImages({ images }: { images: string[] | null }) {
if (!images || !images.length) return <Text type="secondary"></Text>;
return (
<Image.PreviewGroup>
<Space size={4} wrap>
{images.map((src, i) => (
<Image
key={i}
src={mediaUrl(src)}
width={48}
height={48}
style={{ objectFit: 'cover', borderRadius: 4 }}
/>
))}
</Space>
</Image.PreviewGroup>
);
}
export default function FeedbackHandleDrawer({ feedback, open, onClose, onDone }: Props) {
const { message } = App.useApp();
const [form] = Form.useForm();
const [action, setAction] = useState<'approve' | 'reject'>('approve');
const [submitting, setSubmitting] = useState(false);
const [history, setHistory] = useState<Feedback[]>([]);
const [loadingHistory, setLoadingHistory] = useState(false);
const editable = feedback != null && isPending(feedback.status) && canDo(['operator']);
// 打开时:重置表单 + 拉该用户全部历史反馈(含本条,渲染时滤掉本条)
useEffect(() => {
if (!open || !feedback) return;
setAction('approve');
form.resetFields();
let alive = true;
setLoadingHistory(true);
api
.get<CursorPage<Feedback>>('/admin/api/feedbacks', {
params: { user_id: feedback.user_id, limit: 50, sort_by: 'id', sort_order: 'desc' },
})
.then((r) => {
if (alive) setHistory(r.data.items);
})
.catch(() => {
if (alive) setHistory([]);
})
.finally(() => {
if (alive) setLoadingHistory(false);
});
return () => {
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, feedback?.id]);
const submit = async () => {
if (!feedback) return;
const v = await form.validateFields();
setSubmitting(true);
try {
if (action === 'approve') {
await api.post(`/admin/api/feedbacks/${feedback.id}/approve`, {
reward_coins: v.reward_coins,
note: v.note?.trim() || null,
reply: v.reply?.trim() || null,
});
message.success(`已采纳,发放 ${v.reward_coins} 金币`);
} else {
await api.post(`/admin/api/feedbacks/${feedback.id}/reject`, {
reason: v.reason.trim(),
note: v.note?.trim() || null,
reply: v.reply?.trim() || null,
});
message.success('已拒绝');
}
onClose();
onDone();
} catch (e) {
message.error(errMsg(e));
} finally {
setSubmitting(false);
}
};
const others = feedback ? history.filter((h) => h.id !== feedback.id) : [];
return (
<Drawer
title={feedback ? (editable ? `审核反馈 #${feedback.id}` : `反馈详情 #${feedback.id}`) : ''}
width={560}
open={open}
onClose={onClose}
destroyOnHidden
extra={
editable ? (
<Button type="primary" danger={action === 'reject'} loading={submitting} onClick={submit}>
{action === 'approve' ? '采纳并发金币' : '确认拒绝'}
</Button>
) : null
}
>
{feedback && (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{/* 本条反馈 */}
<div>
<Text type="secondary">
{feedback.user_id} · {formatWallTime(feedback.created_at)}
</Text>
<div style={{ marginTop: 6 }}>
{sourceTag(feedback.source)}
{feedback.scene ? (
<Text type="secondary" style={{ marginLeft: 6 }}>
:{feedback.scene}
</Text>
) : null}
</div>
<Paragraph style={{ whiteSpace: 'pre-wrap', marginTop: 8, marginBottom: 8 }}>
{feedback.content || '-'}
</Paragraph>
<FeedbackImages images={feedback.images} />
</div>
{/* 已审核(或无权限):只读回显 */}
{!editable && (
<div
style={{
background:
feedback.status === 'adopted'
? '#f6ffed'
: feedback.status === 'rejected'
? '#fff1f0'
: '#fafafa',
border: `1px solid ${
feedback.status === 'adopted'
? '#b7eb8f'
: feedback.status === 'rejected'
? '#ffccc7'
: '#f0f0f0'
}`,
borderRadius: 8,
padding: 12,
}}
>
<div>
:{statusTag(feedback.status)}
{feedback.reviewed_at ? (
<Text type="secondary"> · {formatWallTime(feedback.reviewed_at)}</Text>
) : null}
</div>
{feedback.status === 'adopted' && (
<div style={{ marginTop: 6 }}>
:<b>{feedback.reward_coins ?? 0}</b>
</div>
)}
{feedback.status === 'rejected' && (
<div style={{ marginTop: 6 }}>
():
{feedback.reject_reason || <Text type="secondary"></Text>}
</div>
)}
<div style={{ marginTop: 6 }}>
():{feedback.review_note || <Text type="secondary"></Text>}
</div>
<div style={{ marginTop: 6 }}>
():
{feedback.admin_reply || <Text type="secondary"></Text>}
</div>
{isPending(feedback.status) && (
<div style={{ marginTop: 6 }}>
<Text type="secondary"> operator / super_admin </Text>
</div>
)}
</div>
)}
{/* 待审核:采纳 / 拒绝表单 */}
{editable && (
<div>
<Segmented
value={action}
onChange={(v) => setAction(v as 'approve' | 'reject')}
options={[
{ label: '采纳(发金币)', value: 'approve' },
{ label: '拒绝', value: 'reject' },
]}
style={{ marginBottom: 16 }}
/>
{/* preserve=false:切换采纳/拒绝时卸载的字段从 store 移除,validateFields 不误校验另一侧 */}
<Form form={form} layout="vertical" preserve={false}>
{action === 'approve' ? (
<>
<Form.Item
name="reward_coins"
label={`奖励金币(必填,1 ~ ${REWARD_MAX})`}
rules={[
{ required: true, message: '请输入奖励金币' },
{ type: 'number', min: 1, max: REWARD_MAX, message: `1 ~ ${REWARD_MAX}` },
]}
>
<InputNumber
style={{ width: '100%' }}
min={1}
max={REWARD_MAX}
placeholder="采纳后发放给用户的金币"
/>
</Form.Item>
<Form.Item
name="note"
label="采纳要点 / 审核备注(选填,内部)"
rules={[{ max: 256, message: '最多 256 字' }]}
>
<Input.TextArea rows={3} maxLength={256} showCount placeholder="如:建议已采纳,将在下个版本上线" />
</Form.Item>
<Form.Item
name="reply"
label="给用户的回复(选填,用户端可见)"
rules={[{ max: 256, message: '最多 256 字' }]}
>
<Input.TextArea
rows={2}
maxLength={256}
showCount
placeholder="将展示在用户的「我的反馈」里,如:感谢反馈,已收到~"
/>
</Form.Item>
</>
) : (
<>
<Form.Item
name="reason"
label="未采纳原因(必填,用户端可见)"
rules={[
{ required: true, message: '请填写未采纳原因' },
{ max: 256, message: '最多 256 字' },
]}
>
<Input.TextArea rows={3} maxLength={256} showCount placeholder="向用户说明为何未采纳" />
</Form.Item>
<Form.Item
name="note"
label="内部备注(选填,用户不可见)"
rules={[{ max: 256, message: '最多 256 字' }]}
>
<Input.TextArea rows={2} maxLength={256} showCount placeholder="运营内部备注" />
</Form.Item>
<Form.Item
name="reply"
label="给用户的回复(选填,用户端可见)"
rules={[{ max: 256, message: '最多 256 字' }]}
>
<Input.TextArea
rows={2}
maxLength={256}
showCount
placeholder="除未采纳原因外,想额外对用户说的话(选填)"
/>
</Form.Item>
</>
)}
</Form>
</div>
)}
{/* 该用户历史反馈 */}
<div>
<Text strong>{others.length ? `${others.length}` : ''}</Text>
{loadingHistory ? (
<Spin style={{ display: 'block', margin: '16px 0' }} />
) : others.length === 0 ? (
<div style={{ marginTop: 8 }}>
<Text type="secondary"></Text>
</div>
) : (
<Space direction="vertical" size={8} style={{ width: '100%', marginTop: 8 }}>
{others.map((h) => (
<div
key={h.id}
style={{ border: '1px solid #f0f0f0', borderRadius: 8, padding: 10 }}
>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Text type="secondary">
#{h.id} · {formatWallTime(h.created_at)}
</Text>
<Space size={4}>
{sourceTag(h.source)}
{statusTag(h.status)}
</Space>
</div>
<Paragraph
style={{ whiteSpace: 'pre-wrap', margin: '6px 0 0' }}
ellipsis={{ rows: 2, expandable: true, symbol: '展开' }}
>
{h.content || '-'}
</Paragraph>
{h.status === 'adopted' && (
<Text type="secondary" style={{ fontSize: 12 }}>
· {h.reward_coins ?? 0}
{h.review_note ? ` · ${h.review_note}` : ''}
</Text>
)}
{h.status === 'rejected' && (
<Text type="secondary" style={{ fontSize: 12 }}>
:{h.reject_reason || '-'}
</Text>
)}
{h.admin_reply ? (
<div style={{ fontSize: 12, marginTop: 2 }}>:{h.admin_reply}</div>
) : null}
</div>
))}
</Space>
)}
</div>
</Space>
)}
</Drawer>
);
}
+413 -46
View File
@@ -1,84 +1,451 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import { Button, Select, Space, Table, Tag, message } from 'antd';
import { api, errMsg } from '@/lib/api';
import type { SorterResult } from 'antd/es/table/interface';
import {
Button,
Card,
DatePicker,
Image,
Input,
Select,
Space,
Statistic,
Table,
Tag,
Typography,
} from 'antd';
import type { Dayjs } from 'dayjs';
import { api } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { useCursorList } from '@/lib/useCursorList';
import type { Feedback } from '@/lib/types';
import { formatWallTime } from '@/lib/format';
import { usePagedList } from '@/lib/usePagedList';
import type { Feedback, FeedbackSummary } from '@/lib/types';
import FeedbackHandleDrawer from './FeedbackHandleDrawer';
import UserRecordsDrawer from '@/components/UserRecordsDrawer';
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';
// 待审核(同事后端初始态 pending;兼容历史 new 数据)
const isPending = (s: string) => s === 'pending' || s === 'new';
const STATUS_META: Record<string, { label: string; color: string }> = {
pending: { label: '审核中', color: 'orange' },
new: { label: '待审核', color: 'orange' },
adopted: { label: '已采纳', color: 'green' },
rejected: { label: '未采纳', color: 'red' },
};
const statusTag = (s: string) => {
const m = STATUS_META[s] ?? { label: s, color: 'default' };
return <Tag color={m.color}>{m.label}</Tag>;
};
// 反馈类型:comparison=比价反馈(比价结果页入口) / profile=普通反馈(「我的」页入口)。
// 旧数据与未识别来源按普通反馈展示。
const SOURCE_META: Record<string, { label: string; color: string }> = {
comparison: { label: '比价反馈', color: 'geekblue' },
profile: { label: '普通反馈', color: 'default' },
};
const SOURCE_OPTIONS = [
{ value: 'comparison', label: '比价反馈' },
{ value: 'profile', label: '普通反馈' },
];
// 反馈类型标签 +(比价反馈时)问题场景副标题
const renderSource = (f: Feedback) => {
const m = SOURCE_META[f.source] ?? { label: '普通反馈', color: 'default' };
return (
<Space direction="vertical" size={0}>
<Tag color={m.color}>{m.label}</Tag>
{f.scene ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{f.scene}
</Text>
) : null}
</Space>
);
};
// 用户可见的运营回复留言(采纳/未采纳都可能有)
const replyLine = (f: Feedback) =>
f.admin_reply ? (
<Text style={{ fontSize: 12 }} ellipsis>
:{f.admin_reply}
</Text>
) : null;
// 审核结果展示(采纳=金币+批注 / 未采纳=原因 / 回复留言 / 待审核=-),主表与「该用户全部反馈」抽屉共用
const renderReview = (f: Feedback) => {
if (f.status === 'adopted') {
return (
<Space direction="vertical" size={0}>
<Tag color="gold">+{f.reward_coins ?? 0} </Tag>
{f.review_note ? (
<Text type="secondary" style={{ fontSize: 12 }} ellipsis>
{f.review_note}
</Text>
) : null}
{replyLine(f)}
</Space>
);
}
if (f.status === 'rejected') {
return (
<Space direction="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 12 }} ellipsis>
:{f.reject_reason || '-'}
</Text>
{replyLine(f)}
</Space>
);
}
return <Text type="secondary">-</Text>;
};
// 机型 / OS版本(提交反馈时的端环境;改版前历史反馈无 → -)
const renderDeviceOs = (f: Feedback) => {
const os = [f.android_version ? `Android ${f.android_version}` : null, f.rom_name]
.filter(Boolean)
.join(' · ');
if (!f.device_model && !os) return <Text type="secondary">-</Text>;
return (
<Space direction="vertical" size={0}>
<Text>{f.device_model || '-'}</Text>
{os ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{os}
</Text>
) : null}
</Space>
);
};
// 点手机号弹出的「该用户全部反馈」抽屉列(精简版:不含操作/手机号,避免抽屉里再套抽屉)
const RECORD_COLUMNS: ColumnsType<Feedback> = [
{ title: '提交时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatWallTime(v) },
{ title: '状态', dataIndex: 'status', width: 80, render: statusTag },
{ title: '反馈类型', key: 'source', width: 100, render: (_: unknown, f: Feedback) => renderSource(f) },
{
title: '内容',
dataIndex: 'content',
render: (v: string) =>
v ? (
<Paragraph
style={{ marginBottom: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
ellipsis={{ rows: 2, expandable: true, symbol: '展开' }}
>
{v}
</Paragraph>
) : (
<Text type="secondary">-</Text>
),
},
{ title: '审核结果', key: 'review', width: 160, render: (_: unknown, f: Feedback) => renderReview(f) },
];
export default function FeedbacksPage() {
// 筛选草稿:点「查询」才应用(避免输入即刷新)
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 canHandle = canDo(['operator']);
const [source, setSource] = useState<string | undefined>();
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 handle = async (f: Feedback) => {
const filters: Record<string, unknown> = { ...applied, sort_by: sortBy, sort_order: sortOrder };
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
usePagedList<Feedback>('/admin/api/feedbacks', filters);
const canReview = canDo(['operator']);
const [summary, setSummary] = useState<FeedbackSummary | null>(null);
const loadSummary = async () => {
try {
await api.post(`/admin/api/feedbacks/${f.id}/handle`);
message.success('已标记处理');
reload();
} catch (e) {
message.error(errMsg(e));
const { data } = await api.get<FeedbackSummary>('/admin/api/feedbacks/summary');
setSummary(data);
} catch {
/* 统计失败不阻塞列表 */
}
};
useEffect(() => {
loadSummary();
}, []);
// 审核/查看抽屉(采纳发金币 / 拒绝填原因 + 看该用户历史反馈)
const [drawerFb, setDrawerFb] = useState<Feedback | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const openDrawer = (f: Feedback) => {
setDrawerFb(f);
setDrawerOpen(true);
};
// 点手机号:看该用户全部反馈
const [recordsUser, setRecordsUser] = useState<{ userId: number; phone: string | null } | null>(null);
const openUserRecords = (f: Feedback) => setRecordsUser({ userId: f.user_id, phone: f.phone });
const search = () =>
setApplied({
status,
source,
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);
setSource(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 columns: ColumnsType<Feedback> = [
{ title: 'ID', dataIndex: 'id', width: 70 },
{ title: '用户', dataIndex: 'user_id', width: 80 },
{ title: '内容', dataIndex: 'content' },
{ title: '联系方式', dataIndex: 'contact', width: 140 },
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s: string) => <Tag color={s === 'new' ? 'orange' : 'green'}>{s}</Tag>,
title: '用户ID',
dataIndex: 'user_id',
width: 80,
render: (v: number) => <Text strong>#{v}</Text>,
},
{
title: '手机号',
dataIndex: 'phone',
width: 130,
render: (_: unknown, f: Feedback) =>
f.phone ? (
<a onClick={() => openUserRecords(f)}>{f.phone}</a>
) : (
<Text type="secondary"></Text>
),
},
{
title: '用户昵称',
dataIndex: 'nickname',
width: 120,
render: (v: string | null) => v || <Text type="secondary"></Text>,
},
{
title: '反馈类型',
key: 'source',
width: 110,
render: (_: unknown, f: Feedback) => renderSource(f),
},
{
title: '提交版本号',
dataIndex: 'app_version',
width: 100,
render: (v: string | null) => v || <Text type="secondary">-</Text>,
},
{
title: '机型 / OS版本',
key: 'device',
width: 150,
render: (_: unknown, f: Feedback) => renderDeviceOs(f),
},
{
title: '内容',
dataIndex: 'content',
width: 340,
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: 160,
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: 'status', width: 90, render: statusTag },
{
title: '审核结果',
key: 'review',
width: 170,
render: (_: unknown, f: Feedback) => renderReview(f),
},
{
title: '提交时间',
dataIndex: 'created_at',
width: 170,
sorter: true,
sortOrder: sortOrderOf('created_at'),
render: (v: string) => formatWallTime(v),
},
{ title: '时间', dataIndex: 'created_at', render: dt, width: 180 },
{
title: '操作',
key: 'op',
width: 100,
width: 90,
render: (_: unknown, f: Feedback) =>
canHandle && f.status === 'new' ? (
<a onClick={() => handle(f)}></a>
canReview && isPending(f.status) ? (
<a onClick={() => openDrawer(f)}></a>
) : (
<span style={{ color: '#ccc' }}>-</span>
<a onClick={() => openDrawer(f)}></a>
),
},
];
return (
<div>
<h2></h2>
<Space style={{ marginBottom: 16 }}>
<h2></h2>
<div style={{ display: 'flex', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
<Card size="small" style={{ minWidth: 120 }}>
<Statistic title="待审核" value={summary?.pending ?? 0} valueStyle={{ color: '#fa8c16' }} />
</Card>
<Card size="small" style={{ minWidth: 120 }}>
<Statistic title="已采纳" value={summary?.adopted ?? 0} valueStyle={{ color: '#52c41a' }} />
</Card>
<Card size="small" style={{ minWidth: 120 }}>
<Statistic title="未采纳" value={summary?.rejected ?? 0} valueStyle={{ color: '#8c8c8c' }} />
</Card>
<Card size="small" style={{ minWidth: 120 }}>
<Statistic title="合计" value={summary?.total ?? 0} />
</Card>
</div>
<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: '已处理' },
{ value: 'pending', label: '审核中' },
{ value: 'adopted', label: '已采纳' },
{ value: 'rejected', 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 ? '加载更多' : '没有更多了'}
<Select
placeholder="反馈类型"
value={source}
onChange={setSource}
allowClear
style={{ width: 130 }}
options={SOURCE_OPTIONS}
/>
<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>
<Table
rowKey="id"
columns={columns}
dataSource={items}
loading={loading}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t} 条反馈`,
onChange: onPageChange,
}}
onChange={onTableChange}
scroll={{ x: 1720 }}
/>
<FeedbackHandleDrawer
feedback={drawerFb}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
onDone={() => { reload(); loadSummary(); }}
/>
<UserRecordsDrawer<Feedback>
open={recordsUser != null}
onClose={() => setRecordsUser(null)}
userId={recordsUser?.userId ?? null}
phone={recordsUser?.phone ?? null}
endpoint="/admin/api/feedbacks"
columns={RECORD_COLUMNS}
recordLabel="反馈"
countLabel="提交总计"
rewardLabel="提交奖励总计"
rewardOf={(f) => (f.status === 'adopted' ? (f.reward_coins ?? 0) : 0)}
/>
</div>
);
}
+271 -26
View File
@@ -3,54 +3,130 @@
import { useEffect, useState } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import {
BarChartOutlined,
DashboardOutlined,
DatabaseOutlined,
FileSearchOutlined,
FlagOutlined,
GiftOutlined,
HeartOutlined,
LogoutOutlined,
MessageOutlined,
MoneyCollectOutlined,
NotificationOutlined,
ProfileOutlined,
SettingOutlined,
ShareAltOutlined,
TeamOutlined,
UserOutlined,
} from '@ant-design/icons';
import { Avatar, Dropdown, Layout, Menu } from 'antd';
import { clearAuth, getAdmin, getToken } from '@/lib/auth';
import { Avatar, Dropdown, Layout, Tooltip } from 'antd';
import { api } from '@/lib/api';
import { clearAuth, getAdmin, getToken, setAuth } from '@/lib/auth';
import type { AdminInfo } from '@/lib/types';
const { Sider, Header, Content } = Layout;
const MENU = [
{ key: '/dashboard', icon: <DashboardOutlined />, label: '数据大盘' },
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
{ key: '/withdraws', icon: <MoneyCollectOutlined />, label: '提现管理' },
{ key: '/feedbacks', icon: <MessageOutlined />, label: '反馈工单' },
{ key: '/config', icon: <SettingOutlined />, label: '系统配置' },
{ key: '/admins', icon: <TeamOutlined />, label: '管理员', superOnly: true },
type NavItem = {
key: string;
icon: React.ReactNode;
label: string;
};
type NavGroup =
| (NavItem & { children?: never })
| {
key: string;
icon: React.ReactNode;
label: string;
children: NavItem[];
};
const hasChildren = (group: NavGroup): group is NavGroup & { children: NavItem[] } =>
Array.isArray(group.children);
const NAV_GROUPS: NavGroup[] = [
{
key: 'dashboard',
icon: <DashboardOutlined />,
label: '看板',
children: [
{ key: '/dashboard', icon: <DashboardOutlined />, label: '数据大盘' },
{ key: '/coupon-data', icon: <GiftOutlined />, label: '领券数据' },
{ key: '/ad-revenue-report', icon: <BarChartOutlined />, label: '广告收益' },
{ key: '/comparison-records', icon: <ProfileOutlined />, label: '比价记录' },
{ key: '/cps', icon: <ShareAltOutlined />, label: 'CPS收益' },
{ key: '/device-liveness', icon: <HeartOutlined />, label: '设备存活' },
],
},
{
key: 'reward-review',
icon: <MoneyCollectOutlined />,
label: '奖励审核',
children: [
{ key: '/withdraws', icon: <MoneyCollectOutlined />, label: '提现审核' },
{ key: '/price-reports', icon: <FlagOutlined />, label: '低价审核' },
{ key: '/feedbacks', icon: <MessageOutlined />, label: '用户反馈' },
],
},
{
key: 'data-config',
icon: <SettingOutlined />,
label: '数据配置',
children: [
{ key: '/config', icon: <SettingOutlined />, label: '系统配置' },
{ key: '/ad-revenue', icon: <NotificationOutlined />, label: '广告配置' },
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
],
},
{ key: '/admins', icon: <TeamOutlined />, label: '权限管理' },
{ key: '/event-logs', icon: <DatabaseOutlined />, label: '埋点日志' },
{ key: '/audit-logs', icon: <FileSearchOutlined />, label: '审计日志' },
];
// 导航项 key(/dashboard)→ 权限页 key(dashboard),与后端 permissions.py 目录对齐
const permOf = (key: string) => key.replace(/^\//, '');
export default function MainLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [admin, setAdmin] = useState<AdminInfo | null>(null);
const [collapsed, setCollapsed] = useState(false);
useEffect(() => {
if (!getToken()) {
const token = getToken();
if (!token) {
router.replace('/login');
return;
}
setAdmin(getAdmin());
setAdmin(getAdmin()); // 先用本地存的(快、不闪)
// 再拉 /me 刷新有效可见页:超管改过该角色权限也能及时反映到左侧导航
api
.get<AdminInfo>('/admin/api/auth/me')
.then(({ data }) => {
setAdmin(data);
setAuth(token, data);
})
.catch(() => {});
}, [router]);
if (!admin) return null; // 守卫期间不闪烁内容
const items = MENU.filter((m) => !m.superOnly || admin.role === 'super_admin').map((m) => ({
key: m.key,
icon: m.icon,
label: m.label,
}));
// 选中态:取路径一级(/users/123 → /users)
// 选中态:取路径一级(/users/123 -> /users)
const selectedKey = '/' + (pathname.split('/')[1] || 'dashboard');
// 叶子导航项按当前角色的有效可见页过滤:super_admin 恒可见;缺 pages 信息(旧登录)兜底全显示不锁死。
const isSuper = admin.role === 'super_admin';
const canShowLeaf = (item: { key: string }) =>
isSuper || !admin.pages || admin.pages.includes(permOf(item.key));
const visibleGroups = NAV_GROUPS
.map((group) => {
if (!hasChildren(group)) return group;
return { ...group, children: group.children.filter(canShowLeaf) };
})
.filter((group) => (hasChildren(group) ? group.children.length > 0 : canShowLeaf(group)));
const flatNavItems = visibleGroups.flatMap((group) =>
hasChildren(group) ? group.children : [group],
);
const logout = () => {
clearAuth();
@@ -59,7 +135,15 @@ export default function MainLayout({ children }: { children: React.ReactNode })
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider theme="dark" breakpoint="lg" collapsible>
<Sider
theme="dark"
breakpoint="lg"
collapsible
collapsed={collapsed}
collapsedWidth={72}
width={220}
onCollapse={setCollapsed}
>
<div
style={{
height: 48,
@@ -72,13 +156,61 @@ export default function MainLayout({ children }: { children: React.ReactNode })
>
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[selectedKey]}
items={items}
onClick={({ key }) => router.push(key)}
/>
<nav className={`side-nav${collapsed ? ' side-nav-collapsed' : ''}`} aria-label="后台导航">
{collapsed
? flatNavItems.map((item) => (
<Tooltip key={item.key} title={item.label} placement="right">
<button
type="button"
className={`nav-icon-button${selectedKey === item.key ? ' is-selected' : ''}`}
onClick={() => router.push(item.key)}
aria-label={item.label}
>
{item.icon}
</button>
</Tooltip>
))
: visibleGroups.map((group) => {
const isGroup = hasChildren(group);
const groupSelected = isGroup
? group.children.some((child) => child.key === selectedKey)
: selectedKey === group.key;
if (!isGroup) {
return (
<button
key={group.key}
type="button"
className={`nav-primary nav-direct${groupSelected ? ' is-selected' : ''}`}
onClick={() => router.push(group.key)}
>
<span className="nav-primary-icon">{group.icon}</span>
<span>{group.label}</span>
</button>
);
}
return (
<section key={group.key} className={`nav-group${groupSelected ? ' is-active' : ''}`}>
<div className="nav-primary">
<span className="nav-primary-icon">{group.icon}</span>
<span>{group.label}</span>
</div>
<div className="nav-children">
{group.children.map((child) => (
<button
key={child.key}
type="button"
className={`nav-child${selectedKey === child.key ? ' is-selected' : ''}`}
onClick={() => router.push(child.key)}
>
<span className="nav-child-icon">{child.icon}</span>
<span>{child.label}</span>
</button>
))}
</div>
</section>
);
})}
</nav>
</Sider>
<Layout>
<Header
@@ -104,6 +236,119 @@ export default function MainLayout({ children }: { children: React.ReactNode })
</Header>
<Content style={{ margin: 24 }}>{children}</Content>
</Layout>
<style jsx global>{`
.side-nav {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0 10px 14px;
}
.nav-group {
padding: 6px 0 8px;
}
.nav-group + .nav-group,
.nav-group + .nav-direct,
.nav-direct + .nav-direct {
margin-top: 4px;
}
.nav-primary {
display: flex;
align-items: center;
gap: 10px;
min-height: 36px;
padding: 0 12px;
color: rgba(255, 255, 255, 0.88);
font-size: 14px;
font-weight: 600;
letter-spacing: 0;
}
.nav-primary-icon,
.nav-child-icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.72);
font-size: 16px;
}
.nav-direct,
.nav-child,
.nav-icon-button {
border: 0;
cursor: pointer;
font: inherit;
text-align: left;
}
.nav-direct {
width: 100%;
border-radius: 8px;
background: transparent;
}
.nav-direct:hover,
.nav-direct.is-selected {
background: #1677ff;
color: #fff;
}
.nav-direct:hover .nav-primary-icon,
.nav-direct.is-selected .nav-primary-icon {
color: #fff;
}
.nav-children {
display: flex;
flex-direction: column;
gap: 2px;
margin-left: 34px;
margin-top: -2px;
padding-right: 2px;
}
.nav-child {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 34px;
border-radius: 8px;
background: transparent;
color: rgba(255, 255, 255, 0.72);
font-size: 13px;
padding: 0 10px;
}
.nav-child:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.nav-child.is-selected {
background: #1677ff;
color: #fff;
font-weight: 600;
}
.nav-child.is-selected .nav-child-icon {
color: #fff;
}
.nav-group.is-active .nav-primary {
color: #fff;
}
.side-nav-collapsed {
align-items: center;
gap: 6px;
padding: 0 8px 14px;
}
.nav-icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 8px;
background: transparent;
color: rgba(255, 255, 255, 0.72);
font-size: 17px;
}
.nav-icon-button:hover,
.nav-icon-button.is-selected {
background: #1677ff;
color: #fff;
}
`}</style>
</Layout>
);
}
+459
View File
@@ -0,0 +1,459 @@
'use client';
import { useEffect, useState } from 'react';
import {
App,
Button,
Card,
Image,
Input,
Modal,
Space,
Statistic,
Table,
Tabs,
Tag,
Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { SorterResult } from 'antd/es/table/interface';
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { mediaUrl } from '@/lib/media';
import { usePagedList } from '@/lib/usePagedList';
import type { PriceReport, PriceReportSummary } from '@/lib/types';
import UserRecordsDrawer from '@/components/UserRecordsDrawer';
const { Text } = Typography;
// 后端 created_at/reviewed_at 存的是北京 wall-clock(naive,同 savings/comparison),
// 直接本地解析、不加 Z(否则会差 8h)。
const dt = (v: string | null) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-');
const yuan = (c: number | null) => (c == null ? '-' : `¥${(c / 100).toFixed(2)}`);
// 截图 URL 解析见 @/lib/media(dev 指向 :8770,生产走 nginx /media 同域反代)。
const STATUS_COLOR: Record<string, string> = {
pending: 'gold',
approved: 'green',
rejected: 'default',
};
const STATUS_LABEL: Record<string, string> = {
pending: '待审核',
approved: '已通过',
rejected: '已拒绝',
};
const STATUS_TABS = [
{ key: 'pending', label: '待审核' },
{ key: 'approved', label: '已通过' },
{ key: 'rejected', label: '已拒绝' },
{ key: 'all', label: '全部' },
];
const REJECT_TEMPLATES = ['截图不清晰', '商品不匹配', '价格不属实', '平台不支持', '重复上报', '其他原因'];
function statusTag(status: string) {
return <Tag color={STATUS_COLOR[status]}>{STATUS_LABEL[status] || status}</Tag>;
}
type SortField = 'id' | 'created_at';
// 门店/菜品、原价→上报价、审核结果:主表与「该用户全部上报」抽屉共用
const renderStore = (r: PriceReport) => (
<Space direction="vertical" size={0}>
<Text>{r.store_name || '-'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{r.dish_summary || '-'}
</Text>
</Space>
);
const renderPrice = (r: PriceReport) => (
<Space direction="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 12 }}>
{r.original_platform_name || '原'}: {yuan(r.original_price_cents)}
</Text>
<Text strong style={{ color: '#fa541c' }}>
{r.reported_platform_name}: {yuan(r.reported_price_cents)}
</Text>
</Space>
);
// 只读审核结果(「审核结果」列 + 抽屉共用,不带操作按钮;待审核在「状态」列已显示,这里给 -)
const renderResult = (r: PriceReport) => {
if (r.status === 'approved') return <Text type="success"> {r.reward_coins ?? 0} </Text>;
if (r.status === 'rejected') return <Text type="secondary">: {r.reject_reason || '-'}</Text>;
return <Text type="secondary">-</Text>;
};
// Trace:与「比价记录」页一致 —— 有 trace_url(本地比价=localhost debug viewer,线上=公网)
// 就给可点的「trace」链接,否则 -。
const traceLink = (r: PriceReport) =>
r.trace_url ? (
<a href={r.trace_url} target="_blank" rel="noreferrer">
trace
</a>
) : (
<Text type="secondary">-</Text>
);
// 机型 / OS版本(取自关联比价记录;无关联记录时为 -)
const renderDeviceOs = (r: PriceReport) => {
const os = [r.android_version ? `Android ${r.android_version}` : null, r.rom_name]
.filter(Boolean)
.join(' · ');
if (!r.device_model && !os) return <Text type="secondary">-</Text>;
return (
<Space direction="vertical" size={0}>
<Text>{r.device_model || '-'}</Text>
{os ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{os}
</Text>
) : null}
</Space>
);
};
// 点手机号弹出的「该用户全部低价上报」抽屉列(精简版:不含操作/手机号)
const RECORD_COLUMNS: ColumnsType<PriceReport> = [
{ title: '提交时间', dataIndex: 'created_at', width: 150, render: (v: string) => dt(v) },
{ title: '门店 / 菜品', key: 'store', width: 180, render: (_: unknown, r: PriceReport) => renderStore(r) },
{ title: '原价 → 上报价', key: 'price', width: 200, render: (_: unknown, r: PriceReport) => renderPrice(r) },
{ title: '状态', dataIndex: 'status', width: 90, render: statusTag },
{ title: 'Trace', key: 'trace', width: 80, render: (_: unknown, r: PriceReport) => traceLink(r) },
{ title: '结果', key: 'result', width: 150, render: (_: unknown, r: PriceReport) => renderResult(r) },
];
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);
const [rejectReason, setRejectReason] = useState('');
// 提交时间列服务端排序(默认按提交时间倒序,最新在前)
const [sortBy, setSortBy] = useState<SortField>('created_at');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// 点手机号:看该用户全部低价上报
const [recordsUser, setRecordsUser] = useState<{ userId: number; phone: string | null } | null>(null);
const openUserRecords = (r: PriceReport) => setRecordsUser({ userId: r.user_id, phone: r.phone });
const filters: Record<string, unknown> = { sort_by: sortBy, sort_order: sortOrder };
if (activeStatus !== 'all') filters.status = activeStatus;
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
usePagedList<PriceReport>('/admin/api/price-reports', filters);
const sortOrderOf = (field: SortField) =>
sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null;
const onTableChange = (
_pagination: unknown,
_filters: unknown,
sorter: SorterResult<PriceReport> | SorterResult<PriceReport>[],
) => {
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('created_at');
setSortOrder('desc');
}
};
const canReview = canDo(['operator']);
const loadSummary = async () => {
try {
const { data } = await api.get<PriceReportSummary>('/admin/api/price-reports/summary');
setSummary(data);
} catch {
/* 统计失败不阻塞列表 */
}
};
useEffect(() => {
loadSummary();
}, []);
const refreshAfterChange = () => {
reload();
loadSummary();
};
const approve = (r: PriceReport) => {
modal.confirm({
title: '确认通过该上报?',
icon: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
content: (
<div>
<div> #{r.user_id}</div>
<div>: {r.store_name || '-'}</div>
<div>
: {yuan(r.reported_price_cents)}{r.reported_platform_name}
</div>
<div style={{ marginTop: 8, color: '#8c8c8c' }}>
1000
</div>
</div>
),
okText: '通过并发金币',
onOk: async () => {
try {
await api.post(`/admin/api/price-reports/${r.id}/approve`);
message.success('已通过,已发放 1000 金币');
refreshAfterChange();
} catch (e) {
message.error(errMsg(e));
}
},
});
};
const confirmReject = async () => {
if (!rejecting) return;
const reason = rejectReason.trim();
if (!reason) {
message.warning('请填写拒绝理由');
return;
}
try {
await api.post(`/admin/api/price-reports/${rejecting.id}/reject`, { reason });
message.success('已拒绝');
setRejecting(null);
setRejectReason('');
refreshAfterChange();
} catch (e) {
message.error(errMsg(e));
}
};
const columns: ColumnsType<PriceReport> = [
{
title: '用户ID',
dataIndex: 'user_id',
width: 80,
render: (v: number) => <Text strong>#{v}</Text>,
},
{
title: '手机号',
dataIndex: 'phone',
width: 130,
render: (_: unknown, r: PriceReport) =>
r.phone ? (
<a onClick={() => openUserRecords(r)}>{r.phone}</a>
) : (
<Text type="secondary"></Text>
),
},
{
title: '用户昵称',
dataIndex: 'nickname',
width: 120,
render: (v: string | null) => v || <Text type="secondary"></Text>,
},
{
title: '提交版本号',
dataIndex: 'app_version',
width: 100,
render: (v: string | null) => v || <Text type="secondary">-</Text>,
},
{
title: '机型 / OS版本',
key: 'device',
width: 150,
render: (_: unknown, r: PriceReport) => renderDeviceOs(r),
},
{
title: '门店 / 菜品',
key: 'store',
width: 200,
render: (_: unknown, r: PriceReport) => renderStore(r),
},
{
title: '原最低价 → 上报价',
key: 'price',
width: 220,
render: (_: unknown, r: PriceReport) => renderPrice(r),
},
{
title: '截图证明',
dataIndex: 'images',
width: 180,
render: (imgs: string[]) =>
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: 'status', width: 90, render: statusTag },
{
title: '审核结果',
key: 'review',
width: 160,
render: (_: unknown, r: PriceReport) => renderResult(r),
},
{
title: '提交时间',
dataIndex: 'created_at',
width: 150,
sorter: true,
sortOrder: sortOrderOf('created_at'),
render: (v: string) => dt(v),
},
{
title: 'Trace',
key: 'trace',
width: 80,
render: (_: unknown, r: PriceReport) => traceLink(r),
},
{
title: '操作',
key: 'op',
fixed: 'right',
width: 150,
render: (_: unknown, r: PriceReport) => {
if (r.status !== 'pending') return <Text type="secondary">-</Text>;
if (!canReview) return <Text type="secondary"></Text>;
return (
<Space>
<Button
size="small"
type="link"
icon={<CheckCircleOutlined />}
onClick={() => approve(r)}
>
</Button>
<Button
size="small"
type="link"
danger
icon={<CloseCircleOutlined />}
onClick={() => {
setRejecting(r);
setRejectReason('');
}}
>
</Button>
</Space>
);
},
},
];
return (
<div>
<Space direction="vertical" style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Text type="secondary">
+ 1000 app
</Text>
</Space>
<div style={{ display: 'flex', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
<Card size="small" style={{ minWidth: 120 }}>
<Statistic title="待审核" value={summary?.pending ?? 0} valueStyle={{ color: '#fa8c16' }} />
</Card>
<Card size="small" style={{ minWidth: 120 }}>
<Statistic title="已通过" value={summary?.approved ?? 0} valueStyle={{ color: '#52c41a' }} />
</Card>
<Card size="small" style={{ minWidth: 120 }}>
<Statistic title="已拒绝" value={summary?.rejected ?? 0} valueStyle={{ color: '#8c8c8c' }} />
</Card>
<Card size="small" style={{ minWidth: 120 }}>
<Statistic title="合计" value={summary?.total ?? 0} />
</Card>
</div>
<Card>
<Tabs
activeKey={activeStatus}
onChange={setActiveStatus}
items={STATUS_TABS.map((t) => ({ key: t.key, label: t.label }))}
/>
<Table
rowKey="id"
columns={columns}
dataSource={items}
loading={loading}
onChange={onTableChange}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: onPageChange,
}}
scroll={{ x: 1790 }}
/>
</Card>
<Modal
title="拒绝上报"
open={!!rejecting}
okText="确认拒绝"
okButtonProps={{ danger: true, icon: <CloseCircleOutlined /> }}
onOk={confirmReject}
onCancel={() => {
setRejecting(null);
setRejectReason('');
}}
>
{rejecting && (
<Space direction="vertical" style={{ width: '100%' }}>
<Text>
#{rejecting.user_id} · {rejecting.store_name || '-'}
</Text>
<Space wrap>
{REJECT_TEMPLATES.map((tpl) => (
<Button size="small" key={tpl} onClick={() => setRejectReason(tpl)}>
{tpl}
</Button>
))}
</Space>
<Input.TextArea
rows={4}
maxLength={200}
value={rejectReason}
placeholder="请输入拒绝理由,用户端记录页会看到"
onChange={(e) => setRejectReason(e.target.value)}
/>
</Space>
)}
</Modal>
<UserRecordsDrawer<PriceReport>
open={recordsUser != null}
onClose={() => setRecordsUser(null)}
userId={recordsUser?.userId ?? null}
phone={recordsUser?.phone ?? null}
endpoint="/admin/api/price-reports"
columns={RECORD_COLUMNS}
recordLabel="低价上报"
countLabel="上报总计"
rewardLabel="上报奖励总计"
rewardOf={(r) => (r.status === 'approved' ? (r.reward_coins ?? 0) : 0)}
/>
</div>
);
}
+166 -18
View File
@@ -1,32 +1,97 @@
'use client';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import type { ColumnsType } from 'antd/es/table';
import { Card, Col, Descriptions, Row, Spin, Statistic, Table, Tabs, Tag } from 'antd';
import { api } from '@/lib/api';
import {
App,
Button,
Card,
Col,
Descriptions,
Result,
Row,
Space,
Spin,
Statistic,
Table,
Tabs,
Tag,
} from 'antd';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { formatUtcTime, formatWallTime, yuan } from '@/lib/format';
import { useCursorList } from '@/lib/useCursorList';
import type { CoinTxn, UserOverview, WithdrawOrder } from '@/lib/types';
const yuan = (c: number) => `¥${(c / 100).toFixed(2)}`;
const dt = (v: string) => new Date(v).toLocaleString('zh-CN');
import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals';
import { DeleteUserModal } from '@/components/DeleteUserModal';
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);
const [err, setErr] = useState<string | null>(null);
const [coinOpen, setCoinOpen] = useState(false);
const [cashOpen, setCashOpen] = useState(false);
const [delOpen, setDelOpen] = useState(false);
useEffect(() => {
api.get<UserOverview>(`/admin/api/users/${uid}`).then((r) => setData(r.data));
const loadData = useCallback(() => {
setErr(null);
api
.get<UserOverview>(`/admin/api/users/${uid}`)
.then((r) => setData(r.data))
.catch((e) => setErr(errMsg(e, '加载用户详情失败')));
}, [uid]);
useEffect(() => {
loadData();
}, [loadData]);
const coins = useCursorList<CoinTxn>('/admin/api/wallet/coin-transactions', { user_id: uid });
const cash = useCursorList<CashTxn>('/admin/api/wallet/cash-transactions', { user_id: uid });
const withdraws = useCursorList<WithdrawOrder>('/admin/api/withdraws', { user_id: uid });
const canCoins = canDo(['finance']);
const canStatus = canDo(['operator']);
const canDelete = canDo(['super_admin']); // 注销账号最高危,仅 super_admin(与后端 require_role 对齐)
const toggleStatus = () => {
if (!data) return;
const next = data.user.status === 'active' ? 'disabled' : 'active';
modal.confirm({
title: `确认${next === 'disabled' ? '封禁' : '解封'}用户 ${data.user.phone}?`,
onOk: async () => {
try {
await api.post(`/admin/api/users/${uid}/status`, { status: next });
message.success('已更新状态');
loadData();
} catch (e) {
message.error(errMsg(e));
}
},
});
};
if (err) {
return (
<Result
status="error"
title="加载失败"
subTitle={err}
extra={
<Button type="primary" onClick={loadData}>
</Button>
}
/>
);
}
if (!data) return <Spin style={{ display: 'block', marginTop: 80 }} />;
const coinCols: ColumnsType<CoinTxn> = [
{ title: '时间', dataIndex: 'created_at', render: dt },
// 金币流水为北京 wall-clock 口径,原样显示
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatWallTime(v) },
{
title: '变动',
dataIndex: 'amount',
@@ -42,17 +107,63 @@ export default function UserDetailPage() {
{ title: '备注', dataIndex: 'remark', render: (v: string | null) => v || '-' },
];
const cashCols: ColumnsType<CashTxn> = [
// 现金流水为北京 wall-clock 口径,原样显示
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatWallTime(v) },
{
title: '变动',
dataIndex: 'amount_cents',
render: (v: number) => (
<span style={{ color: v > 0 ? '#3f8600' : '#cf1322' }}>
{v > 0 ? '+' : ''}
{yuan(v)}
</span>
),
},
{ title: '余额', dataIndex: 'balance_after_cents', render: yuan },
{ title: '类型', dataIndex: 'biz_type' },
{ title: '备注', dataIndex: 'remark', render: (v: string | null) => v || '-' },
];
const wdCols: ColumnsType<WithdrawOrder> = [
{ title: '时间', dataIndex: 'created_at', render: dt },
// 提现单为 UTC 口径
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v) },
{ title: '金额', dataIndex: 'amount_cents', render: yuan },
{ title: '状态', dataIndex: 'status', render: (s: string) => <Tag>{s}</Tag> },
{ title: '单号', dataIndex: 'out_bill_no' },
{ title: '失败原因', dataIndex: 'fail_reason', render: (v: string | null) => v || '-' },
];
const loadMoreLink = (list: { loadMore: () => void; nextCursor: number | null }) => (
<div style={{ marginTop: 12, textAlign: 'center' }}>
<a
onClick={list.loadMore}
style={{ pointerEvents: list.nextCursor ? 'auto' : 'none', opacity: list.nextCursor ? 1 : 0.4 }}
>
</a>
</div>
);
return (
<div>
<h2> #{uid}</h2>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }} wrap>
<h2 style={{ margin: 0 }}> #{uid}</h2>
<Space wrap>
{canCoins && <Button onClick={() => setCoinOpen(true)}></Button>}
{canCoins && <Button onClick={() => setCashOpen(true)}></Button>}
{canStatus && data.user.status !== 'deleted' && (
<Button danger={data.user.status === 'active'} onClick={toggleStatus}>
{data.user.status === 'active' ? '封禁' : '解封'}
</Button>
)}
{canDelete && data.user.status !== 'deleted' && (
<Button danger onClick={() => setDelOpen(true)}>
</Button>
)}
</Space>
</Space>
<Card style={{ marginBottom: 16 }}>
<Descriptions column={3}>
<Descriptions.Item label="手机号">{data.user.phone}</Descriptions.Item>
@@ -64,7 +175,7 @@ export default function UserDetailPage() {
<Descriptions.Item label="微信绑定">
{data.user.wechat_openid ? '已绑定' : '未绑定'}
</Descriptions.Item>
<Descriptions.Item label="注册时间">{dt(data.user.created_at)}</Descriptions.Item>
<Descriptions.Item label="注册时间">{formatUtcTime(data.user.created_at)}</Descriptions.Item>
</Descriptions>
</Card>
@@ -115,11 +226,23 @@ export default function UserDetailPage() {
loading={coins.loading}
pagination={false}
/>
<div style={{ marginTop: 12, textAlign: 'center' }}>
<a onClick={coins.loadMore} style={{ pointerEvents: coins.nextCursor ? 'auto' : 'none', opacity: coins.nextCursor ? 1 : 0.4 }}>
</a>
</div>
{loadMoreLink(coins)}
</>
),
},
{
key: 'cash',
label: '现金流水',
children: (
<>
<Table
rowKey="id"
columns={cashCols}
dataSource={cash.items}
loading={cash.loading}
pagination={false}
/>
{loadMoreLink(cash)}
</>
),
},
@@ -138,6 +261,31 @@ export default function UserDetailPage() {
},
]}
/>
<AdjustCoinModal
user={data.user}
open={coinOpen}
onClose={() => setCoinOpen(false)}
onDone={() => {
loadData();
coins.reload();
}}
/>
<AdjustCashModal
user={data.user}
open={cashOpen}
onClose={() => setCashOpen(false)}
onDone={() => {
loadData();
cash.reload();
}}
/>
<DeleteUserModal
user={data.user}
open={delOpen}
onClose={() => setDelOpen(false)}
onDone={loadData}
/>
</div>
);
}
+314 -60
View File
@@ -1,60 +1,107 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import type { Key } from 'react';
import { useRouter } from 'next/navigation';
import type { ColumnsType } from 'antd/es/table';
import {
Button,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Table,
Tag,
message,
} from 'antd';
import type { SorterResult } from 'antd/es/table/interface';
import { App, Button, DatePicker, Input, Select, Space, Table, Tag, Tooltip } from 'antd';
import dayjs, { 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 { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals';
import { DeleteUserModal } from '@/components/DeleteUserModal';
import type { UserListItem } from '@/lib/types';
const { RangePicker } = DatePicker;
const STATUS_COLOR: Record<string, string> = { active: 'green', disabled: 'red', deleted: 'default' };
type SortField = 'id' | 'created_at' | 'last_active_at';
export default function UsersPage() {
const router = useRouter();
const { message, modal } = App.useApp();
// 筛选草稿:点「查询」才应用(避免输入即刷新);状态/渠道/日期一并随查询提交
const [phone, setPhone] = useState('');
const [nickname, setNickname] = useState('');
const [status, setStatus] = useState<string | undefined>();
const [filters, setFilters] = useState<Record<string, unknown>>({});
const { items, nextCursor, loading, loadMore, reload } = useCursorList<UserListItem>(
'/admin/api/users',
filters,
);
const [channel, setChannel] = useState<string | undefined>();
const [regRange, setRegRange] = useState<[Dayjs, Dayjs] | null>(null);
const [loginRange, setLoginRange] = 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<UserListItem>('/admin/api/users', filters);
const canCoins = canDo(['finance']);
const canStatus = canDo(['operator']);
const canDelete = canDo(['super_admin']); // 注销账号最高危,仅 super_admin(与后端 require_role 对齐)
const [coinUser, setCoinUser] = useState<UserListItem | null>(null);
const [coinForm] = Form.useForm();
const [cashUser, setCashUser] = useState<UserListItem | null>(null);
const [delUser, setDelUser] = useState<UserListItem | null>(null);
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
const search = () => setFilters({ phone: phone || undefined, status });
// 筛选/排序变化后清空已选(避免选中项跨筛选错位)
useEffect(() => {
setSelectedRowKeys([]);
}, [filterKey]);
const submitCoins = async () => {
const v = await coinForm.validateFields();
try {
await api.post(`/admin/api/users/${coinUser!.id}/coins`, v);
message.success('已调整金币');
setCoinUser(null);
coinForm.resetFields();
} catch (e) {
message.error(errMsg(e));
const search = () =>
setApplied({
phone: phone || undefined,
nickname: nickname || undefined,
status,
register_channel: channel || undefined,
created_from: regRange?.[0] ? regRange[0].startOf('day').toISOString() : undefined,
created_to: regRange?.[1] ? regRange[1].endOf('day').toISOString() : undefined,
last_active_from: loginRange?.[0] ? loginRange[0].startOf('day').toISOString() : undefined,
last_active_to: loginRange?.[1] ? loginRange[1].endOf('day').toISOString() : undefined,
});
const resetFilters = () => {
setPhone('');
setNickname('');
setStatus(undefined);
setChannel(undefined);
setRegRange(null);
setLoginRange(null);
setSortBy('id');
setSortOrder('desc');
setApplied({});
};
const onTableChange = (
_pagination: unknown,
_filters: unknown,
sorter: SorterResult<UserListItem> | SorterResult<UserListItem>[],
) => {
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 {
// 取消排序 → 回默认(ID 倒序)
setSortBy('id');
setSortOrder('desc');
}
};
const sortOrderOf = (field: SortField) =>
sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null;
const toggleStatus = (u: UserListItem) => {
const next = u.status === 'active' ? 'disabled' : 'active';
Modal.confirm({
modal.confirm({
title: `确认${next === 'disabled' ? '封禁' : '解封'}用户 ${u.phone}?`,
onOk: async () => {
try {
@@ -68,10 +115,113 @@ export default function UsersPage() {
});
};
const toggleDebugTrace = (u: UserListItem) => {
const next = !u.debug_trace_enabled;
modal.confirm({
title: `确认${next ? '开启' : '关闭'}用户 ${u.phone} 的调试链接权限?`,
content: next
? '开启后,该用户在 App 比价结果页/记录页可复制 trace 调试链接发给开发排障'
: undefined,
onOk: async () => {
try {
await api.post(`/admin/api/users/${u.id}/debug-trace`, { enabled: next });
message.success('已更新调试链接权限');
reload();
} catch (e) {
message.error(errMsg(e));
}
},
});
};
// 一次性操作:点一下把该用户标记为「未看过引导」,下次打开 App 重看一遍,看完后端自动恢复(只触发这一次)。
// 用户尚未重看前可「撤销」(把 force_onboarding 置回 false),纠正误点。
const setForceOnboarding = (u: UserListItem, enable: boolean) => {
modal.confirm({
title: enable
? `确认让用户 ${u.phone} 重看一次新手引导?`
: `撤销用户 ${u.phone} 的「重看新手引导」?`,
content: enable
? '点一下即把该用户标记为「未看过引导」:Ta 下次打开 App 会重走一遍新手引导,看完自动恢复——只触发这一次。仅对已支持该功能的 App 版本生效。'
: '该用户尚未重看,撤销后下次打开 App 不再触发。',
onOk: async () => {
try {
await api.post(`/admin/api/users/${u.id}/force-onboarding`, { enabled: enable });
message.success(enable ? '已开启:该用户下次打开 App 会重看一次' : '已撤销');
reload();
} catch (e) {
message.error(errMsg(e));
}
},
});
};
const selectedUsers = items.filter((u) => selectedRowKeys.includes(u.id));
const selectedActive = selectedUsers.filter((u) => u.status === 'active');
const selectedDisabled = selectedUsers.filter((u) => u.status === 'disabled');
// 批量:循环调现有逐用户接口(各自已带审计),Promise.allSettled 汇总成败,不新增 bulk 端点
const runBulk = async (
targets: UserListItem[],
label: string,
call: (u: UserListItem) => Promise<unknown>,
) => {
const results = await Promise.allSettled(targets.map(call));
const ok = results.filter((r) => r.status === 'fulfilled').length;
const fail = results.length - ok;
if (fail) message.warning(`${label}完成:成功 ${ok}/${results.length},失败 ${fail}`);
else message.success(`${label}完成:${ok}`);
setSelectedRowKeys([]);
reload();
};
const bulkStatus = (target: 'disabled' | 'active') => {
const targets = target === 'disabled' ? selectedActive : selectedDisabled;
const label = target === 'disabled' ? '批量封禁' : '批量解封';
if (!targets.length) {
message.warning(`没有可${target === 'disabled' ? '封禁(active)' : '解封(disabled)'}的选中用户`);
return;
}
modal.confirm({
title: `确认${label} ${targets.length} 个用户?`,
content: target === 'disabled' ? '仅对选中的 active 用户生效' : '仅对选中的 disabled 用户生效',
okButtonProps: { danger: target === 'disabled' },
onOk: () =>
runBulk(targets, label, (u) =>
api.post(`/admin/api/users/${u.id}/status`, { status: target }),
),
});
};
const bulkDebugTrace = (enabled: boolean) => {
// 只对「非 deleted 且当前态不同」的选中用户操作,避免无谓请求
const targets = selectedUsers.filter(
(u) => u.status !== 'deleted' && u.debug_trace_enabled !== enabled,
);
const label = enabled ? '批量开调试链接' : '批量关调试链接';
if (!targets.length) {
message.warning('没有需要变更的选中用户');
return;
}
modal.confirm({
title: `确认${label} ${targets.length} 个用户?`,
onOk: () =>
runBulk(targets, label, (u) =>
api.post(`/admin/api/users/${u.id}/debug-trace`, { enabled }),
),
});
};
const columns: ColumnsType<UserListItem> = [
{ title: 'ID', dataIndex: 'id', width: 70 },
{ title: '手机号', dataIndex: 'phone' },
{ title: '昵称', dataIndex: 'nickname', render: (v: string | null) => v || '-' },
{ title: 'ID', dataIndex: 'id', width: 80, sorter: true, sortOrder: sortOrderOf('id') },
{ title: '手机号', dataIndex: 'phone', width: 130 },
{
title: '昵称',
dataIndex: 'nickname',
width: 150,
ellipsis: true,
render: (v: string | null) => v || '-',
},
{ title: '渠道', dataIndex: 'register_channel', width: 90 },
{
title: '状态',
@@ -82,18 +232,58 @@ export default function UsersPage() {
{
title: '注册时间',
dataIndex: 'created_at',
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
width: 160,
sorter: true,
sortOrder: sortOrderOf('created_at'),
render: (v: string) => formatUtcTime(v),
},
{
// 最近活跃 = max(最近登录, 最近发起比价, 最近发起领券),与大盘 DAU/留存活跃口径一致(2026-07-05)
title: (
<Tooltip title="进入 App(登录)/ 发起比价 / 发起领券,取最近一次">
</Tooltip>
),
dataIndex: 'last_active_at',
width: 160,
sorter: true,
// 关掉 antd 默认的「点击升序/降序」表头提示,否则与上面自定义口径 Tooltip 同时弹出互相遮挡
showSorterTooltip: false,
sortOrder: sortOrderOf('last_active_at'),
render: (v: string | null, u: UserListItem) => formatUtcTime(v ?? u.last_login_at),
},
{
title: '操作',
key: 'op',
fixed: 'right',
width: 340,
render: (_: unknown, u: UserListItem) => (
<Space>
<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>}
{canStatus && u.status !== 'deleted' && (
<a onClick={() => toggleStatus(u)}>{u.status === 'active' ? '封禁' : '解封'}</a>
)}
{canStatus && u.status !== 'deleted' && (
<a onClick={() => toggleDebugTrace(u)}>
{u.debug_trace_enabled ? '关调试链接' : '开调试链接'}
</a>
)}
{canStatus &&
u.status !== 'deleted' &&
(u.force_onboarding ? (
<span style={{ color: '#fa8c16' }}>
<a onClick={() => setForceOnboarding(u, false)}></a>
</span>
) : (
<a onClick={() => setForceOnboarding(u, true)}></a>
))}
{canDelete && u.status !== 'deleted' && (
<a style={{ color: '#cf1322' }} onClick={() => setDelUser(u)}>
</a>
)}
</Space>
),
},
@@ -109,47 +299,111 @@ export default function UsersPage() {
onChange={(e) => setPhone(e.target.value)}
onPressEnter={search}
allowClear
style={{ width: 160 }}
style={{ width: 150 }}
/>
<Input
placeholder="昵称(模糊)"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
onPressEnter={search}
allowClear
style={{ width: 150 }}
/>
<Select
placeholder="状态"
value={status}
onChange={setStatus}
allowClear
style={{ width: 120 }}
style={{ width: 110 }}
options={[
{ value: 'active', label: 'active' },
{ value: 'disabled', label: 'disabled' },
{ value: 'deleted', label: 'deleted' },
]}
/>
<Select
placeholder="渠道"
value={channel}
onChange={setChannel}
allowClear
style={{ width: 110 }}
options={[
{ value: 'sms', label: 'sms' },
{ value: 'wechat', label: 'wechat' },
]}
/>
<RangePicker
placeholder={['注册起', '注册止']}
value={regRange}
onChange={(v) => setRegRange(v as [Dayjs, Dayjs] | null)}
allowClear
/>
<RangePicker
placeholder={['最近活跃起', '最近活跃止']}
value={loginRange}
onChange={(v) => setLoginRange(v as [Dayjs, Dayjs] | null)}
allowClear
/>
<Button type="primary" onClick={search}>
</Button>
<Button onClick={resetFilters}></Button>
</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 ? '加载更多' : '没有更多了'}
</Button>
</div>
<Modal
title={`调整金币 - ${coinUser?.phone ?? ''}`}
open={!!coinUser}
onOk={submitCoins}
onCancel={() => setCoinUser(null)}
destroyOnClose
>
<Form form={coinForm} layout="vertical">
<Form.Item name="amount" label="金币变动(正=增加,负=扣减)" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="reason" label="原因(入审计)" rules={[{ required: true }]}>
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
{canStatus && selectedUsers.length > 0 && (
<Space style={{ marginBottom: 12, width: '100%' }} wrap>
<span style={{ color: '#888' }}>
{selectedUsers.length} (active {selectedActive.length}disabled{' '}
{selectedDisabled.length})
</span>
<Button danger disabled={!selectedActive.length} onClick={() => bulkStatus('disabled')}>
</Button>
<Button disabled={!selectedDisabled.length} onClick={() => bulkStatus('active')}>
</Button>
<Button onClick={() => bulkDebugTrace(true)}></Button>
<Button onClick={() => bulkDebugTrace(false)}></Button>
<Button type="link" onClick={() => setSelectedRowKeys([])}>
</Button>
</Space>
)}
<Table
rowKey="id"
rowSelection={
canStatus
? {
selectedRowKeys,
onChange: setSelectedRowKeys,
getCheckboxProps: (u) => ({ disabled: u.status === 'deleted' }),
}
: undefined
}
columns={columns}
dataSource={items}
loading={loading}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t} 个用户`,
onChange: onPageChange,
}}
scroll={{ x: 1200 }}
onChange={onTableChange}
/>
<AdjustCoinModal user={coinUser} open={!!coinUser} onClose={() => setCoinUser(null)} />
<AdjustCashModal user={cashUser} open={!!cashUser} onClose={() => setCashUser(null)} />
<DeleteUserModal
user={delUser}
open={!!delUser}
onClose={() => setDelUser(null)}
onDone={reload}
/>
</div>
);
}
@@ -0,0 +1,246 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import {
DatePicker,
Descriptions,
Radio,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs, { type Dayjs } from 'dayjs';
import type { CursorPage, UserCoinRecord, UserRewardStats, WithdrawUserSnapshot } from '@/lib/types';
import { api, errMsg } from '@/lib/api';
const { Text } = Typography;
const { RangePicker } = DatePicker;
const yuan = (c: number) => `¥${(c / 100).toFixed(2)}`;
// 后端时间为 UTC(无时区串补 Z),按北京时区展示(与提现列表口径一致)
const apiTime = (v: string) => {
const hasTz = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(v);
return dayjs(hasTz ? v : `${v}Z`);
};
const dt = (v: string) => apiTime(v).format('YYYY-MM-DD HH:mm');
const SOURCE_COLOR: Record<string, string> = {
reward_video: 'blue',
signin_boost: 'cyan',
feed: 'purple',
signin: 'green',
};
const PAGE_SIZE = 10; // 金币记录每页条数
interface Props {
userId: number;
user: WithdrawUserSnapshot | null;
// 统计区字段集:withdraw(默认,提现详情:含提现细分) / ad(广告收益详情:只看看广告观看统计,对齐参考图 6 项)
statsVariant?: 'withdraw' | 'ad';
}
/** 用户看广告/提现详情:基本信息 + 互斥时间筛选 + 统计区(按 statsVariant 取字段集)+ 金币发放记录表。 */
export default function UserRewardPanel({ userId, user, statsVariant = 'withdraw' }: Props) {
const [mode, setMode] = useState<'all' | 'range'>('all'); // all=注册至今 / range=自定义区间
const [range, setRange] = useState<[Dayjs, Dayjs] | null>(null);
const [stats, setStats] = useState<UserRewardStats | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
const [records, setRecords] = useState<UserCoinRecord[]>([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [recordsLoading, setRecordsLoading] = useState(false);
// 时间窗口参数:注册至今=不传;自定义区间(选齐两端)=date_from/date_to。
const params =
mode === 'range' && range?.[0] && range?.[1]
? {
date_from: range[0].startOf('day').toISOString(),
date_to: range[1].endOf('day').toISOString(),
}
: {};
const paramsKey = JSON.stringify(params);
const loadStats = useCallback(async () => {
setStatsLoading(true);
try {
const { data } = await api.get<UserRewardStats>(`/admin/api/users/${userId}/reward-stats`, {
params: JSON.parse(paramsKey),
});
setStats(data);
} catch (e) {
message.error(errMsg(e));
} finally {
setStatsLoading(false);
}
}, [userId, paramsKey]);
const loadRecords = useCallback(
async (p: number) => {
setRecordsLoading(true);
try {
const { data } = await api.get<CursorPage<UserCoinRecord>>(
`/admin/api/users/${userId}/coin-records`,
{ params: { ...JSON.parse(paramsKey), limit: PAGE_SIZE, cursor: (p - 1) * PAGE_SIZE } },
);
setRecords(data.items);
setTotal(data.total ?? 0);
} catch (e) {
message.error(errMsg(e));
} finally {
setRecordsLoading(false);
}
},
[userId, paramsKey],
);
// 用户或时间筛选变化:回到第 1 页重新拉取
useEffect(() => {
loadStats();
setPage(1);
loadRecords(1);
}, [loadStats, loadRecords]);
const regDays = user?.created_at ? dayjs().diff(apiTime(user.created_at), 'day') : null;
const columns: ColumnsType<UserCoinRecord> = [
{ title: '日期时间', dataIndex: 'created_at', width: 150, render: dt },
{
title: '赚取途径',
dataIndex: 'source_label',
width: 120,
render: (label: string, r) => <Tag color={SOURCE_COLOR[r.source] ?? 'default'}>{label}</Tag>,
},
{
title: 'ECPM',
dataIndex: 'ecpm',
width: 100,
render: (v: string | null) => v ?? <Text type="secondary">-</Text>,
},
{ title: '发放金币数', dataIndex: 'coin', width: 100, render: (v: number) => <b>{v}</b> },
];
return (
<div>
{/* 用户基本信息 + 互斥时间筛选(同一行) */}
<Space
wrap
align="center"
style={{ width: '100%', justifyContent: 'space-between', marginBottom: 12 }}
>
<Space size="large" wrap>
<span>
<Text strong>{user?.phone || '-'}</Text>
</span>
<span>
<Text strong>{user?.nickname || '-'}</Text>
</span>
<span>
<Text strong>{user?.wechat_nickname || '-'}</Text>
</span>
<span>
<Text strong>{regDays != null ? `${regDays}` : '-'}</Text>
</span>
</Space>
<Space wrap>
<Radio.Group
size="small"
value={mode}
onChange={(e) => setMode(e.target.value)}
optionType="button"
buttonStyle="solid"
>
<Radio.Button value="all"></Radio.Button>
<Radio.Button value="range"></Radio.Button>
</Radio.Group>
<RangePicker
size="small"
disabled={mode !== 'range'}
value={range}
onChange={(v) => setRange(v as [Dayjs, Dayjs] | null)}
/>
</Space>
</Space>
{/* 统计区(受时间筛选;现金余额为当前快照)。ad 视图只保留看广告观看统计(对齐参考图 6 项) */}
<Descriptions bordered size="small" column={2} style={{ marginBottom: 8 }}>
<Descriptions.Item label="累计提现">
{stats ? yuan(stats.withdraw_success_cents) : '-'}
</Descriptions.Item>
<Descriptions.Item label="现金余额">
{stats ? yuan(stats.cash_balance_cents) : '-'}
</Descriptions.Item>
{statsVariant === 'ad' ? (
<>
<Descriptions.Item label="激励视频观看数">
{stats?.reward_video_count ?? '-'}
</Descriptions.Item>
{/* eCPM 均按元展示(reward_video / feed avg_ecpm 原始为分/千次,÷100 转元) */}
<Descriptions.Item label="平均激励视频ECPM">
{stats ? `${(stats.reward_video_avg_ecpm / 100).toFixed(2)} 元/千` : '-'}
</Descriptions.Item>
<Descriptions.Item label="draw信息流视频观看数">
{stats?.feed_count ?? '-'}
</Descriptions.Item>
<Descriptions.Item label="平均draw信息流ECPM">
{stats ? `${(stats.feed_avg_ecpm / 100).toFixed(2)} 元/千` : '-'}
</Descriptions.Item>
</>
) : (
<>
<Descriptions.Item label="提现总次数">{stats?.withdraw_total ?? '-'}</Descriptions.Item>
<Descriptions.Item label="传统任务提现">
{stats ? yuan(stats.traditional_task_cash_cents) : '-'}
</Descriptions.Item>
<Descriptions.Item label="累计激励视频数">
{stats?.reward_video_count ?? '-'}
</Descriptions.Item>
<Descriptions.Item label="平均激励视频ECPM">
{stats ? `${stats.reward_video_avg_ecpm} 分/千` : '-'}
</Descriptions.Item>
<Descriptions.Item label="激励视频提现">
{stats ? yuan(stats.reward_video_cash_cents) : '-'}
</Descriptions.Item>
<Descriptions.Item label="累计信息流广告数">{stats?.feed_count ?? '-'}</Descriptions.Item>
<Descriptions.Item label="平均信息流广告ECPM">
{stats ? `${stats.feed_avg_ecpm} 分/千` : '-'}
</Descriptions.Item>
<Descriptions.Item label="信息流广告提现">
{stats ? yuan(stats.feed_cash_cents) : '-'}
</Descriptions.Item>
</>
)}
</Descriptions>
<Text type="secondary" style={{ fontSize: 12 }}>
{statsVariant === 'ad'
? '统计随上方时间筛选(现金余额除外,为当前快照);eCPM 均按元展示(原始分/千次 ÷100)。'
: '统计随上方时间筛选(现金余额除外,为当前快照);eCPM 单位分/千次,同金币审计。'}
</Text>
{/* 金币记录 */}
<h4 style={{ margin: '16px 0 8px' }}></h4>
<Table
rowKey={(r) => `${r.source}-${r.created_at}-${r.coin}-${r.ecpm ?? ''}`}
size="small"
columns={columns}
dataSource={records}
loading={recordsLoading || statsLoading}
pagination={{
current: page,
pageSize: PAGE_SIZE,
total,
showSizeChanger: false,
onChange: (p) => {
setPage(p);
loadRecords(p);
},
showTotal: (t) => `${t}`,
}}
/>
</div>
);
}
+149 -210
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 {
@@ -36,35 +36,28 @@ import {
SyncOutlined,
} from '@ant-design/icons';
import dayjs, { type Dayjs } from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/zh-cn';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { useCursorList } from '@/lib/useCursorList';
import { formatUtcTime, utcDayjs, utcFromNow, yuan } from '@/lib/format';
import { usePagedList } from '@/lib/usePagedList';
import type {
AuditLog,
CashTxn,
WithdrawBulkResult,
WithdrawDetail,
WithdrawLedgerCheck,
WithdrawListItem,
WithdrawOrder,
WithdrawSummary,
WxpayHealthCheck,
} from '@/lib/types';
dayjs.extend(relativeTime);
dayjs.locale('zh-cn');
import UserRewardPanel from './UserRewardPanel';
const { Text } = Typography;
const { RangePicker } = DatePicker;
const yuan = (c: number) => `¥${(c / 100).toFixed(2)}`;
const apiTime = (v: string) => {
const hasTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(v);
return dayjs(hasTimezone ? v : `${v}Z`);
};
const dt = (v: string) => apiTime(v).format('YYYY-MM-DD HH:mm');
const ago = (v: string) => apiTime(v).fromNow();
// 提现单 / 审计 / 用户均为 UTC 口径(server_default=func.now());现金流水为北京 wall-clock。
const dt = (v: string) => formatUtcTime(v);
const ago = (v: string) => utcFromNow(v);
const STATUS_COLOR: Record<string, string> = {
reviewing: 'gold',
@@ -109,19 +102,25 @@ const QUICK_FILTER_STATUS: Record<string, string> = {
};
const SORT_OPTIONS = [
{ value: 'created_at', label: '申请时间' },
{ value: 'updated_at', label: '更新时间' },
{ value: 'amount_cents', label: '提现金额' },
{ value: 'id', label: '记录 ID' },
];
// 提现类型:coin_cash=福利页提现(金币兑换现金) / invite_cash=邀请提现(邀请奖励金)
const SOURCE_LABEL: Record<string, string> = {
coin_cash: '福利页提现',
invite_cash: '邀请提现',
};
const SOURCE_COLOR: Record<string, string> = {
coin_cash: 'blue',
invite_cash: 'purple',
};
const SOURCE_OPTIONS = [
{ value: 'coin_cash', label: '福利页提现' },
{ value: 'invite_cash', label: '邀请提现' },
];
const DATE_FIELD_OPTIONS = [
{ value: 'created_at', label: '申请时间' },
{ value: 'updated_at', label: '更新时间' },
];
const CASH_BIZ_LABEL: Record<string, string> = {
exchange_in: '金币兑入',
withdraw: '提现扣款',
withdraw_refund: '提现退款',
};
function statusTag(status: string) {
return <Tag color={STATUS_COLOR[status]}>{STATUS_LABEL[status] || status}</Tag>;
@@ -161,11 +160,12 @@ function riskTags(detail: WithdrawDetail | null) {
const tags: string[] = [];
if (!detail.order.user_name) tags.push('缺少提现实名');
if (detail.user?.status && detail.user.status !== 'active') tags.push(`账号状态:${detail.user.status}`);
if (detail.user && dayjs().diff(apiTime(detail.user.created_at), 'hour') < 24) tags.push('新注册用户');
if (detail.user && dayjs().diff(utcDayjs(detail.user.created_at), 'hour') < 24) tags.push('新注册用户');
if ((detail.user?.withdraw_total || 0) >= 3) tags.push('多次提现用户');
if (detail.recent_withdraws.some((o) => o.status === 'rejected' || o.status === 'failed')) {
tags.push('历史异常单');
}
const rejectedN = detail.recent_withdraws.filter((o) => o.status === 'rejected').length;
const failedN = detail.recent_withdraws.filter((o) => o.status === 'failed').length;
if (rejectedN) tags.push(`提现拒绝${rejectedN}`);
if (failedN) tags.push(`提现失败${failedN}`);
return tags;
}
@@ -177,10 +177,12 @@ 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);
const [ledger, setLedger] = useState<WithdrawLedgerCheck | null>(null);
const [ledgerLoading, setLedgerLoading] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [detail, setDetail] = useState<WithdrawDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
@@ -188,19 +190,18 @@ 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);
const [dateField, setDateField] = useState<'created_at' | 'updated_at'>('created_at');
const [sortBy, setSortBy] = useState<'created_at' | 'updated_at' | 'amount_cents' | 'id'>(
'created_at',
);
const [sortBy, setSortBy] = useState<'created_at' | 'amount_cents'>('created_at');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [quickFilter, setQuickFilter] = useState<string | undefined>(undefined);
const [source, setSource] = useState<string | undefined>(undefined);
const filters: Record<string, unknown> = {};
if (activeStatus !== 'all') filters.status = activeStatus;
if (source) filters.source = source;
if (keyword.trim()) filters.keyword = keyword.trim();
if (dateRange?.[0]) filters.date_from = dateRange[0].startOf('day').toISOString();
if (dateRange?.[1]) filters.date_to = dateRange[1].endOf('day').toISOString();
@@ -209,11 +210,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<WithdrawListItem>('/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');
@@ -228,6 +226,7 @@ export default function WithdrawsPage() {
setSortBy('created_at');
setSortOrder('desc');
setQuickFilter(undefined);
setSource(undefined);
};
const applyQuickFilter = (value?: string) => {
@@ -255,19 +254,28 @@ export default function WithdrawsPage() {
}
}, []);
const loadChecks = useCallback(async () => {
const loadHealth = useCallback(async () => {
try {
const [{ data: healthData }, { data: ledgerData }] = await Promise.all([
api.get<WxpayHealthCheck>('/admin/api/withdraws/health-check'),
api.get<WithdrawLedgerCheck>('/admin/api/withdraws/ledger-check'),
]);
setHealth(healthData);
setLedger(ledgerData);
const { data } = await api.get<WxpayHealthCheck>('/admin/api/withdraws/health-check');
setHealth(data);
} catch (e) {
message.error(errMsg(e));
}
}, []);
// 账本校验是全表对账、相对重,故只在进页时拉一次 + 提供手动按钮,不随每次写操作重算。
const loadLedger = useCallback(async () => {
setLedgerLoading(true);
try {
const { data } = await api.get<WithdrawLedgerCheck>('/admin/api/withdraws/ledger-check');
setLedger(data);
} catch (e) {
message.error(errMsg(e));
} finally {
setLedgerLoading(false);
}
}, []);
const loadDetail = useCallback(async (outBillNo: string) => {
setDetailLoading(true);
try {
@@ -282,17 +290,19 @@ export default function WithdrawsPage() {
useEffect(() => {
void loadSummary();
void loadChecks();
}, [loadChecks, loadSummary]);
void loadHealth();
void loadLedger();
}, [loadHealth, loadLedger, loadSummary]);
useEffect(() => {
setSelectedRowKeys([]);
}, [filterKey, pageSize]);
}, [filterKey, page, pageSize]);
const refreshAfterChange = async (outBillNo?: string) => {
setSelectedRowKeys([]);
reload();
await Promise.all([loadSummary(), loadChecks()]);
// 账本校验偏重,写操作后不重算(只刷 summary + health);需要时点账本 Alert 上的「重新校验」。
await Promise.all([loadSummary(), loadHealth()]);
if (outBillNo && drawerOpen && detail?.order.out_bill_no === outBillNo) {
await loadDetail(outBillNo);
}
@@ -305,7 +315,7 @@ export default function WithdrawsPage() {
};
const approve = (o: WithdrawOrder) => {
Modal.confirm({
modal.confirm({
title: `确认通过并打款 ${yuan(o.amount_cents)}?`,
content: (
<div>
@@ -368,7 +378,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%' }}>
@@ -406,7 +416,7 @@ export default function WithdrawsPage() {
message.warning('请选择打款中的提现单');
return;
}
Modal.confirm({
modal.confirm({
title: `确认批量刷新 ${selectedPending.length} 笔打款状态?`,
content: '会逐笔向微信查单,并按最新状态归一化。',
okText: '批量刷新查单',
@@ -428,7 +438,7 @@ export default function WithdrawsPage() {
};
const retry = (o: WithdrawOrder) => {
Modal.confirm({
modal.confirm({
title: `重新查询 ${o.out_bill_no} 的微信状态?`,
content: '会按微信最新状态归一化为成功、失败退款或撤销未确认。',
okText: '刷新查单',
@@ -450,7 +460,7 @@ export default function WithdrawsPage() {
};
const reconcile = () => {
Modal.confirm({
modal.confirm({
title: '批量对账?',
content: '扫描超过 15 分钟仍处于打款中的提现单,逐单向微信查实并归一化。',
okText: '开始对账',
@@ -468,37 +478,43 @@ export default function WithdrawsPage() {
});
};
const columns: ColumnsType<WithdrawOrder> = [
const columns: ColumnsType<WithdrawListItem> = [
{
title: '用户',
dataIndex: 'user_id',
width: 110,
render: (v: number) => <Text strong>#{v}</Text>,
},
{
title: '提现实名',
dataIndex: 'user_name',
title: '手机号',
dataIndex: 'phone',
width: 130,
render: (v: string | null) => v || <Text type="secondary"></Text>,
render: (v: string | null) => v || <Text type="secondary">-</Text>,
},
{
title: '金额',
title: '用户昵称',
dataIndex: 'nickname',
width: 130,
render: (v: string | null) => v || <Text type="secondary">-</Text>,
},
{
title: '提现金额',
dataIndex: 'amount_cents',
width: 120,
render: (v: number) => <Text strong>{yuan(v)}</Text>,
},
{
title: '提现类型',
dataIndex: 'source',
width: 110,
render: (v: string) => <Tag color={SOURCE_COLOR[v] || 'default'}>{SOURCE_LABEL[v] || v}</Tag>,
},
{
title: '累计提现',
dataIndex: 'cumulative_success_cents',
width: 120,
render: (v: number) => yuan(v ?? 0),
},
{
title: '状态',
dataIndex: 'status',
width: 110,
render: statusTag,
},
{
title: '微信状态',
dataIndex: 'wechat_state',
width: 150,
render: (v: string | null) => v || <Text type="secondary">-</Text>,
},
{
title: '提交时间',
dataIndex: 'created_at',
@@ -511,7 +527,7 @@ export default function WithdrawsPage() {
),
},
{
title: '提单号',
title: '提单号',
dataIndex: 'out_bill_no',
ellipsis: true,
},
@@ -526,7 +542,7 @@ export default function WithdrawsPage() {
key: 'op',
fixed: 'right',
width: 210,
render: (_: unknown, o: WithdrawOrder) => {
render: (_: unknown, o: WithdrawListItem) => {
if (!canManage) return <Text type="secondary">-</Text>;
if (o.status === 'reviewing') {
return (
@@ -575,36 +591,6 @@ export default function WithdrawsPage() {
},
];
const cashColumns: ColumnsType<CashTxn> = [
{
title: '类型',
dataIndex: 'biz_type',
width: 130,
render: (v: string) => CASH_BIZ_LABEL[v] || v,
},
{
title: '金额',
dataIndex: 'amount_cents',
width: 100,
render: (v: number) => (
<Text style={{ color: v < 0 ? '#cf1322' : '#389e0d' }}>
{v > 0 ? '+' : ''}
{yuan(v)}
</Text>
),
},
{ title: '余额', dataIndex: 'balance_after_cents', width: 100, render: yuan },
{ title: '备注', dataIndex: 'remark', render: (v: string | null) => v || '-' },
{ title: '时间', dataIndex: 'created_at', width: 150, render: dt },
];
const recentWithdrawColumns: ColumnsType<WithdrawOrder> = [
{ title: '单号', dataIndex: 'out_bill_no', ellipsis: true },
{ title: '金额', dataIndex: 'amount_cents', width: 90, render: yuan },
{ title: '状态', dataIndex: 'status', width: 90, render: statusTag },
{ title: '时间', dataIndex: 'created_at', width: 150, render: dt },
];
const auditTimeline = detail
? [
{
@@ -638,12 +624,27 @@ export default function WithdrawsPage() {
const rejectAmount = rejectTargets.reduce((sum, item) => sum + item.amount_cents, 0);
const ledgerIssues = ledger
? [
ledger.balance_diff_cents ? `余额差额 ${yuan(ledger.balance_diff_cents)}` : '',
ledger.missing_withdraw_txn_count ? `缺扣款流水 ${ledger.missing_withdraw_txn_count}` : '',
ledger.missing_refund_txn_count ? `缺退款流水 ${ledger.missing_refund_txn_count}` : '',
ledger.duplicate_refund_txn_count ? `重复退款流水 ${ledger.duplicate_refund_txn_count} ` : '',
// 普通现金账(coin_cash)
ledger.balance_diff_cents ? `现金余额差额 ${yuan(ledger.balance_diff_cents)}` : '',
ledger.missing_withdraw_txn_count ? `现金缺扣款流水 ${ledger.missing_withdraw_txn_count}` : '',
ledger.missing_refund_txn_count ? `现金缺退款流水 ${ledger.missing_refund_txn_count} ` : '',
ledger.duplicate_refund_txn_count ? `现金重复退款流水 ${ledger.duplicate_refund_txn_count}` : '',
ledger.refund_txn_on_non_terminal_count
? `非退款终态存在退款流水 ${ledger.refund_txn_on_non_terminal_count}`
? `现金非退款终态存在退款流水 ${ledger.refund_txn_on_non_terminal_count}`
: '',
// 邀请奖励金账(invite_cash)
ledger.invite_balance_diff_cents ? `邀请金余额差额 ${yuan(ledger.invite_balance_diff_cents)}` : '',
ledger.invite_missing_withdraw_txn_count
? `邀请金缺扣款流水 ${ledger.invite_missing_withdraw_txn_count}`
: '',
ledger.invite_missing_refund_txn_count
? `邀请金缺退款流水 ${ledger.invite_missing_refund_txn_count}`
: '',
ledger.invite_duplicate_refund_txn_count
? `邀请金重复退款流水 ${ledger.invite_duplicate_refund_txn_count}`
: '',
ledger.invite_refund_txn_on_non_terminal_count
? `邀请金非退款终态存在退款流水 ${ledger.invite_refund_txn_on_non_terminal_count}`
: '',
].filter(Boolean)
: [];
@@ -683,43 +684,33 @@ export default function WithdrawsPage() {
message={ledger.ok ? '现金账本校验正常' : '现金账本存在异常'}
description={
ledger.ok
? `账户余额合计 ${yuan(ledger.cash_balance_total_cents)},现金流水净额 ${yuan(ledger.cash_transaction_total_cents)}`
? `现金 余额 ${yuan(ledger.cash_balance_total_cents)} / 流水 ${yuan(ledger.cash_transaction_total_cents)};邀请金 余额 ${yuan(ledger.invite_cash_balance_total_cents)} / 流水 ${yuan(ledger.invite_cash_transaction_total_cents)}`
: ledgerIssues.join('')
}
action={
<Button
size="small"
icon={<ReloadOutlined />}
loading={ledgerLoading}
onClick={() => void loadLedger()}
>
</Button>
}
/>
)}
</Space>
)}
<Row gutter={12} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12} xl={5}>
<Col xs={24} sm={12}>
<Card size="small">
<Statistic title="待审核" value={summary?.reviewing_count || 0} suffix="笔" />
<Text type="secondary"> {yuan(summary?.reviewing_amount_cents || 0)}</Text>
</Card>
</Col>
<Col xs={24} sm={12} xl={5}>
<Col xs={24} sm={12}>
<Card size="small">
<Statistic title="今日拒绝" value={summary?.today_rejected_count || 0} suffix="笔" />
<Text type="secondary"></Text>
</Card>
</Col>
<Col xs={24} sm={12} xl={5}>
<Card size="small">
<Statistic title="打款中" value={summary?.pending_count || 0} suffix="笔" />
<Text type="secondary"></Text>
</Card>
</Col>
<Col xs={24} sm={12} xl={4}>
<Card size="small">
<Statistic title="失败待处理" value={summary?.failed_count || 0} suffix="笔" />
<Text type="secondary">退</Text>
</Card>
</Col>
<Col xs={24} sm={12} xl={5}>
<Card size="small">
<Statistic title="今日到账" value={summary?.today_success_count || 0} suffix="笔" />
<Text type="secondary"> {yuan(summary?.today_success_amount_cents || 0)}</Text>
<Statistic title="待审核金额" value={yuan(summary?.reviewing_amount_cents || 0)} />
</Card>
</Col>
</Row>
@@ -760,9 +751,9 @@ export default function WithdrawsPage() {
allowClear
enterButton="搜索"
prefix={<SearchOutlined />}
placeholder="用户ID / 手机号 / 单号 / 原因"
placeholder="用户手机号"
value={searchDraft}
style={{ width: 320 }}
style={{ width: 240 }}
onChange={(e) => {
const value = e.target.value;
setSearchDraft(value);
@@ -770,6 +761,14 @@ export default function WithdrawsPage() {
}}
onSearch={(value) => setKeyword(value.trim())}
/>
<Select
value={source}
allowClear
placeholder="提现类型"
style={{ width: 140 }}
options={SOURCE_OPTIONS}
onChange={(value?: string) => setSource(value)}
/>
<Select
value={quickFilter}
allowClear
@@ -809,9 +808,7 @@ export default function WithdrawsPage() {
style={{ width: 130 }}
suffixIcon={<SortAscendingOutlined />}
options={SORT_OPTIONS}
onChange={(value: 'created_at' | 'updated_at' | 'amount_cents' | 'id') =>
setSortBy(value)
}
onChange={(value: 'created_at' | 'amount_cents') => setSortBy(value)}
/>
<Select
value={sortOrder}
@@ -872,31 +869,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={
@@ -913,23 +885,26 @@ export default function WithdrawsPage() {
columns={columns}
dataSource={items}
loading={loading}
pagination={false}
scroll={{ x: 1350 }}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
showTotal: (t) => `${t}`,
onChange: onPageChange,
}}
scroll={{ x: 1460 }}
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
title="提现详情"
width={720}
width="50%"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
@@ -951,6 +926,11 @@ export default function WithdrawsPage() {
<Descriptions.Item label="金额">
<Text strong>{yuan(detail.order.amount_cents)}</Text>
</Descriptions.Item>
<Descriptions.Item label="提现类型">
<Tag color={SOURCE_COLOR[detail.order.source] || 'default'}>
{SOURCE_LABEL[detail.order.source] || detail.order.source}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="提现实名">
{detail.order.user_name || '-'}
</Descriptions.Item>
@@ -968,29 +948,10 @@ export default function WithdrawsPage() {
</Descriptions.Item>
</Descriptions>
<Descriptions bordered size="small" column={2} title="用户与钱包">
<Descriptions.Item label="用户 ID">#{detail.order.user_id}</Descriptions.Item>
<Descriptions.Item label="账号状态">
{detail.user ? <Tag>{detail.user.status}</Tag> : '-'}
</Descriptions.Item>
<Descriptions.Item label="手机号">{detail.user?.phone || '-'}</Descriptions.Item>
<Descriptions.Item label="昵称">{detail.user?.nickname || '-'}</Descriptions.Item>
<Descriptions.Item label="微信昵称">
{detail.user?.wechat_nickname || '-'}
</Descriptions.Item>
<Descriptions.Item label="现金余额">
{yuan(detail.user?.cash_balance_cents || 0)}
</Descriptions.Item>
<Descriptions.Item label="提现总次数">
{detail.user?.withdraw_total ?? '-'}
</Descriptions.Item>
<Descriptions.Item label="成功提现">
{yuan(detail.user?.withdraw_success_cents || 0)}
</Descriptions.Item>
</Descriptions>
<UserRewardPanel userId={detail.order.user_id} user={detail.user} />
<div>
<h3> {detail.risk_score ? <Tag color="red">{detail.risk_score}</Tag> : null}</h3>
<h3></h3>
{risks.length ? (
<Space wrap>
{risks.map((risk) => (
@@ -1008,28 +969,6 @@ export default function WithdrawsPage() {
<h3>线</h3>
<Timeline items={auditTimeline} />
</div>
<div>
<h3></h3>
<Table
rowKey="id"
size="small"
columns={recentWithdrawColumns}
dataSource={detail.recent_withdraws}
pagination={false}
/>
</div>
<div>
<h3></h3>
<Table
rowKey="id"
size="small"
columns={cashColumns}
dataSource={detail.recent_cash_transactions}
pagination={false}
/>
</div>
</Space>
)}
</Spin>
+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>
);
+222
View File
@@ -0,0 +1,222 @@
'use client';
// 调金币 / 调现金弹窗(受控)。列表页与用户详情页共用,避免两处各写一套。
// 每个弹窗自带「增减 / 设为指定值」两种模式;打开时拉一次 360 概览展示当前余额。
import { useEffect, useState } from 'react';
import { App, Form, Input, InputNumber, Modal, Radio, Segmented } from 'antd';
import { api, errMsg } from '@/lib/api';
import { yuan } from '@/lib/format';
import type { UserOverview } from '@/lib/types';
export type AdjustTargetUser = { id: number; phone: string };
interface ModalProps {
user: AdjustTargetUser | null;
open: boolean;
onClose: () => void;
onDone?: () => void; // 调整成功后回调(刷新余额/列表)
}
type Balances = { coin: number; cashCents: number; inviteCashCents: number };
const balanceLine = (balances: Balances | null) => (
<p style={{ color: '#888', marginBottom: 12 }}>
当前余额:金币 {balances ? balances.coin : '…'} (){' '}
{balances ? yuan(balances.cashCents) : '…'} {' '}
{balances ? yuan(balances.inviteCashCents) : '…'}
</p>
);
// 弹窗打开时拉一次该用户余额;用户切换/关闭时丢弃过期响应(alive 标记防竞态)。
function useBalances(userId: number | undefined, open: boolean) {
const [balances, setBalances] = useState<Balances | null>(null);
useEffect(() => {
if (!open || userId == null) {
setBalances(null);
return;
}
let alive = true;
setBalances(null);
api
.get<UserOverview>(`/admin/api/users/${userId}`)
.then((r) => {
if (alive)
setBalances({
coin: r.data.coin_balance,
cashCents: r.data.cash_balance_cents,
inviteCashCents: r.data.invite_cash_balance_cents,
});
})
.catch(() => {});
return () => {
alive = false;
};
}, [userId, open]);
return balances;
}
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);
useEffect(() => {
if (open) {
setMode('delta');
form.resetFields();
}
}, [open, form]);
const submit = async () => {
if (!user) return;
const v = await form.validateFields();
try {
await api.post(`/admin/api/users/${user.id}/coins`, { mode, ...v });
message.success(mode === 'set' ? '已设置金币' : '已调整金币');
onClose();
onDone?.();
} catch (e) {
message.error(errMsg(e));
}
};
return (
<Modal
title={`调整金币 - ${user?.phone ?? ''}`}
open={open}
onOk={submit}
onCancel={onClose}
destroyOnHidden
>
{balanceLine(balances)}
<Form form={form} layout="vertical">
<Form.Item label="操作方式">
<Segmented
value={mode}
onChange={(v) => setMode(v as 'delta' | 'set')}
options={[
{ label: '增减', value: 'delta' },
{ label: '设为指定值', value: 'set' },
]}
/>
</Form.Item>
<Form.Item
name="amount"
label={mode === 'set' ? '目标金币值(直接设为该值)' : '金币变动(正=增加,负=扣减)'}
rules={[
{ required: true },
...(mode === 'set' ? [{ type: 'number' as const, min: 0, message: '目标值不能为负' }] : []),
]}
>
<InputNumber style={{ width: '100%' }} min={mode === 'set' ? 0 : undefined} />
</Form.Item>
<Form.Item name="reason" label="原因(入审计)" rules={[{ required: true }]}>
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
);
}
export function AdjustCashModal({ user, open, onClose, onDone }: ModalProps) {
const { message } = App.useApp();
const [form] = Form.useForm();
// 操作方式改为表单必选项(无默认、手动选);用 watch 驱动金额栏文案/最小值
const mode = Form.useWatch('mode', form) as 'delta' | 'set' | undefined;
const balances = useBalances(user?.id, open);
useEffect(() => {
if (open) form.resetFields(); // 目标账户 / 操作方式 都重置为「未选」
}, [open, form]);
// 输入元,×100 转分后调后端(主要给无现金用户发钱直接测试提现)
const submit = async () => {
if (!user) return;
const v = await form.validateFields(); // 任一必填/必选缺失 → 该项下方红字 + 阻断提交
const amountCents = Math.round(Number(v.amount_yuan) * 100);
if (v.mode === 'delta' && amountCents === 0) {
message.warning('金额不能为 0(且不小于 1 分)');
return;
}
if (v.mode === 'set' && amountCents < 0) {
message.warning('目标金额不能为负');
return;
}
const acctName = v.account === 'invite_cash' ? '邀请奖励金' : '现金';
try {
await api.post(`/admin/api/users/${user.id}/cash`, {
account: v.account,
mode: v.mode,
amount_cents: amountCents,
reason: v.reason,
});
message.success(
v.mode === 'set'
? `已设为 ¥${(amountCents / 100).toFixed(2)} ${acctName}`
: `${amountCents > 0 ? '发放' : '扣减'} ¥${(Math.abs(amountCents) / 100).toFixed(2)} ${acctName}`,
);
onClose();
onDone?.();
} catch (e) {
message.error(errMsg(e));
}
};
return (
<Modal
title={`调现金 - ${user?.phone ?? ''}`}
open={open}
onOk={submit}
onCancel={onClose}
destroyOnHidden
>
{balanceLine(balances)}
<Form form={form} layout="vertical">
<Form.Item name="account" label="目标账户" rules={[{ required: true, message: '请选择目标账户' }]}>
<Radio.Group
optionType="button"
buttonStyle="solid"
options={[
{ label: '金币兑现金账户', value: 'coin_cash' },
{ label: '邀请账户', value: 'invite_cash' },
]}
/>
</Form.Item>
<Form.Item name="mode" label="操作方式" rules={[{ required: true, message: '请选择操作方式' }]}>
<Radio.Group
optionType="button"
buttonStyle="solid"
options={[
{ label: '增减', value: 'delta' },
{ label: '设为指定值', value: 'set' },
]}
/>
</Form.Item>
<Form.Item
name="amount_yuan"
label={
mode === 'set'
? '目标现金(元,直接设为该值,须≥0)'
: '现金变动(元,正=发放,负=扣减;不可扣成负)'
}
rules={[
{ required: true, message: '请输入现金变动金额' },
...(mode === 'set' ? [{ type: 'number' as const, min: 0, message: '目标值不能为负' }] : []),
]}
>
<InputNumber
style={{ width: '100%' }}
step={0.01}
precision={2}
suffix="元"
min={mode === 'set' ? 0 : undefined}
/>
</Form.Item>
<Form.Item name="reason" label="原因(入审计)" rules={[{ required: true, message: '请填写原因' }]}>
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
);
}
+61
View File
@@ -0,0 +1,61 @@
'use client';
// 注销/删除账号弹窗(受控)。列表页与用户详情页共用,避免两处各写一套(同 AdjustBalanceModals)。
// 复用 C 端自助注销同一套后端清理:物理删除个人内容/行为/PII 表 + 清零余额 + 匿名化 user 行,
// 保留资金流水/邀请关系/广告幂等+对账表。仅 super_admin 可见入口;后端亦 require_role("super_admin") 兜底。
// 后端资金前置闸(有未提现现金/邀请金 / 在审提现单 → 409)带明确 detail,errMsg 原样透出。
import { App, Form, Input, Modal } from 'antd';
import { api, errMsg } from '@/lib/api';
export type DeleteTargetUser = { id: number; phone: string };
interface Props {
user: DeleteTargetUser | null;
open: boolean;
onClose: () => void;
onDone?: () => void; // 删除成功后回调(刷新详情/列表)
}
export function DeleteUserModal({ user, open, onClose, onDone }: Props) {
const { message } = App.useApp();
const [form] = Form.useForm();
const submit = async () => {
if (!user) return;
const v = await form.validateFields(); // 原因必填,缺失即红字阻断
try {
// axios 的 DELETE 带 body 要放在 config.data 里
await api.delete(`/admin/api/users/${user.id}`, { data: { reason: v.reason } });
message.success('账号已注销');
onClose();
onDone?.();
} catch (e) {
message.error(errMsg(e));
}
};
return (
<Modal
title={`注销账号 - ${user?.phone ?? ''}`}
open={open}
onOk={submit}
onCancel={onClose}
okText="确认注销"
okButtonProps={{ danger: true }}
afterOpenChange={(o) => {
if (o) form.resetFields();
}}
destroyOnHidden
>
<p style={{ color: '#cf1322', marginBottom: 12 }}>
(//////),
<strong></strong>//广
</p>
<Form form={form} layout="vertical">
<Form.Item name="reason" label="注销原因(入审计)" rules={[{ required: true, message: '请填写注销原因' }]}>
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
);
}
+113
View File
@@ -0,0 +1,113 @@
'use client';
// 点列表里的手机号 → 抽屉列出该用户「同类型」的全部记录(反馈页=全部反馈;低价审核页=全部上报)。
// 顶部汇总该用户的「提交/上报总计」+「奖励总计」;复用各自的 list 接口按 user_id 过滤(无需新后端接口)。
import { useEffect, useState } from 'react';
import { Drawer, Empty, Space, Spin, Statistic, Table, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { api } from '@/lib/api';
import type { CursorPage } from '@/lib/types';
const { Text } = Typography;
interface Props<T> {
open: boolean;
onClose: () => void;
userId: number | null;
phone: string | null;
endpoint: string; // 列表接口,按 user_id 过滤,如 /admin/api/feedbacks
columns: ColumnsType<T>;
recordLabel: string; // 「反馈」/「低价上报」,用于标题与空态文案
countLabel: string; // 顶部计数卡标题,如「提交总计」/「上报总计」
rewardLabel: string; // 顶部奖励卡标题,如「提交奖励总计」/「上报奖励总计」
rewardOf: (item: T) => number; // 单条已发奖励金币(未采纳/未通过返回 0)
rewardSuffix?: string; // 第二个汇总卡单位,默认「金币」;领券场景可传「张」(领到券张数)等
}
export default function UserRecordsDrawer<T extends { id: number }>({
open,
onClose,
userId,
phone,
endpoint,
columns,
recordLabel,
countLabel,
rewardLabel,
rewardOf,
rewardSuffix = '金币',
}: Props<T>) {
const [items, setItems] = useState<T[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open || userId == null) return;
let alive = true;
setLoading(true);
api
.get<CursorPage<T>>(endpoint, {
params: { user_id: userId, limit: 100, sort_by: 'created_at', sort_order: 'desc' },
})
.then((r) => {
if (!alive) return;
setItems(r.data.items);
setTotal(r.data.total ?? r.data.items.length);
})
.catch(() => {
if (!alive) return;
setItems([]);
setTotal(0);
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [open, userId, endpoint]);
// 奖励总计在已加载记录上累加(单用户记录数远小于 100 上限,够用)
const rewardTotal = items.reduce((sum, it) => sum + rewardOf(it), 0);
return (
<Drawer
title={
<span>
#{userId ?? '-'} {recordLabel}
{phone ? (
<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>
{phone}
</Text>
) : null}
</span>
}
width={760}
open={open}
onClose={onClose}
destroyOnHidden
>
{loading ? (
<Spin style={{ display: 'block', margin: '40px auto' }} />
) : (
<>
<Space size={48} style={{ marginBottom: 20 }}>
<Statistic title={countLabel} value={total} />
<Statistic title={rewardLabel} value={rewardTotal} suffix={rewardSuffix} />
</Space>
{items.length === 0 ? (
<Empty description={`该用户暂无${recordLabel}`} style={{ marginTop: 24 }} />
) : (
<Table<T>
rowKey="id"
size="small"
columns={columns}
dataSource={items}
pagination={false}
/>
)}
</>
)}
</Drawer>
);
}
+47
View File
@@ -0,0 +1,47 @@
// 全站统一的金额 / 时间格式化。
//
// 后端有两种时间口径,序列化后从字符串无法区分,必须按字段语义选对函数:
// 1. UTC 口径:`server_default=func.now()` 的字段(用户/提现单/审计日志/广告发奖记录)。
// 入库是 UTC,SQLite 下序列化不带时区、Postgres 下带 +00:00。统一当 UTC 解析后转北京显示。
// 2. 北京 wall-clock 口径:钱包流水(coin_transaction / cash_transaction)。入库即
// `datetime.now(CN_TZ).replace(tzinfo=None)`,字面就是北京时间且无时区,**不能再做时区换算**。
// 历史上各页面混用 `new Date().toLocaleString`(按本地解析)与 `apiTime`(补 Z 当 UTC),
// 导致提现详情里现金流水时间快 8 小时。此模块收口,按口径区分。
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/zh-cn';
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(relativeTime);
dayjs.locale('zh-cn');
const TZ = 'Asia/Shanghai';
/** 金额:分 → ¥元(两位小数)。 */
export const yuan = (cents: number) => `¥${(cents / 100).toFixed(2)}`;
const hasTz = (v: string) => /(?:Z|[+-]\d{2}:?\d{2})$/i.test(v);
/** 把 UTC 口径字符串解析为带时区的 dayjs(无时区后缀的补 Z 当 UTC)。 */
export const utcDayjs = (v: string) => dayjs(hasTz(v) ? v : `${v}Z`);
/** UTC 口径时间 → 北京时间显示(用户/提现单/审计/广告发奖记录)。 */
export function formatUtcTime(v?: string | null, fmt = 'YYYY-MM-DD HH:mm'): string {
if (!v) return '-';
return utcDayjs(v).tz(TZ).format(fmt);
}
/** UTC 口径相对时间(如「3 分钟前」)。 */
export function utcFromNow(v?: string | null): string {
if (!v) return '-';
return utcDayjs(v).fromNow();
}
/** 北京 wall-clock 口径时间 → 原样格式化,不做时区换算(钱包金币/现金流水)。 */
export function formatWallTime(v?: string | null, fmt = 'YYYY-MM-DD HH:mm'): string {
if (!v) return '-';
return dayjs(v).format(fmt);
}
+12
View File
@@ -0,0 +1,12 @@
// 媒体(上报截图 / 反馈截图 / 反馈二维码等)由 App 后端的 /media 静态服务托管,与 admin 不同域:
// - dev :NEXT_PUBLIC_MEDIA_BASE = http://localhost:8770(本地 App 后端)。
// - 生产:NEXT_PUBLIC_MEDIA_BASE = https://app-api.shaguabijia.com(线上 App 后端,/media 真正的家)。
// 留空则退化为同域相对路径——而 admin 域(admin-web.shaguabijia.com)并不 serve /media,会 404,
// 这正是线上后台图片打不开的根因。NEXT_PUBLIC_* 是编译期常量,改完需重新 build 才生效。
const MEDIA_BASE = (process.env.NEXT_PUBLIC_MEDIA_BASE || '').replace(/\/+$/, ''); // 去末尾斜杠,防拼出 //media
/** 把后端返回的相对媒体路径(/media/...)拼成可加载的 URL;已是 http(s) 绝对地址的原样返回。 */
export function mediaUrl(p: string): string {
if (!p) return p;
return p.startsWith('http') ? p : MEDIA_BASE + p;
}
+533 -4
View File
@@ -3,10 +3,32 @@
export interface AdminInfo {
id: number;
username: string;
role: string; // super_admin / finance / operator
role: string; // super_admin / finance / operator / custom(自定义) / 自定义角色名
status: string;
created_at: string;
last_login_at: string | null;
pages?: string[]; // 当前有效可见页 key(登录 / /me 下发);super_admin = 全部页;role==custom = pages_override。左侧导航按此过滤
pages_override?: string[] | null; // 「自定义」勾选的可见页原始集(role==custom 时非空);列表接口下发,供编辑回填勾选
password?: string | null; // 明文登录密码(仅账号列表下发);无留存(脚本建的超管)为 null → 不显示
}
// RBAC:页面权限目录(= 左侧导航项,后端 /admin/api/roles/catalog 下发)
export interface PermissionPage {
key: string;
label: string;
}
export interface PermissionGroup {
group: string;
pages: PermissionPage[];
}
// RBAC:角色(可见页集合)
export interface AdminRole {
id: number;
name: string; // 角色 key(承重,admin_user.role 引用)
label: string; // 展示名(UI 显示,如 管理员/运营/财务/技术)
pages: string[]; // 该角色有效可见页 key(super_admin = 全部)
is_builtin: boolean; // 内建(super_admin):不可编辑/删除
in_use: number; // 使用该角色的管理员数
}
export interface LoginResponse {
@@ -19,6 +41,7 @@ export interface LoginResponse {
export interface CursorPage<T> {
items: T[];
next_cursor: number | null;
total?: number | null; // offset 分页时为符合筛选条件的总条数(页码分页用);加载更多接口可能没有
}
export interface UserListItem {
@@ -27,15 +50,62 @@ export interface UserListItem {
nickname: string | null;
register_channel: string;
status: string;
// 调试链接权限:开了的用户在 App 比价结果页/记录页可复制 price.shaguabijia.com 的 trace 调试链接
debug_trace_enabled: boolean;
// 运营「一键开启新手引导」:true = 该用户下次打开 App 会被强制重走新手引导,走完自动取消
force_onboarding: boolean;
wechat_openid: string | null;
created_at: string;
last_login_at: string;
// 最近活跃 = max(最近登录, 最近发起比价, 最近发起领券);与大盘 DAU/留存活跃口径一致
last_active_at: string | null;
}
// 设备维度新手引导:一台设备(ANDROID_ID)聚合,走过引导的账号数 + 最近完成时间。
export interface DeviceOnboardingItem {
device_id: string;
account_count: number;
last_completed_at: string;
}
// 设备存活监控:device_liveness 表一行 + 后端按心跳阈值派生的在线/掉线/掉线时长。
export interface DeviceLivenessItem {
id: number;
user_id: number;
phone: string | null;
nickname: string | null;
device_id: string;
device_model: string | null; // 由 device_id 解析的机型
platform: string;
app_version: string | null;
registration_id: string | null; // 非空 = 可极光推送
ever_protected: boolean;
first_protected_at: string | null; // 首次开无障碍时刻(老设备为 null)
last_heartbeat_at: string | null;
last_report_protection_on: boolean;
liveness_state: string; // unknown / alive / silent(当前未用) / notified
notified_at: string | null;
kill_alert_pending: boolean; // 掉线召回待客户端 ack
created_at: string;
updated_at: string;
// 后端派生
online: boolean;
display_state: string; // online 在线 / offline 已掉线 / never 未启用
offline_seconds: number | null; // 掉线时长(秒);仅 offline 有值
}
export interface DeviceLivenessStats {
total: number;
online: number;
offline: number;
never: number;
}
export interface UserOverview {
user: UserListItem;
coin_balance: number;
cash_balance_cents: number;
invite_cash_balance_cents: number; // 邀请奖励金余额(与 cash_balance_cents 物理隔离)
total_coin_earned: number;
comparison_total: number;
comparison_success: number;
@@ -71,6 +141,8 @@ export interface WithdrawOrder {
user_id: number;
out_bill_no: string;
amount_cents: number;
// 提现类型:coin_cash(福利页提现,金币兑换现金) / invite_cash(邀请提现,邀请奖励金)
source: string;
user_name: string | null;
status: string; // reviewing / pending / success / failed / rejected
wechat_state: string | null;
@@ -80,6 +152,14 @@ export interface WithdrawOrder {
updated_at: string;
}
// 提现列表项:WithdrawOrder + 列表接口联表带出的用户字段。
// 详情抽屉的 recent_withdraws 仍是裸 WithdrawOrder(无这些字段)。
export interface WithdrawListItem extends WithdrawOrder {
phone: string | null;
nickname: string | null;
cumulative_success_cents: number; // 累计成功提现(分)
}
export interface WithdrawSummary {
reviewing_count: number;
reviewing_amount_cents: number;
@@ -114,6 +194,30 @@ export interface WithdrawDetail {
audit_logs: AuditLog[];
}
// 提现详情抽屉「用户统计区」:按时间窗口算的提现 + 看广告行为统计。
// *_cash_cents = 该来源累计金币折算成可提现现金(分);现金余额是当前快照不随窗口。
export interface UserRewardStats {
withdraw_success_cents: number; // 累计提现
cash_balance_cents: number; // 现金余额(快照)
withdraw_total: number; // 提现总次数(全部状态)
traditional_task_cash_cents: number; // 传统任务提现
reward_video_count: number; // 累计激励视频数
reward_video_avg_ecpm: number; // 平均激励视频 eCPM(分/千次)
reward_video_cash_cents: number; // 激励视频提现
feed_count: number; // 累计信息流广告数(份)
feed_avg_ecpm: number; // 平均信息流广告 eCPM(分/千次)
feed_cash_cents: number; // 信息流广告提现
}
// 金币发放记录(提现详情底部表)。ecpm 为原始分/千次,非广告来源为 null。
export interface UserCoinRecord {
source: string; // reward_video / signin_boost / feed / signin
source_label: string; // 激励视频 / 签到膨胀 / 信息流广告 / 签到
created_at: string;
ecpm: string | null;
coin: number;
}
export interface WithdrawBulkItemResult {
out_bill_no: string;
ok: boolean;
@@ -130,6 +234,7 @@ export interface WithdrawBulkResult {
export interface WithdrawLedgerCheck {
ok: boolean;
// 普通现金账(coin_cash)
cash_balance_total_cents: number;
cash_transaction_total_cents: number;
balance_diff_cents: number;
@@ -137,6 +242,14 @@ export interface WithdrawLedgerCheck {
missing_refund_txn_count: number;
duplicate_refund_txn_count: number;
refund_txn_on_non_terminal_count: number;
// 邀请奖励金账(invite_cash:与普通现金物理隔离,各自对账)
invite_cash_balance_total_cents: number;
invite_cash_transaction_total_cents: number;
invite_balance_diff_cents: number;
invite_missing_withdraw_txn_count: number;
invite_missing_refund_txn_count: number;
invite_duplicate_refund_txn_count: number;
invite_refund_txn_on_non_terminal_count: number;
}
export interface WxpayHealthCheck {
@@ -161,9 +274,47 @@ export interface Feedback {
user_id: number;
content: string;
contact: string;
// 反馈来源入口:profile(「我的」页=普通反馈) / comparison(比价结果页=比价反馈)
source: string;
// 比价反馈的问题场景(找错商品/优惠不对…);普通反馈为 null
scene: string | null;
images: string[] | null;
status: string; // new / handled
// pending(审核中) / adopted(已采纳) / rejected(未采纳);历史数据可能为 new
status: string;
reject_reason: string | null; // 未采纳原因(用户端可见)
reward_coins: number | null; // 采纳后发放金币
review_note: string | null; // 审核批注(采纳要点 / 内部备注,用户不可见)
admin_reply: string | null; // 运营给用户的回复留言(用户端可见)
reviewed_by_admin_id: number | null;
reviewed_at: string | null;
created_at: string;
// 提交端环境快照:提交版本号 / 机型OS版本;改版前的历史反馈为 null
app_version: string | null;
device_model: string | null;
rom_name: string | null;
android_version: string | null;
// 联表瞬态:展示完整手机号(点手机号查该用户全部反馈)
phone: string | null;
nickname: string | null;
}
// 反馈审核台顶部各状态计数(pending 含历史 new 态)。后端 GET /admin/api/feedbacks/summary。
export interface FeedbackSummary {
pending: number;
adopted: number;
rejected: number;
total: number;
}
// 反馈页「加群二维码」卡配置(运营后台改 → App 意见反馈页同步)。
// image_url 为后端 /media 相对路径(留空=App 走本地兜底图);其余为卡片三行文案与显隐开关。
export interface FeedbackQrConfig {
enabled: boolean;
image_url: string | null;
title: string; // 第一行(操作提示),如「长按图片保存二维码」
group_name: string; // 第二行加粗群名,App 渲染为 直通「{group_name}」
subtitle: string; // 第三行(标语)
updated_at: string | null;
}
export interface AuditLog {
@@ -178,6 +329,187 @@ export interface AuditLog {
created_at: string;
}
// 广告收益报表聚合行下钻的单条展示明细
// ===== 比价记录(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;
product_names: 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;
input_tokens: number | null;
output_tokens: 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;
}
// 单次 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; // 因子1
units: number; // 份数
lt_index_start: number | null;
lt_index_end: number | null;
lt_factor_start: number | null; // 因子2
lt_factor_end: number | null;
expected_coin: number;
actual_coin: number;
matched: boolean;
}
// 广告收益报表:按日期汇总的一天(按天趋势图用;全量,不受分页影响)
export interface AdRevenueDaily {
date: string; // 北京时间 YYYY-MM-DD
impressions: number;
revenue_yuan: number; // 客户端预估收益(eCPM 折算)
pangle_revenue_yuan: number | null; // 穿山甲后台预估收益(GroMore revenue);非全量视图/无数据为 null
pangle_api_revenue_yuan: number | null; // 穿山甲收益API(更接近结算);未配/当天/无数据为 null
expected_coin: number;
actual_coin: number;
}
// 广告收益报表:按北京小时(0–23)汇总的一小时(按小时趋势图用;全量,不受分页影响;按天查询时为空)
export interface AdRevenueHourly {
hour: number; // 北京时间小时 023
impressions: number;
revenue_yuan: number;
expected_coin: number;
actual_coin: number;
}
// 广告收益报表:按广告类型(ad_type)的小计(展示条数 + 预估收益;eCPM 前端用 收益÷展示×1000 算)
export interface AdRevenueTypeStat {
impressions: number; // 该类型展示条数合计
revenue_yuan: number; // 该类型预估收益合计(元)
}
// 广告收益报表:一次广告事件(逐条一行)。激励视频展示+发奖按 ad_session_id 合并;信息流展示/发奖各自成行。
export interface AdRevenueRow {
event_key: string; // 事件稳定唯一键(前端 rowKey)
report_date: string; // 该事件所属日期 北京时间 YYYY-MM-DD
user_id: number;
user_phone: string | null; // 用户手机号(admin 展示;查不到为空)
ad_type: string; // reward_video / feed / draw
feed_scene?: string | null; // Draw 信息流投放场景 comparison(比价) / coupon(领券) / welfare(福利);非信息流或旧数据为 null
app_env: string | null; // prod(傻瓜比价) / test(测试);旧数据为空
our_code_id: string | null; // 我们配置的代码位 104xxx;旧数据为空
hour: number | null; // 北京时间小时 0–23(按小时粒度时有值;按天为 null)
created_at: string; // 事件时间(有展示=展示时间,纯发奖=发奖时间)
// ── 展示侧 ──
has_impression: boolean; // 是否有广告展示(信息流逐条展示=true,纯发奖行=false)
impressions: number; // 本行展示条数 1/0(供日汇总、趋势图复用)
ecpm: string | null; // 分/千次;展示行取展示值,纯发奖行取发奖采用值
revenue_yuan: number; // 本次展示预估收益(元);纯发奖行=0
row_revenue_yuan?: number | null; // 主表逐行显示用:一次比价/领券聚合行=该次发奖广告 eCPM 折算之和;其它行空(回退 revenue_yuan)
adn: string | null; // 实际填充 ADN;纯发奖行为空
slot_id: string | null; // 底层 mediation rit;纯发奖行为空
// ── 发奖侧 ──
has_reward: boolean; // 是否有发奖(激励视频合并行/信息流整场发奖行=true;纯展示=false)
status: string | null; // 发奖状态 granted/closed_early/…;纯展示为空
expected_coin: number; // 应发金币;纯展示=0
actual_coin: number; // 实发金币;纯展示=0
matched: boolean; // 本条应发==实发;纯展示恒 true(不计对账)
reward_detail: AdRevenueRecord | null; // 发奖复算明细(点行展开下钻);纯展示为空
sub_rewards?: AdRevenueRecord[]; // 一次比价/领券聚合行的组内逐条明细(同整场 ad_session_id 的多条广告);展开渲染多行
sub_count?: number; // 本行聚合的发奖条数:一次比价/领券=该次广告条数(≥1);激励视频/纯展示=1
}
export interface AdRevenueReport {
date_from: string; // 起始日 北京时间 YYYY-MM-DD
date_to: string; // 结束日 北京时间 YYYY-MM-DD(闭区间;单日时与 date_from 相同)
daily: AdRevenueDaily[]; // 按日期汇总序列(全量,供按天趋势图)
hourly: AdRevenueHourly[]; // 按小时汇总序列(全量,供按小时趋势图;按天查询时为空)
type_stats: Record<string, AdRevenueTypeStat>; // 按广告类型小计;前端取 draw / reward_video
// 按信息流场景小计(comparison/coupon/welfare;全量,不受分页截断)。数据大盘「领券广告/比价广告」卡
// 优先读它:2026-07-02 后端起 items 不再含信息流逐条展示行(唯一带收益+场景的行),按 items 现算恒 0。
// 可选(`?.` 探测)以兼容未升级后端,缺失时回退 items 现算。
scene_stats?: Record<string, AdRevenueTypeStat>;
// 可选:后端预聚合的「按广告类型」富小计(含发放金币明细)。当前后端未下发 → 数据大盘页
// (dashboard adTypeSummary)用 `?.` 探测、缺失时按 items 现算兜底;后端补上即自动启用。
by_ad_type?: Record<
string,
{ ad_type: string; impressions: number; revenue_yuan: number; expected_coin: number; actual_coin: number }
>;
dau: number | null; // 区间去重活跃用户(口径同数据大盘 period.users.active:登录+开始比价+开始领券);按查询区间统计、含今日,全局口径不随筛选变化
total: number; // 当前筛选下的分页总条数(全量,不受分页影响)
truncated: boolean; // 当前页之后是否还有更多事件(分页后前端不再据此报警)
total_impressions: number;
total_revenue_yuan: number; // 客户端预估收益合计(eCPM 折算)
total_pangle_revenue_yuan: number | null; // 穿山甲后台预估收益合计(GroMore revenue);非全量视图/无数据为 null
total_pangle_api_revenue_yuan: number | null; // 穿山甲收益API合计(更接近结算);未配/当天/非全量视图为 null
pangle_revenue_available: boolean; // 本次结果是否带穿山甲后台收益(全量视图且已同步到数据)
total_expected_coin: number;
total_actual_coin: number;
mismatch_count: number; // 应发≠实发的发奖条数
items: AdRevenueRow[];
}
export interface DashboardOverview {
users: {
total: number;
@@ -187,7 +519,17 @@ export interface DashboardOverview {
new_today: number;
dau: number;
};
coins: { granted_total: number };
coins: {
reward_video_coin_total: number;
reward_video_watch_count: number;
feed_ad_coin_total: number;
feed_ad_watch_count: number;
signin_coin_total: number;
signin_count: number;
signin_boost_coin_total: number;
signin_boost_watch_count: number;
granted_total: number;
};
cash: {
withdraw_success_cents: number;
withdraw_pending_count: number;
@@ -195,6 +537,193 @@ export interface DashboardOverview {
withdraw_failed_count: number;
};
comparison: { total: number; success: number; success_rate: number };
// 时段大盘(对应后端 app/admin/schemas/dashboard.py DashboardPeriod);新版数据大盘页用
period: {
date_from: string;
date_to: string;
users: {
new: number;
active: number;
retained_new_users: number;
// 次日留存基数:窗口内逐日「前一日新增用户数」之和(retention_rate 的分母;旧后端无此字段)
retention_cohort?: number;
retention_rate: number | null;
retention_note: string;
};
comparison: {
total: number;
success: number;
success_rate: number;
ordered: number;
average_duration_ms: number | null;
average_saved_cents: number | null;
};
// 领券核心数据(2026-07-05 新增;旧后端无此字段,前端 `?.` 探测)。
// 点位=一张券;成功口径 success+already_claimed;点位成功率分母=发起数×应领点位数(未跑到视为失败)。
coupon?: {
started: number;
all_success: number; // 全部点位领成功的完成场次数
success_rate: number | null;
point_success: number;
points_per_session: number | null; // 应领点位数(本期完成场点位数众数)
point_success_rate: number | null;
median_elapsed_ms: number | null; // 仅 completed,同「领券数据」页口径
};
coins: {
granted_total: number;
reward_video_coin_total: number;
feed_ad_coin_total: number;
signin_coin_total: number;
signin_boost_coin_total: number;
task_coin_total: number;
coupon_reward_coin_total: number;
comparison_reward_coin_total: number;
regular_task_coin_total: number;
};
cash: { withdraw_success_cents: number };
trend: {
date: string;
active_users: number;
new_users: number;
comparisons: number;
}[];
};
feedback: { new: number };
cps: { available: boolean; note: string };
cps: {
available: boolean;
note: string;
meituan_order_count: number;
meituan_commission_cents: number;
meituan_hit_count: number;
meituan_miss_count: number;
meituan_unknown_rate_count: number;
meituan_hit_rate: number | null;
jd_order_count?: number;
jd_commission_cents?: number;
jd_actual_commission_cents?: number;
jd_estimated_commission_cents?: number;
jd_invalid_count?: number;
};
}
export interface PriceReport {
id: number;
user_id: number;
comparison_record_id: number | null;
store_name: string | null;
dish_summary: string | null;
original_platform_id: string | null;
original_platform_name: string | null;
original_price_cents: number | null;
reported_platform_id: string;
reported_platform_name: string;
reported_price_cents: number;
images: string[];
status: string; // pending / approved / rejected
reject_reason: string | null;
reward_coins: number | null;
reviewed_at: string | null;
created_at: string;
// 联表瞬态:phone/nickname 展示完整手机号(点手机号查该用户全部上报);
// 其余取自关联比价记录(无关联记录时为 null):trace 调试链接、机型OS版本、提交版本号
phone: string | null;
nickname: string | null;
trace_id: string | null;
trace_url: string | null;
device_model: string | null; // 机型,如 PEEM00
rom_name: string | null; // ROM/OS 名,如 ColorOS / MIUI / HarmonyOS
android_version: string | null; // Android 版本号,如 13
app_version: string | null; // 提交时我们 app 的 versionName,如 1.2.3
}
export interface PriceReportSummary {
pending: number;
approved: number;
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), // 写操作后刷新当前页
};
}