Compare commits

..

9 Commits

Author SHA1 Message Date
陈世睿 6f4758e81d feat(device-liveness): 运营后台设备存活监控页(卡片/筛选/掉线置顶/刷新)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:06:52 +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
18 changed files with 2210 additions and 1055 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
+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;
-60
View File
@@ -551,9 +551,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -570,9 +567,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -589,9 +583,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -608,9 +599,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -627,9 +615,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -646,9 +631,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -665,9 +647,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -684,9 +663,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -703,9 +679,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -728,9 +701,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -753,9 +723,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -778,9 +745,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -803,9 +767,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -828,9 +789,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -853,9 +811,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -878,9 +833,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1017,9 +969,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1036,9 +985,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1055,9 +1001,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1074,9 +1017,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
+749
View File
@@ -0,0 +1,749 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import {
App,
Alert,
Button,
Card,
Col,
DatePicker,
Divider,
InputNumber,
Modal,
Popover,
Row,
Select,
Space,
Statistic,
Table,
Tag,
Typography,
} from 'antd';
import {
FallOutlined,
InfoCircleOutlined,
QuestionCircleOutlined,
RiseOutlined,
} from '@ant-design/icons';
import dayjs, { type Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format';
import type {
AdRevenueDaily,
AdRevenueRecord,
AdRevenueReport,
AdRevenueRow,
} from '@/lib/types';
const { RangePicker } = DatePicker;
// 广告类型标签
const TYPE_TAG: Record<string, { color: string; label: string }> = {
reward_video: { color: 'blue', label: '激励视频' },
feed: { color: 'purple', label: '信息流' },
draw: { color: 'geekblue', label: 'Draw 信息流' },
withdrawal_video: { color: 'gold', label: '提现激励视频' },
};
// 我们的应用环境标签
const APP_TAG: Record<string, { color: string; label: string }> = {
prod: { color: 'green', label: '傻瓜比价(正式)' },
test: { color: 'default', label: '测试应用' },
};
// 发奖状态 → 颜色 + 中文(含「不发金币的原因」:提前关闭/未满10秒/缺eCPM/次数超限)
const STATUS_TAG: Record<string, { color: string; label: string }> = {
granted: { color: 'green', label: '已发' },
capped: { color: 'orange', label: '次数超限' },
ecpm_missing: { color: 'red', label: '缺 eCPM' },
too_short: { color: 'gold', label: '未满10秒' },
closed_early: { color: 'default', label: '提前关闭' },
};
const fmtFactorRange = (a: number | null, b: number | null) => {
if (a == null) return '-';
return a === b || b == null ? String(a) : `${a}${b}`;
};
const fmtIndexRange = (a: number | null, b: number | null) => {
if (a == null) return '-';
return a === b ? String(a) : `${a}${b}`;
};
// 金币计算公式(写死,展示用)——与后端 app/core/rewards.py 同源,改动以后端常量为准。
// 单次奖励(元) = (eCPM元 ÷ 1000) × 因子1(eCPM 元档) × 因子2(LT 累计条数);1 元 = 10000 金币,四舍五入取整。
const COIN_PER_YUAN = 10000;
// 因子1:按 eCPM(元/千次)判档,对应 AD_ECPM_FACTOR_TABLE
const ECPM_FACTOR_ROWS = [
{ key: '4', range: '> 400', factor: 0.6 },
{ key: '3', range: '201 400', factor: 0.4 },
{ key: '2', range: '101 200', factor: 0.3 },
{ key: '1', range: '0 100', factor: 0.1 },
];
// 因子2:按该账号累计第几条/份(不按天重置),对应 AD_LT_FACTOR_TABLE
const LT_FACTOR_ROWS = [
{ key: '1', lt: '第 1 条', factor: 2.0 },
{ key: '2', lt: '第 2 条', factor: 1.5 },
{ key: '3', lt: '第 3 条', factor: 1.3 },
{ key: '4', lt: '4 10 条', factor: 1.1 },
{ key: '5', lt: '第 11 条及以后', factor: 1.0 },
];
// 展开行(逐条事件展开)- 该条的发奖复算明细(还原金币审计的 eCPM/因子1/份数/LT/因子2 等列)
const DETAIL_COLUMNS: ColumnsType<AdRevenueRecord> = [
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 165 },
{
title: '状态',
dataIndex: 'status',
width: 110,
render: (s: string) => {
const t = STATUS_TAG[s] ?? { color: 'default', label: s };
return <Tag color={t.color}>{t.label}</Tag>;
},
},
{ title: 'eCPM(分)', dataIndex: 'ecpm', width: 90, render: (v: string | null) => v ?? '-' },
{ title: '因子1', dataIndex: 'ecpm_factor', width: 70, render: (v: number | null) => v ?? '-' },
{ title: '份数', dataIndex: 'units', width: 60 },
{
title: 'LT累计条数',
key: 'lt_index',
width: 100,
render: (_: unknown, r: AdRevenueRecord) => fmtIndexRange(r.lt_index_start, r.lt_index_end),
},
{
title: '因子2',
key: 'lt_factor',
width: 90,
render: (_: unknown, r: AdRevenueRecord) => fmtFactorRange(r.lt_factor_start, r.lt_factor_end),
},
{ title: '应发金币', dataIndex: 'expected_coin', width: 100, render: (v: number) => <b>{v}</b> },
{ title: '实发金币', dataIndex: 'actual_coin', width: 100 },
{
title: '一致',
dataIndex: 'matched',
width: 80,
render: (m: boolean) => (m ? <Tag color="green"></Tag> : <Tag color="red"> </Tag>),
},
];
// 趋势图(纯 SVG,零依赖):柱=展示条数(左轴),线=预估收益元(右轴)。x 轴按传入点序。
const CHART_BAR = '#69b1ff';
const CHART_LINE = '#fa8c16';
interface TrendPoint {
label: string; // x 轴刻度文案(小时数 / MM-DD)
tip: string; // hover 原生 tooltip 全文
impressions: number;
revenue: number;
}
// 按小时聚合(023),数据源是 items(被 limit 截断时会偏少,调用处给警告)
function aggregateHourly(rows: AdRevenueRow[]): TrendPoint[] {
const byHour = Array.from({ length: 24 }, (_, h) => ({ hour: h, impressions: 0, revenue: 0 }));
for (const r of rows) {
if (r.hour == null || r.hour < 0 || r.hour > 23) continue;
byHour[r.hour].impressions += r.impressions;
byHour[r.hour].revenue += r.revenue_yuan;
}
return byHour.map((b) => ({
label: String(b.hour),
tip: `${String(b.hour).padStart(2, '0')}:00 展示 ${b.impressions} 条 · 预估收益 ${b.revenue.toFixed(4)}`,
impressions: b.impressions,
revenue: b.revenue,
}));
}
// 按天聚合,数据源是 daily(全量,不受 limit 影响);用 from..to 补齐空缺日为 0,轴连续
function aggregateDaily(dateFrom: string, dateTo: string, daily: AdRevenueDaily[]): TrendPoint[] {
const map = new Map(daily.map((d) => [d.date, d]));
const out: TrendPoint[] = [];
let cur = dayjs(dateFrom);
let guard = 0;
while (cur.format('YYYY-MM-DD') <= dateTo && guard < 400) {
const ds = cur.format('YYYY-MM-DD');
const d = map.get(ds);
const impressions = d?.impressions ?? 0;
const revenue = d?.revenue_yuan ?? 0;
out.push({
label: ds.slice(5),
tip: `${ds} 展示 ${impressions} 条 · 预估收益 ${revenue.toFixed(4)}`,
impressions,
revenue,
});
cur = cur.add(1, 'day');
guard += 1;
}
return out;
}
function TrendChart({ points }: { points: TrendPoint[] }) {
const n = points.length;
const maxImp = Math.max(1, ...points.map((p) => p.impressions));
const maxRev = Math.max(1e-9, ...points.map((p) => p.revenue));
const W = 960;
const H = 280;
const padL = 48;
const padR = 56;
const padT = 16;
const padB = 32;
const plotW = W - padL - padR;
const plotH = H - padT - padB;
const step = plotW / Math.max(1, n);
const barW = Math.min(28, step * 0.55);
const yBase = padT + plotH;
const barX = (i: number) => padL + i * step + (step - barW) / 2;
const cx = (i: number) => padL + i * step + step / 2;
const impH = (v: number) => (v / maxImp) * plotH;
const revY = (v: number) => yBase - (v / maxRev) * plotH;
const linePts = points.map((p, i) => `${cx(i)},${revY(p.revenue)}`).join(' ');
const labelEvery = Math.max(1, Math.ceil(n / 12)); // 至多 ~12 个 x 标签,避免拥挤
return (
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ display: 'block' }} role="img" aria-label="趋势图">
{[0, 0.25, 0.5, 0.75, 1].map((t) => {
const y = yBase - t * plotH;
return (
<g key={t}>
<line x1={padL} y1={y} x2={W - padR} y2={y} stroke="#f0f0f0" />
<text x={padL - 8} y={y + 4} textAnchor="end" fontSize={11} fill={CHART_BAR}>
{Math.round(maxImp * t)}
</text>
<text x={W - padR + 8} y={y + 4} textAnchor="start" fontSize={11} fill={CHART_LINE}>
{(maxRev * t).toFixed(2)}
</text>
</g>
);
})}
{points.map((p, i) => (
<rect
key={i}
x={barX(i)}
y={yBase - impH(p.impressions)}
width={barW}
height={impH(p.impressions)}
fill={CHART_BAR}
rx={2}
>
<title>{p.tip}</title>
</rect>
))}
<polyline points={linePts} fill="none" stroke={CHART_LINE} strokeWidth={2} />
{points.map((p, i) => (
<circle key={i} cx={cx(i)} cy={revY(p.revenue)} r={2.5} fill={CHART_LINE}>
<title>{p.tip}</title>
</circle>
))}
{points.map((p, i) =>
i % labelEvery === 0 || i === n - 1 ? (
<text key={i} x={cx(i)} y={H - 14} textAnchor="middle" fontSize={11} fill="#999">
{p.label}
</text>
) : null,
)}
</svg>
);
}
// 广告收益报表页:单表 —— 按 用户×类型×应用×代码位 聚合,一行同时给出
// 展示条数 / 收益 / 应发金币 / 实发金币 / 是否一致(发奖对账下沉到聚合级)。
// 穿山甲 app_id / 广告位 / 开关等配置在「广告配置」页(/ad-revenue)维护。
export default function AdRevenueReportPage() {
const { message } = App.useApp();
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]);
const [userId, setUserId] = useState<number | null>(null);
const [adType, setAdType] = useState<string | undefined>();
const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
const [limit, setLimit] = useState<number>(500);
const [queriedGranularity, setQueriedGranularity] = useState<'day' | 'hour'>('day'); // 本次结果对应的粒度,决定是否显示「小时」列
const [queriedMultiDay, setQueriedMultiDay] = useState(false); // 本次结果是否跨多天,决定显示「日期」列 + 按天/按小时图
const [data, setData] = useState<AdRevenueReport | null>(null);
const [queriedLimit, setQueriedLimit] = useState<number>(500);
const [loading, setLoading] = useState(false);
const [formulaOpen, setFormulaOpen] = useState(false);
// 当前选择是否跨多天:跨多天时「按小时」无意义,粒度强制按天
const rangeMultiDay = range[0].format('YYYY-MM-DD') !== range[1].format('YYYY-MM-DD');
const load = useCallback(async () => {
const from = range[0].format('YYYY-MM-DD');
const to = range[1].format('YYYY-MM-DD');
const multiDay = from !== to;
const gran = multiDay ? 'day' : granularity; // 跨多天强制按天
setLoading(true);
try {
const res = await api.get<AdRevenueReport>('/admin/api/ad-revenue-report', {
params: {
date_from: from,
date_to: to,
user_id: userId ?? undefined,
ad_type: adType ?? undefined,
granularity: gran,
limit,
},
});
setData(res.data);
setQueriedLimit(limit);
setQueriedGranularity(gran);
setQueriedMultiDay(multiDay);
} catch (e) {
message.error(errMsg(e));
} finally {
setLoading(false);
}
}, [range, userId, adType, granularity, limit]);
useEffect(() => {
load();
// 仅首次自动拉今天;之后由「查询」按钮触发
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const columns: ColumnsType<AdRevenueRow> = [
...(queriedMultiDay
? [{ title: '日期', dataIndex: 'report_date', width: 105, fixed: 'left' } as ColumnsType<AdRevenueRow>[number]]
: []),
{ title: '时间', dataIndex: 'created_at', width: 165, render: (v: string) => formatUtcTime(v) },
{
title: '用户',
dataIndex: 'user_phone',
width: 150,
render: (phone: string | null, r: AdRevenueRow) =>
phone ? (
<span>
{phone}
<Typography.Text type="secondary" style={{ fontSize: 11, marginLeft: 6 }}>
#{r.user_id}
</Typography.Text>
</span>
) : (
<Typography.Text type="secondary">#{r.user_id}</Typography.Text>
),
},
{
title: '广告类型',
dataIndex: 'ad_type',
width: 110,
render: (s: string) => {
const t = TYPE_TAG[s] ?? { color: 'default', label: s };
return <Tag color={t.color}>{t.label}</Tag>;
},
},
{
title: '来源应用',
dataIndex: 'app_env',
width: 140,
render: (v: string | null) => {
if (!v) return <Typography.Text type="secondary"></Typography.Text>;
const t = APP_TAG[v] ?? { color: 'default', label: v };
return <Tag color={t.color}>{t.label}</Tag>;
},
},
{
title: '广告位ID',
dataIndex: 'our_code_id',
width: 110,
render: (v: string | null) =>
v ? <Typography.Text code>{v}</Typography.Text> : <Typography.Text type="secondary">-</Typography.Text>,
},
{
title: 'eCPM(分)',
dataIndex: 'ecpm',
width: 100,
align: 'right',
render: (v: string | null) => v ?? '-',
},
{
title: '预估收益(元)',
dataIndex: 'revenue_yuan',
width: 110,
align: 'right',
render: (v: number, r: AdRevenueRow) => (r.has_impression ? v.toFixed(4) : '-'),
},
{
title: '发奖状态',
dataIndex: 'status',
width: 100,
render: (s: string | null) => {
if (!s) return <Tag></Tag>;
const t = STATUS_TAG[s] ?? { color: 'default', label: s };
return <Tag color={t.color}>{t.label}</Tag>;
},
},
{
title: '应发金币',
dataIndex: 'expected_coin',
width: 90,
align: 'right',
render: (v: number, r: AdRevenueRow) =>
r.has_reward ? <b>{v}</b> : <Typography.Text type="secondary">-</Typography.Text>,
},
{
title: '实发金币',
dataIndex: 'actual_coin',
width: 90,
align: 'right',
render: (v: number, r: AdRevenueRow) =>
r.has_reward ? v : <Typography.Text type="secondary">-</Typography.Text>,
},
{
title: '一致',
dataIndex: 'matched',
width: 70,
align: 'center',
render: (m: boolean, r: AdRevenueRow) =>
r.has_reward ? (
m ? <Tag color="green"></Tag> : <Tag color="red"> </Tag>
) : (
<Typography.Text type="secondary">-</Typography.Text>
),
},
{
title: '底层 ADN',
dataIndex: 'adn',
width: 100,
render: (v: string | null) =>
v ? <Tag>{v}</Tag> : <Typography.Text type="secondary">-</Typography.Text>,
},
];
// 派生指标(全部基于全量 total_* 字段,不受 limit 截断影响,准):
// 平均 eCPM = 收益÷展示×1000;发奖成本(元)= 实发金币÷汇率;预估毛利 = 收益−发奖成本;
// 发奖占收益比 = 发奖成本÷收益;应发实发差额(金币)= 应发−实发(正=少发/负=多发)。
const derived = data
? {
avgEcpm: data.total_impressions
? (data.total_revenue_yuan / data.total_impressions) * 1000
: 0,
payoutYuan: data.total_actual_coin / COIN_PER_YUAN,
grossProfit: data.total_revenue_yuan - data.total_actual_coin / COIN_PER_YUAN,
payoutRatioPct:
data.total_revenue_yuan > 0
? (data.total_actual_coin / COIN_PER_YUAN / data.total_revenue_yuan) * 100
: null,
coinGap: data.total_expected_coin - data.total_actual_coin,
}
: null;
return (
<div>
<Space align="center" style={{ display: 'flex', marginBottom: 16 }}>
<h2 style={{ margin: 0 }}></h2>
<Button
type="link"
size="small"
icon={<QuestionCircleOutlined />}
onClick={() => setFormulaOpen(true)}
>
</Button>
<Popover
placement="bottomLeft"
title="收益口径说明"
content={
<div style={{ maxWidth: 360, fontSize: 13, lineHeight: 1.7 }}>
广(onAdShow) eCPM ( ÷1000
),<b>广</b>;穿/,,
<b>穿</b> 0广ID / ,
</div>
}
>
<Button type="link" size="small" icon={<InfoCircleOutlined />}>
</Button>
</Popover>
</Space>
<Card size="small" style={{ marginBottom: 16 }}>
<Space wrap size="middle" align="center">
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<RangePicker
value={range}
onChange={(v) => v && v[0] && v[1] && setRange([v[0], v[1]])}
allowClear={false}
presets={[
{ label: '今天', value: [dayjs(), dayjs()] },
{ label: '近 7 天', value: [dayjs().subtract(6, 'day'), dayjs()] },
{ label: '近 30 天', value: [dayjs().subtract(29, 'day'), dayjs()] },
]}
/>
</Space>
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<InputNumber
placeholder="可空"
value={userId ?? undefined}
onChange={(v) => setUserId(v ?? null)}
style={{ width: 130 }}
/>
</Space>
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<Select
placeholder="全部"
value={adType}
onChange={setAdType}
allowClear
style={{ width: 150 }}
options={[
{ value: 'reward_video', label: '激励视频' },
{ value: 'feed', label: '信息流' },
{ value: 'draw', label: 'Draw 信息流' },
{ value: 'withdrawal_video', 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={limit}
onChange={setLimit}
style={{ width: 110 }}
options={[100, 200, 500, 1000].map((n) => ({ value: n, label: `${n}` }))}
/>
</Space>
<Button type="primary" onClick={load} loading={loading}>
</Button>
</Space>
</Card>
{data && derived && (
<Card size="small" style={{ marginBottom: 16 }}>
<Divider orientation="left" plain style={{ marginTop: 0, marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
广
</Typography.Text>
</Divider>
<Row gutter={[16, 12]}>
<Col span={6}>
<Statistic title="展示条数合计" value={data.total_impressions} />
</Col>
<Col span={6}>
<Statistic title="平均 eCPM(元/千次)" value={derived.avgEcpm} precision={2} />
</Col>
<Col span={6}>
<Statistic title="预估收益合计(元)" value={data.total_revenue_yuan} precision={4} />
</Col>
<Col span={6}>
<Statistic title="广告事件数" value={data.total} />
</Col>
</Row>
<Divider orientation="left" plain style={{ marginTop: 20, marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
</Typography.Text>
</Divider>
<Row gutter={[16, 12]}>
<Col span={6}>
<Statistic title="应发金币合计" value={data.total_expected_coin} />
</Col>
<Col span={6}>
<Statistic title="实发金币合计" value={data.total_actual_coin} />
</Col>
<Col span={6}>
<Statistic title="发奖成本(元)" value={derived.payoutYuan} precision={4} />
</Col>
<Col span={6}>
<Statistic
title="预估毛利(元)"
value={derived.grossProfit}
precision={4}
prefix={derived.grossProfit >= 0 ? <RiseOutlined /> : <FallOutlined />}
valueStyle={{ color: derived.grossProfit >= 0 ? '#3f8600' : '#cf1322' }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{' '}
{derived.payoutRatioPct == null ? '—' : `${derived.payoutRatioPct.toFixed(0)}%`}
</Typography.Text>
</Col>
</Row>
</Card>
)}
{data && (
<Space direction="vertical" style={{ width: '100%', marginBottom: 12 }}>
{data.mismatch_count > 0 ? (
<Alert
type="error"
showIcon
message={`${data.total} 条广告事件,其中 ${data.mismatch_count} 条应发≠实发(✗);应发−实发 合计 ${
derived && derived.coinGap >= 0 ? '+' : ''
}${derived?.coinGap ?? 0} 金币(正=少发/负=多发)——公式可能未生效或发奖有问题。定位到具体记录可用后端逐条审计接口。`}
/>
) : (
<Alert type="success" showIcon message={`${data.total} 条广告事件,应发与实发全部一致。`} />
)}
{data.truncated && (
<Alert
type="warning"
showIcon
message={`明细已截断到 ${queriedLimit} 条,还有更多未显示——请按用户/类型/日期缩小范围,或调大「条数」。合计数字不受影响。`}
/>
)}
</Space>
)}
{data &&
(queriedMultiDay
? (data.daily?.length ?? 0) > 0
: queriedGranularity === 'hour' && (data.items?.length ?? 0) > 0) && (
<Card
size="small"
title={queriedMultiDay ? '按天趋势' : '按小时趋势'}
style={{ marginBottom: 16 }}
extra={
<Space size={16}>
<span style={{ fontSize: 12, color: '#666' }}>
<span
style={{
display: 'inline-block',
width: 10,
height: 10,
background: CHART_BAR,
borderRadius: 2,
marginRight: 4,
}}
/>
</span>
<span style={{ fontSize: 12, color: '#666' }}>
<span
style={{
display: 'inline-block',
width: 14,
borderTop: `2px solid ${CHART_LINE}`,
marginRight: 4,
verticalAlign: 'middle',
}}
/>
()
</span>
</Space>
}
>
{/* 按小时图由 items 聚合,会被 limit 截断;按天图用 daily(全量)不受影响,故只在按小时时警告 */}
{!queriedMultiDay && data.truncated && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 12 }}
message={`图表按小时聚合的是已截断到 ${queriedLimit} 组的明细,可能少算——调大「条数」或缩小筛选可还原真实趋势。`}
/>
)}
{queriedMultiDay ? (
<TrendChart points={aggregateDaily(data.date_from, data.date_to, data.daily)} />
) : (
<TrendChart points={aggregateHourly(data.items)} />
)}
</Card>
)}
<Table
rowKey="event_key"
columns={columns}
dataSource={data?.items ?? []}
loading={loading}
pagination={false}
size="small"
scroll={{ x: 1200 }}
rowClassName={(r) => (r.has_reward && !r.matched ? 'row-mismatch' : '')}
expandable={{
// 只有发了奖的事件可展开,看「这条广告金币怎么算的」;纯展示行无复算、不可展开。
rowExpandable: (r) => r.reward_detail != null,
expandedRowRender: (r) =>
r.reward_detail ? (
<div>
<Typography.Text strong></Typography.Text>{' '}
<Typography.Text type="secondary">
(广 {r.reward_detail.expected_coin} / {r.reward_detail.actual_coin})
</Typography.Text>
<Table<AdRevenueRecord>
style={{ marginTop: 8 }}
rowKey="record_id"
columns={DETAIL_COLUMNS}
dataSource={[r.reward_detail]}
pagination={false}
size="small"
scroll={{ x: 900 }}
rowClassName={(d) => (d.matched ? '' : 'row-mismatch')}
/>
</div>
) : null,
}}
/>
<Modal
title="金币计算规则"
open={formulaOpen}
onCancel={() => setFormulaOpen(false)}
footer={null}
width={680}
>
<Typography.Paragraph>
<b>()</b> eCPM<sub></sub> ÷ 1000)× <b>1</b>(eCPM )× <b>2</b>(LT )
</Typography.Paragraph>
<Typography.Paragraph type="secondary" style={{ fontSize: 13 }}>
· eCPM 穿 SDK <Typography.Text code>getEcpm()</Typography.Text> ,/, ÷100 ; ÷1000
<br />· :<b>1 {COIN_PER_YUAN.toLocaleString()} </b>,
<br />· 2LT (); 1 , 10 1
<br />· ,
</Typography.Paragraph>
<Row gutter={16}>
<Col span={12}>
<Typography.Text strong>1 · eCPM(/)</Typography.Text>
<Table
style={{ marginTop: 8 }}
size="small"
pagination={false}
dataSource={ECPM_FACTOR_ROWS}
columns={[
{ title: 'eCPM 范围(元)', dataIndex: 'range' },
{ title: '因子1', dataIndex: 'factor', width: 80 },
]}
/>
</Col>
<Col span={12}>
<Typography.Text strong>2 · LT </Typography.Text>
<Table
style={{ marginTop: 8 }}
size="small"
pagination={false}
dataSource={LT_FACTOR_ROWS}
columns={[
{ title: 'LT 累计条数', dataIndex: 'lt' },
{ title: '因子2', dataIndex: 'factor', width: 80 },
]}
/>
</Col>
</Row>
<Typography.Paragraph type="secondary" style={{ fontSize: 13, marginTop: 12, marginBottom: 0 }}>
示例:eCPM 300 / 1 0.3 × 0.4 × 2.0 0.24 2,400
</Typography.Paragraph>
</Modal>
<style jsx global>{`
.row-mismatch > td {
background: #fff1f0 !important;
}
`}</style>
</div>
);
}
+11 -749
View File
@@ -1,278 +1,12 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import {
App,
Alert,
Button,
Card,
Col,
DatePicker,
Divider,
Form,
Input,
InputNumber,
Modal,
Popover,
Row,
Select,
Space,
Statistic,
Switch,
Table,
Tag,
Typography,
} from 'antd';
import {
FallOutlined,
InfoCircleOutlined,
QuestionCircleOutlined,
RiseOutlined,
} from '@ant-design/icons';
import dayjs, { type Dayjs } from 'dayjs';
import { Alert, App, Button, Card, Form, Input, Switch } from 'antd';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format';
import type {
AdRevenueDaily,
AdRevenueImpression,
AdRevenueRecord,
AdRevenueReport,
AdRevenueRow,
} from '@/lib/types';
const { RangePicker } = DatePicker;
// 广告类型标签
const TYPE_TAG: Record<string, { color: string; label: string }> = {
reward_video: { color: 'blue', label: '激励视频' },
feed: { color: 'purple', label: '信息流' },
draw: { color: 'geekblue', label: 'Draw 信息流' },
};
// 我们的应用环境标签
const APP_TAG: Record<string, { color: string; label: string }> = {
prod: { color: 'green', label: '傻瓜比价(正式)' },
test: { color: 'default', label: '测试应用' },
};
// 发奖状态 → 颜色 + 中文(含「不发金币的原因」:提前关闭/未满10秒/缺eCPM/次数超限)
const STATUS_TAG: Record<string, { color: string; label: string }> = {
granted: { color: 'green', label: '已发' },
capped: { color: 'orange', label: '次数超限' },
ecpm_missing: { color: 'red', label: '缺 eCPM' },
too_short: { color: 'gold', label: '未满10秒' },
closed_early: { color: 'default', label: '提前关闭' },
};
const fmtFactorRange = (a: number | null, b: number | null) => {
if (a == null) return '-';
return a === b || b == null ? String(a) : `${a}${b}`;
};
const fmtIndexRange = (a: number | null, b: number | null) => {
if (a == null) return '-';
return a === b ? String(a) : `${a}${b}`;
};
// 金币计算公式(写死,展示用)——与后端 app/core/rewards.py 同源,改动以后端常量为准。
// 单次奖励(元) = (eCPM元 ÷ 1000) × 因子1(eCPM 元档) × 因子2(LT 累计条数);1 元 = 10000 金币,四舍五入取整。
const COIN_PER_YUAN = 10000;
// 因子1:按 eCPM(元/千次)判档,对应 AD_ECPM_FACTOR_TABLE
const ECPM_FACTOR_ROWS = [
{ key: '4', range: '> 400', factor: 0.6 },
{ key: '3', range: '201 400', factor: 0.4 },
{ key: '2', range: '101 200', factor: 0.3 },
{ key: '1', range: '0 100', factor: 0.1 },
];
// 因子2:按该账号累计第几条/份(不按天重置),对应 AD_LT_FACTOR_TABLE
const LT_FACTOR_ROWS = [
{ key: '1', lt: '第 1 条', factor: 2.0 },
{ key: '2', lt: '第 2 条', factor: 1.5 },
{ key: '3', lt: '第 3 条', factor: 1.3 },
{ key: '4', lt: '4 10 条', factor: 1.1 },
{ key: '5', lt: '第 11 条及以后', factor: 1.0 },
];
// 展开行 - 展示明细:每条广告展示一行(时间/eCPM/收益/adn/底层位)
const IMPRESSION_COLUMNS: ColumnsType<AdRevenueImpression> = [
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 165 },
{ title: 'eCPM(分)', dataIndex: 'ecpm', width: 100 },
{ title: '预估收益(元)', dataIndex: 'revenue_yuan', width: 120, render: (v: number) => v.toFixed(4) },
{
title: 'adn',
dataIndex: 'adn',
width: 90,
render: (v: string | null) => v ?? '-',
},
{
title: '底层代码位ID',
dataIndex: 'slot_id',
render: (v: string | null) => v ?? '-',
},
];
// 展开行 - 发奖明细:该组的逐条发奖复算明细(还原原金币审计的 eCPM/因子1/份数/LT/因子2 等列)
const DETAIL_COLUMNS: ColumnsType<AdRevenueRecord> = [
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 165 },
{
title: '状态',
dataIndex: 'status',
width: 110,
render: (s: string) => {
const t = STATUS_TAG[s] ?? { color: 'default', label: s };
return <Tag color={t.color}>{t.label}</Tag>;
},
},
{ title: 'eCPM(分)', dataIndex: 'ecpm', width: 90, render: (v: string | null) => v ?? '-' },
{ title: '因子1', dataIndex: 'ecpm_factor', width: 70, render: (v: number | null) => v ?? '-' },
{ title: '份数', dataIndex: 'units', width: 60 },
{
title: 'LT累计条数',
key: 'lt_index',
width: 100,
render: (_: unknown, r: AdRevenueRecord) => fmtIndexRange(r.lt_index_start, r.lt_index_end),
},
{
title: '因子2',
key: 'lt_factor',
width: 90,
render: (_: unknown, r: AdRevenueRecord) => fmtFactorRange(r.lt_factor_start, r.lt_factor_end),
},
{ title: '应发金币', dataIndex: 'expected_coin', width: 100, render: (v: number) => <b>{v}</b> },
{ title: '实发金币', dataIndex: 'actual_coin', width: 100 },
{
title: '一致',
dataIndex: 'matched',
width: 80,
render: (m: boolean) => (m ? <Tag color="green"></Tag> : <Tag color="red"> </Tag>),
},
];
// 趋势图(纯 SVG,零依赖):柱=展示条数(左轴),线=预估收益元(右轴)。x 轴按传入点序。
const CHART_BAR = '#69b1ff';
const CHART_LINE = '#fa8c16';
interface TrendPoint {
label: string; // x 轴刻度文案(小时数 / MM-DD)
tip: string; // hover 原生 tooltip 全文
impressions: number;
revenue: number;
}
// 按小时聚合(023),数据源是 items(被 limit 截断时会偏少,调用处给警告)
function aggregateHourly(rows: AdRevenueRow[]): TrendPoint[] {
const byHour = Array.from({ length: 24 }, (_, h) => ({ hour: h, impressions: 0, revenue: 0 }));
for (const r of rows) {
if (r.hour == null || r.hour < 0 || r.hour > 23) continue;
byHour[r.hour].impressions += r.impressions;
byHour[r.hour].revenue += r.revenue_yuan;
}
return byHour.map((b) => ({
label: String(b.hour),
tip: `${String(b.hour).padStart(2, '0')}:00 展示 ${b.impressions} 条 · 预估收益 ${b.revenue.toFixed(4)}`,
impressions: b.impressions,
revenue: b.revenue,
}));
}
// 按天聚合,数据源是 daily(全量,不受 limit 影响);用 from..to 补齐空缺日为 0,轴连续
function aggregateDaily(dateFrom: string, dateTo: string, daily: AdRevenueDaily[]): TrendPoint[] {
const map = new Map(daily.map((d) => [d.date, d]));
const out: TrendPoint[] = [];
let cur = dayjs(dateFrom);
let guard = 0;
while (cur.format('YYYY-MM-DD') <= dateTo && guard < 400) {
const ds = cur.format('YYYY-MM-DD');
const d = map.get(ds);
const impressions = d?.impressions ?? 0;
const revenue = d?.revenue_yuan ?? 0;
out.push({
label: ds.slice(5),
tip: `${ds} 展示 ${impressions} 条 · 预估收益 ${revenue.toFixed(4)}`,
impressions,
revenue,
});
cur = cur.add(1, 'day');
guard += 1;
}
return out;
}
function TrendChart({ points }: { points: TrendPoint[] }) {
const n = points.length;
const maxImp = Math.max(1, ...points.map((p) => p.impressions));
const maxRev = Math.max(1e-9, ...points.map((p) => p.revenue));
const W = 960;
const H = 280;
const padL = 48;
const padR = 56;
const padT = 16;
const padB = 32;
const plotW = W - padL - padR;
const plotH = H - padT - padB;
const step = plotW / Math.max(1, n);
const barW = Math.min(28, step * 0.55);
const yBase = padT + plotH;
const barX = (i: number) => padL + i * step + (step - barW) / 2;
const cx = (i: number) => padL + i * step + step / 2;
const impH = (v: number) => (v / maxImp) * plotH;
const revY = (v: number) => yBase - (v / maxRev) * plotH;
const linePts = points.map((p, i) => `${cx(i)},${revY(p.revenue)}`).join(' ');
const labelEvery = Math.max(1, Math.ceil(n / 12)); // 至多 ~12 个 x 标签,避免拥挤
return (
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ display: 'block' }} role="img" aria-label="趋势图">
{[0, 0.25, 0.5, 0.75, 1].map((t) => {
const y = yBase - t * plotH;
return (
<g key={t}>
<line x1={padL} y1={y} x2={W - padR} y2={y} stroke="#f0f0f0" />
<text x={padL - 8} y={y + 4} textAnchor="end" fontSize={11} fill={CHART_BAR}>
{Math.round(maxImp * t)}
</text>
<text x={W - padR + 8} y={y + 4} textAnchor="start" fontSize={11} fill={CHART_LINE}>
{(maxRev * t).toFixed(2)}
</text>
</g>
);
})}
{points.map((p, i) => (
<rect
key={i}
x={barX(i)}
y={yBase - impH(p.impressions)}
width={barW}
height={impH(p.impressions)}
fill={CHART_BAR}
rx={2}
>
<title>{p.tip}</title>
</rect>
))}
<polyline points={linePts} fill="none" stroke={CHART_LINE} strokeWidth={2} />
{points.map((p, i) => (
<circle key={i} cx={cx(i)} cy={revY(p.revenue)} r={2.5} fill={CHART_LINE}>
<title>{p.tip}</title>
</circle>
))}
{points.map((p, i) =>
i % labelEvery === 0 || i === n - 1 ? (
<text key={i} x={cx(i)} y={H - 14} textAnchor="middle" fontSize={11} fill="#999">
{p.label}
</text>
) : null,
)}
</svg>
);
}
// 广告数据页:单表 —— 按 用户×类型×应用×代码位 聚合,一行同时给出
// 展示条数 / 收益 / 应发金币 / 实发金币 / 是否一致(发奖对账下沉到聚合级)。
// 广告配置面板:穿山甲 app_id / 各场景广告位ID / 验签密钥 / 各场景开关。
// 客户端(发版后)从 /api/v1/platform/ad-config 拉取;此处经 admin /ad-config 读写。
// 收益报表在独立的「广告收益」页(/ad-revenue-report)。
interface AdCfg {
app_id: string;
reward_code_id: string;
@@ -282,9 +16,10 @@ interface AdCfg {
reward_enabled: boolean;
compare_ad_enabled: boolean;
coupon_ad_enabled: boolean;
withdrawal_ad_enabled: boolean;
}
function AdConfigPanel() {
export default function AdConfigPage() {
const { message } = App.useApp();
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
@@ -315,7 +50,6 @@ function AdConfigPanel() {
return (
<Card
title="广告配置(穿山甲)"
style={{ marginBottom: 16 }}
extra={
<Button type="primary" loading={saving} onClick={save}>
@@ -355,486 +89,14 @@ function AdConfigPanel() {
<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>
);
}
export default function AdRevenuePage() {
const { message } = App.useApp();
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]);
const [userId, setUserId] = useState<number | null>(null);
const [adType, setAdType] = useState<string | undefined>();
const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
const [limit, setLimit] = useState<number>(500);
const [queriedGranularity, setQueriedGranularity] = useState<'day' | 'hour'>('day'); // 本次结果对应的粒度,决定是否显示「小时」列
const [queriedMultiDay, setQueriedMultiDay] = useState(false); // 本次结果是否跨多天,决定显示「日期」列 + 按天/按小时图
const [data, setData] = useState<AdRevenueReport | null>(null);
const [queriedLimit, setQueriedLimit] = useState<number>(500);
const [loading, setLoading] = useState(false);
const [formulaOpen, setFormulaOpen] = useState(false);
// 当前选择是否跨多天:跨多天时「按小时」无意义,粒度强制按天
const rangeMultiDay = range[0].format('YYYY-MM-DD') !== range[1].format('YYYY-MM-DD');
const load = useCallback(async () => {
const from = range[0].format('YYYY-MM-DD');
const to = range[1].format('YYYY-MM-DD');
const multiDay = from !== to;
const gran = multiDay ? 'day' : granularity; // 跨多天强制按天
setLoading(true);
try {
const res = await api.get<AdRevenueReport>('/admin/api/ad-revenue-report', {
params: {
date_from: from,
date_to: to,
user_id: userId ?? undefined,
ad_type: adType ?? undefined,
granularity: gran,
limit,
},
});
setData(res.data);
setQueriedLimit(limit);
setQueriedGranularity(gran);
setQueriedMultiDay(multiDay);
} catch (e) {
message.error(errMsg(e));
} finally {
setLoading(false);
}
}, [range, userId, adType, granularity, limit]);
useEffect(() => {
load();
// 仅首次自动拉今天;之后由「查询」按钮触发
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const columns: ColumnsType<AdRevenueRow> = [
...(queriedMultiDay
? [{ title: '日期', dataIndex: 'report_date', width: 110, fixed: 'left' } as ColumnsType<AdRevenueRow>[number]]
: []),
{ title: '用户', dataIndex: 'user_id', width: 80 },
...(queriedGranularity === 'hour'
? [
{
title: '小时',
dataIndex: 'hour',
width: 90,
render: (h: number | null) =>
h == null ? '-' : `${String(h).padStart(2, '0')}:00`,
} as ColumnsType<AdRevenueRow>[number],
]
: []),
{
title: '广告类型',
dataIndex: 'ad_type',
width: 120,
render: (s: string) => {
const t = TYPE_TAG[s] ?? { color: 'default', label: s };
return <Tag color={t.color}>{t.label}</Tag>;
},
},
{
title: '来源应用',
dataIndex: 'app_env',
width: 150,
render: (v: string | null) => {
if (!v) return <Typography.Text type="secondary"></Typography.Text>;
const t = APP_TAG[v] ?? { color: 'default', label: v };
return <Tag color={t.color}>{t.label}</Tag>;
},
},
{
title: '广告位ID',
dataIndex: 'our_code_id',
width: 120,
render: (v: string | null) =>
v ? <Typography.Text code>{v}</Typography.Text> : <Typography.Text type="secondary">-</Typography.Text>,
},
{ title: '展示条数', dataIndex: 'impressions', width: 90, align: 'right' },
{
title: '预估收益(元)',
dataIndex: 'revenue_yuan',
width: 120,
align: 'right',
render: (v: number) => v.toFixed(4),
},
{
title: '应发金币',
dataIndex: 'expected_coin',
width: 100,
align: 'right',
render: (v: number) => <b>{v}</b>,
},
{ title: '实发金币', dataIndex: 'actual_coin', width: 100, align: 'right' },
{
title: '一致',
dataIndex: 'matched',
width: 80,
align: 'center',
render: (m: boolean) => (m ? <Tag color="green"></Tag> : <Tag color="red"> </Tag>),
},
{
title: '底层 ADN',
dataIndex: 'adns',
render: (v: string[]) =>
v.length ? v.map((a) => <Tag key={a}>{a}</Tag>) : <Typography.Text type="secondary">-</Typography.Text>,
},
];
// 派生指标(全部基于全量 total_* 字段,不受 limit 截断影响,准):
// 平均 eCPM = 收益÷展示×1000;发奖成本(元)= 实发金币÷汇率;预估毛利 = 收益−发奖成本;
// 发奖占收益比 = 发奖成本÷收益;应发实发差额(金币)= 应发−实发(正=少发/负=多发)。
const derived = data
? {
avgEcpm: data.total_impressions
? (data.total_revenue_yuan / data.total_impressions) * 1000
: 0,
payoutYuan: data.total_actual_coin / COIN_PER_YUAN,
grossProfit: data.total_revenue_yuan - data.total_actual_coin / COIN_PER_YUAN,
payoutRatioPct:
data.total_revenue_yuan > 0
? (data.total_actual_coin / COIN_PER_YUAN / data.total_revenue_yuan) * 100
: null,
coinGap: data.total_expected_coin - data.total_actual_coin,
}
: null;
return (
<div>
<AdConfigPanel />
<Space align="center" style={{ display: 'flex', marginBottom: 16 }}>
<h2 style={{ margin: 0 }}></h2>
<Button
type="link"
size="small"
icon={<QuestionCircleOutlined />}
onClick={() => setFormulaOpen(true)}
>
</Button>
<Popover
placement="bottomLeft"
title="收益口径说明"
content={
<div style={{ maxWidth: 360, fontSize: 13, lineHeight: 1.7 }}>
广(onAdShow) eCPM ( ÷1000
),<b>广</b>;穿/,,
<b>穿</b> 0广ID / ,
</div>
}
>
<Button type="link" size="small" icon={<InfoCircleOutlined />}>
</Button>
</Popover>
</Space>
<Card size="small" style={{ marginBottom: 16 }}>
<Space wrap size="middle" align="center">
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<RangePicker
value={range}
onChange={(v) => v && v[0] && v[1] && setRange([v[0], v[1]])}
allowClear={false}
presets={[
{ label: '今天', value: [dayjs(), dayjs()] },
{ label: '近 7 天', value: [dayjs().subtract(6, 'day'), dayjs()] },
{ label: '近 30 天', value: [dayjs().subtract(29, 'day'), dayjs()] },
]}
/>
</Space>
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<InputNumber
placeholder="可空"
value={userId ?? undefined}
onChange={(v) => setUserId(v ?? null)}
style={{ width: 130 }}
/>
</Space>
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<Select
placeholder="全部"
value={adType}
onChange={setAdType}
allowClear
style={{ width: 150 }}
options={[
{ value: 'reward_video', label: '激励视频' },
{ value: 'feed', label: '信息流' },
{ value: 'draw', label: 'Draw 信息流' },
]}
/>
</Space>
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<Select
value={rangeMultiDay ? 'day' : granularity}
onChange={setGranularity}
disabled={rangeMultiDay}
style={{ width: 110 }}
title={rangeMultiDay ? '跨多天仅支持按天' : undefined}
options={[
{ value: 'day', label: '按天' },
{ value: 'hour', label: '按小时' },
]}
/>
</Space>
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<Select
value={limit}
onChange={setLimit}
style={{ width: 110 }}
options={[100, 200, 500, 1000].map((n) => ({ value: n, label: `${n}` }))}
/>
</Space>
<Button type="primary" onClick={load} loading={loading}>
</Button>
</Space>
</Card>
{data && derived && (
<Card size="small" style={{ marginBottom: 16 }}>
<Divider orientation="left" plain style={{ marginTop: 0, marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
广
</Typography.Text>
</Divider>
<Row gutter={[16, 12]}>
<Col span={6}>
<Statistic title="展示条数合计" value={data.total_impressions} />
</Col>
<Col span={6}>
<Statistic title="平均 eCPM(元/千次)" value={derived.avgEcpm} precision={2} />
</Col>
<Col span={6}>
<Statistic title="预估收益合计(元)" value={data.total_revenue_yuan} precision={4} />
</Col>
<Col span={6}>
<Statistic title="聚合组数" value={data.total} />
</Col>
</Row>
<Divider orientation="left" plain style={{ marginTop: 20, marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
</Typography.Text>
</Divider>
<Row gutter={[16, 12]}>
<Col span={6}>
<Statistic title="应发金币合计" value={data.total_expected_coin} />
</Col>
<Col span={6}>
<Statistic title="实发金币合计" value={data.total_actual_coin} />
</Col>
<Col span={6}>
<Statistic title="发奖成本(元)" value={derived.payoutYuan} precision={4} />
</Col>
<Col span={6}>
<Statistic
title="预估毛利(元)"
value={derived.grossProfit}
precision={4}
prefix={derived.grossProfit >= 0 ? <RiseOutlined /> : <FallOutlined />}
valueStyle={{ color: derived.grossProfit >= 0 ? '#3f8600' : '#cf1322' }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{' '}
{derived.payoutRatioPct == null ? '—' : `${derived.payoutRatioPct.toFixed(0)}%`}
</Typography.Text>
</Col>
</Row>
</Card>
)}
{data && (
<Space direction="vertical" style={{ width: '100%', marginBottom: 12 }}>
{data.mismatch_count > 0 ? (
<Alert
type="error"
showIcon
message={`${data.total} 组,其中 ${data.mismatch_count} 组应发≠实发(✗);应发−实发 合计 ${
derived && derived.coinGap >= 0 ? '+' : ''
}${derived?.coinGap ?? 0} 金币(正=少发/负=多发)——公式可能未生效或发奖有问题。定位到具体记录可用后端逐条审计接口。`}
/>
) : (
<Alert type="success" showIcon message={`${data.total} 组,应发与实发全部一致。`} />
)}
{data.truncated && (
<Alert
type="warning"
showIcon
message={`聚合明细已截断到 ${queriedLimit} 组,还有更多未显示——请按用户/类型/日期缩小范围,或调大「组数」。合计数字不受影响。`}
/>
)}
</Space>
)}
{data &&
(queriedMultiDay
? (data.daily?.length ?? 0) > 0
: queriedGranularity === 'hour' && (data.items?.length ?? 0) > 0) && (
<Card
size="small"
title={queriedMultiDay ? '按天趋势' : '按小时趋势'}
style={{ marginBottom: 16 }}
extra={
<Space size={16}>
<span style={{ fontSize: 12, color: '#666' }}>
<span
style={{
display: 'inline-block',
width: 10,
height: 10,
background: CHART_BAR,
borderRadius: 2,
marginRight: 4,
}}
/>
</span>
<span style={{ fontSize: 12, color: '#666' }}>
<span
style={{
display: 'inline-block',
width: 14,
borderTop: `2px solid ${CHART_LINE}`,
marginRight: 4,
verticalAlign: 'middle',
}}
/>
()
</span>
</Space>
}
>
{/* 按小时图由 items 聚合,会被 limit 截断;按天图用 daily(全量)不受影响,故只在按小时时警告 */}
{!queriedMultiDay && data.truncated && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 12 }}
message={`图表按小时聚合的是已截断到 ${queriedLimit} 组的明细,可能少算——调大「条数」或缩小筛选可还原真实趋势。`}
/>
)}
{queriedMultiDay ? (
<TrendChart points={aggregateDaily(data.date_from, data.date_to, data.daily)} />
) : (
<TrendChart points={aggregateHourly(data.items)} />
)}
</Card>
)}
<Table
rowKey={(r) => `${r.report_date}-${r.user_id}-${r.ad_type}-${r.app_env ?? ''}-${r.our_code_id ?? ''}-${r.hour ?? ''}`}
columns={columns}
dataSource={data?.items ?? []}
loading={loading}
pagination={false}
size="small"
scroll={{ x: 1100 }}
rowClassName={(r) => (r.matched ? '' : 'row-mismatch')}
expandable={{
rowExpandable: (r) =>
(r.impression_records?.length ?? 0) > 0 || (r.records?.length ?? 0) > 0,
expandedRowRender: (r) => {
const imps = r.impression_records ?? [];
const recs = r.records ?? [];
return (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<div>
<Typography.Text strong></Typography.Text>{' '}
<Typography.Text type="secondary">({imps.length} )</Typography.Text>
<Table<AdRevenueImpression>
style={{ marginTop: 8 }}
rowKey="id"
columns={IMPRESSION_COLUMNS}
dataSource={imps}
pagination={false}
size="small"
scroll={{ x: 700 }}
/>
</div>
<div>
<Typography.Text strong></Typography.Text>{' '}
<Typography.Text type="secondary">({recs.length} )</Typography.Text>
<Table<AdRevenueRecord>
style={{ marginTop: 8 }}
rowKey="record_id"
columns={DETAIL_COLUMNS}
dataSource={recs}
pagination={false}
size="small"
scroll={{ x: 900 }}
rowClassName={(d) => (d.matched ? '' : 'row-mismatch')}
locale={{ emptyText: '该组无发奖记录(只有展示,未发奖)' }}
/>
</div>
</Space>
);
},
}}
/>
<Modal
title="金币计算规则"
open={formulaOpen}
onCancel={() => setFormulaOpen(false)}
footer={null}
width={680}
>
<Typography.Paragraph>
<b>()</b> eCPM<sub></sub> ÷ 1000)× <b>1</b>(eCPM )× <b>2</b>(LT )
</Typography.Paragraph>
<Typography.Paragraph type="secondary" style={{ fontSize: 13 }}>
· eCPM 穿 SDK <Typography.Text code>getEcpm()</Typography.Text> ,/, ÷100 ; ÷1000
<br />· :<b>1 {COIN_PER_YUAN.toLocaleString()} </b>,
<br />· 2LT (); 1 , 10 1
<br />· ,
</Typography.Paragraph>
<Row gutter={16}>
<Col span={12}>
<Typography.Text strong>1 · eCPM(/)</Typography.Text>
<Table
style={{ marginTop: 8 }}
size="small"
pagination={false}
dataSource={ECPM_FACTOR_ROWS}
columns={[
{ title: 'eCPM 范围(元)', dataIndex: 'range' },
{ title: '因子1', dataIndex: 'factor', width: 80 },
]}
/>
</Col>
<Col span={12}>
<Typography.Text strong>2 · LT </Typography.Text>
<Table
style={{ marginTop: 8 }}
size="small"
pagination={false}
dataSource={LT_FACTOR_ROWS}
columns={[
{ title: 'LT 累计条数', dataIndex: 'lt' },
{ title: '因子2', dataIndex: 'factor', width: 80 },
]}
/>
</Col>
</Row>
<Typography.Paragraph type="secondary" style={{ fontSize: 13, marginTop: 12, marginBottom: 0 }}>
示例:eCPM 300 / 1 0.3 × 0.4 × 2.0 0.24 2,400
</Typography.Paragraph>
</Modal>
<style jsx global>{`
.row-mismatch > td {
background: #fff1f0 !important;
}
`}</style>
</div>
);
}
+55 -1
View File
@@ -28,6 +28,16 @@ const STUCK_LABEL: Record<string, string> = {
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 '';
@@ -50,6 +60,18 @@ export default function ComparisonRecordsPage() {
const [phone, setPhone] = useState('');
const [status, setStatus] = useState<string | undefined>();
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);
@@ -120,6 +142,18 @@ export default function ComparisonRecordsPage() {
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',
@@ -172,6 +206,15 @@ export default function ComparisonRecordsPage() {
/>
<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
@@ -187,7 +230,7 @@ export default function ComparisonRecordsPage() {
showTotal: (t) => `${t}`,
onChange,
}}
scroll={{ x: 1320 }}
scroll={{ x: 1520 }}
onRow={(r) => ({ onClick: () => openDetail(r.id), style: { cursor: 'pointer' } })}
/>
@@ -204,6 +247,17 @@ export default function ComparisonRecordsPage() {
<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>
+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>
);
}
@@ -0,0 +1,325 @@
'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 { formatUtcTime } 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>;
};
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,
});
message.success(`已采纳,发放 ${v.reward_coins} 金币`);
} else {
await api.post(`/admin/api/feedbacks/${feedback.id}/reject`, {
reason: v.reason.trim(),
note: v.note?.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} · {formatUtcTime(feedback.created_at)}
</Text>
<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"> · {formatUtcTime(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>
{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="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>
</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} · {formatUtcTime(h.created_at)}
</Text>
{statusTag(h.status)}
</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>
)}
</div>
))}
</Space>
)}
</div>
</Space>
)}
</Drawer>
);
}
@@ -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 意见反馈页下次进入即同步。「反馈工单」页的一个区块。 */
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>
);
}
+70 -81
View File
@@ -1,11 +1,9 @@
'use client';
import { useEffect, useState } from 'react';
import type { Key } from 'react';
import { useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import type { SorterResult } from 'antd/es/table/interface';
import {
App,
Button,
DatePicker,
Image,
@@ -17,11 +15,12 @@ import {
Typography,
} from 'antd';
import type { Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { formatUtcTime } from '@/lib/format';
import { usePagedList } from '@/lib/usePagedList';
import type { Feedback } from '@/lib/types';
import FeedbackQrConfig from './FeedbackQrConfig';
import FeedbackHandleDrawer from './FeedbackHandleDrawer';
const { Text, Paragraph } = Typography;
const { RangePicker } = DatePicker;
@@ -32,8 +31,17 @@ 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' },
};
export default function FeedbacksPage() {
const { message, modal } = App.useApp();
// 筛选草稿:点「查询」才应用(避免输入即刷新)
const [status, setStatus] = useState<string | undefined>();
const [userId, setUserId] = useState('');
@@ -45,17 +53,18 @@ export default function FeedbacksPage() {
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const filters: Record<string, unknown> = { ...applied, sort_by: sortBy, sort_order: sortOrder };
const filterKey = JSON.stringify(filters);
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
usePagedList<Feedback>('/admin/api/feedbacks', filters);
const canHandle = canDo(['operator']);
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
const canReview = canDo(['operator']);
// 筛选/排序变化后清空已选(避免选中项跨筛选错位)
useEffect(() => {
setSelectedRowKeys([]);
}, [filterKey]);
// 审核/查看抽屉(采纳发金币 / 拒绝填原因 + 看该用户历史反馈)
const [drawerFb, setDrawerFb] = useState<Feedback | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const openDrawer = (f: Feedback) => {
setDrawerFb(f);
setDrawerOpen(true);
};
const search = () =>
setApplied({
@@ -94,50 +103,13 @@ export default function FeedbacksPage() {
const sortOrderOf = (field: SortField) =>
sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null;
const handle = async (f: Feedback) => {
try {
await api.post(`/admin/api/feedbacks/${f.id}/handle`);
message.success('已标记处理');
reload();
} catch (e) {
message.error(errMsg(e));
}
};
const selectedNew = items.filter(
(f) => selectedRowKeys.includes(f.id) && f.status === 'new',
);
// 批量:循环调现有逐条 /handle 接口(各自已带审计),Promise.allSettled 汇总成败,不新增 bulk 端点
const bulkHandle = () => {
if (!selectedNew.length) {
message.warning('没有可处理的选中项(仅待处理 new 态生效)');
return;
}
modal.confirm({
title: `确认批量标记处理 ${selectedNew.length} 条反馈?`,
content: '仅对选中的待处理(new)反馈生效',
onOk: async () => {
const results = await Promise.allSettled(
selectedNew.map((f) => api.post(`/admin/api/feedbacks/${f.id}/handle`)),
);
const ok = results.filter((r) => r.status === 'fulfilled').length;
const fail = results.length - ok;
if (fail) message.warning(`批量标记处理完成:成功 ${ok}/${results.length},失败 ${fail}`);
else message.success(`批量标记处理完成:${ok}`);
setSelectedRowKeys([]);
reload();
},
});
};
const columns: ColumnsType<Feedback> = [
{ title: 'ID', dataIndex: 'id', width: 70, sorter: true, sortOrder: sortOrderOf('id') },
{ title: '用户', dataIndex: 'user_id', width: 80 },
{
title: '内容',
dataIndex: 'content',
width: 360,
width: 340,
render: (v: string) =>
v ? (
<Paragraph
@@ -153,7 +125,7 @@ export default function FeedbacksPage() {
{
title: '截图',
dataIndex: 'images',
width: 180,
width: 160,
render: (imgs: string[] | null) =>
imgs && imgs.length ? (
<Image.PreviewGroup>
@@ -173,17 +145,46 @@ export default function FeedbacksPage() {
<Text type="secondary"></Text>
),
},
{ title: '联系方式', dataIndex: 'contact', width: 140, render: (v: string) => v || '-' },
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s: string) => <Tag color={s === 'new' ? 'orange' : 'green'}>{s}</Tag>,
render: (s: string) => {
const m = STATUS_META[s] ?? { label: s, color: 'default' };
return <Tag color={m.color}>{m.label}</Tag>;
},
},
{
title: '审核结果',
key: 'review',
width: 170,
render: (_: unknown, 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}
</Space>
);
}
if (f.status === 'rejected') {
return (
<Text type="secondary" style={{ fontSize: 12 }} ellipsis>
:{f.reject_reason || '-'}
</Text>
);
}
return <Text type="secondary">-</Text>;
},
},
{
title: '时间',
dataIndex: 'created_at',
width: 180,
width: 170,
sorter: true,
sortOrder: sortOrderOf('created_at'),
render: (v: string) => formatUtcTime(v),
@@ -191,12 +192,12 @@ export default function FeedbacksPage() {
{
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>
),
},
];
@@ -204,6 +205,7 @@ export default function FeedbacksPage() {
return (
<div>
<h2></h2>
<FeedbackQrConfig />
<Space style={{ marginBottom: 16 }} wrap>
<Select
placeholder="状态"
@@ -212,8 +214,9 @@ export default function FeedbacksPage() {
allowClear
style={{ width: 120 }}
options={[
{ value: 'new', label: '待处理' },
{ value: 'handled', label: '已处理' },
{ value: 'pending', label: '审核中' },
{ value: 'adopted', label: '已采纳' },
{ value: 'rejected', label: '未采纳' },
]}
/>
<Input
@@ -244,29 +247,8 @@ export default function FeedbacksPage() {
<Button onClick={resetFilters}></Button>
</Space>
{canHandle && selectedNew.length > 0 && (
<Space style={{ marginBottom: 12, width: '100%' }} wrap>
<span style={{ color: '#888' }}> {selectedNew.length} </span>
<Button type="primary" onClick={bulkHandle}>
</Button>
<Button type="link" onClick={() => setSelectedRowKeys([])}>
</Button>
</Space>
)}
<Table
rowKey="id"
rowSelection={
canHandle
? {
selectedRowKeys,
onChange: setSelectedRowKeys,
getCheckboxProps: (f) => ({ disabled: f.status !== 'new' }),
}
: undefined
}
columns={columns}
dataSource={items}
loading={loading}
@@ -280,6 +262,13 @@ export default function FeedbacksPage() {
}}
onChange={onTableChange}
/>
<FeedbackHandleDrawer
feedback={drawerFb}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
onDone={reload}
/>
</div>
);
}
+5 -1
View File
@@ -7,10 +7,12 @@ import {
DashboardOutlined,
FileSearchOutlined,
FlagOutlined,
HeartOutlined,
LogoutOutlined,
MessageOutlined,
MobileOutlined,
MoneyCollectOutlined,
NotificationOutlined,
ProfileOutlined,
SettingOutlined,
ShareAltOutlined,
@@ -27,11 +29,13 @@ const MENU = [
{ key: '/dashboard', icon: <DashboardOutlined />, label: '数据大盘' },
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
{ key: '/devices', icon: <MobileOutlined />, label: '设备管理' },
{ key: '/device-liveness', icon: <HeartOutlined />, label: '设备存活' },
{ key: '/withdraws', icon: <MoneyCollectOutlined />, label: '提现管理' },
{ key: '/price-reports', icon: <FlagOutlined />, label: '上报审核' },
{ key: '/comparison-records', icon: <ProfileOutlined />, label: '比价记录' },
{ key: '/feedbacks', icon: <MessageOutlined />, label: '反馈工单' },
{ key: '/ad-revenue', icon: <BarChartOutlined />, label: '广告管理' },
{ key: '/ad-revenue', icon: <NotificationOutlined />, label: '广告配置' },
{ key: '/ad-revenue-report', icon: <BarChartOutlined />, label: '广告收益' },
{ key: '/cps', icon: <ShareAltOutlined />, label: 'CPS 分发' },
{ key: '/config', icon: <SettingOutlined />, label: '系统配置' },
{ key: '/admins', icon: <TeamOutlined />, label: '管理员', superOnly: true },
+2 -3
View File
@@ -20,6 +20,7 @@ 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';
@@ -30,9 +31,7 @@ const { Text } = Typography;
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)}`);
// 截图是 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);
// 截图 URL 解析见 @/lib/media(dev 指向 :8770,生产走 nginx /media 同域反代)
const STATUS_COLOR: Record<string, string> = {
pending: 'gold',
+31
View File
@@ -131,6 +131,28 @@ export default function UsersPage() {
});
};
// 一次性操作:点一下把该用户标记为「未看过引导」,下次打开 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');
@@ -238,6 +260,15 @@ export default function UsersPage() {
{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>
))}
</Space>
),
},
@@ -0,0 +1,222 @@
'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;
}
/** 提现详情抽屉:用户基本信息 + 互斥时间筛选 + 看广告/提现统计区 + 金币发放记录表。 */
export default function UserRewardPanel({ userId, user }: 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>
{/* 统计区(受时间筛选;现金余额为当前快照) */}
<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>
<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 }}>
(,);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>
);
}
+37 -138
View File
@@ -38,18 +38,19 @@ import {
import dayjs, { type Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { formatUtcTime, formatWallTime, utcDayjs, utcFromNow, yuan } from '@/lib/format';
import { 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';
import UserRewardPanel from './UserRewardPanel';
const { Text } = Typography;
const { RangePicker } = DatePicker;
@@ -101,19 +102,12 @@ 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' },
];
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>;
@@ -155,9 +149,10 @@ function riskTags(detail: WithdrawDetail | null) {
if (detail.user?.status && detail.user.status !== 'active') tags.push(`账号状态:${detail.user.status}`);
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;
}
@@ -186,9 +181,7 @@ export default function WithdrawsPage() {
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);
@@ -203,7 +196,7 @@ export default function WithdrawsPage() {
filters.sort_order = sortOrder;
const filterKey = JSON.stringify(filters);
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
usePagedList<WithdrawOrder>('/admin/api/withdraws', filters, 20);
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');
@@ -469,37 +462,37 @@ 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: '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',
@@ -512,7 +505,7 @@ export default function WithdrawsPage() {
),
},
{
title: '提单号',
title: '提单号',
dataIndex: 'out_bill_no',
ellipsis: true,
},
@@ -527,7 +520,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 (
@@ -576,37 +569,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 || '-' },
// 现金流水是北京 wall-clock 口径,原样显示,不能当 UTC 再 +8
{ title: '时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatWallTime(v) },
];
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
? [
{
@@ -704,34 +666,14 @@ export default function WithdrawsPage() {
)}
<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>
@@ -772,9 +714,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);
@@ -821,9 +763,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}
@@ -919,7 +859,7 @@ export default function WithdrawsPage() {
<Drawer
title="提现详情"
width={720}
width="50%"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
@@ -958,29 +898,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) => (
@@ -998,28 +919,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>
+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;
}
+108 -13
View File
@@ -30,6 +30,8 @@ export interface UserListItem {
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;
@@ -42,6 +44,39 @@ export interface DeviceOnboardingItem {
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;
@@ -90,6 +125,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;
@@ -124,6 +167,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;
@@ -172,10 +239,27 @@ export interface Feedback {
content: string;
contact: string;
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; // 审核批注(采纳要点 / 内部备注)
reviewed_by_admin_id: number | null;
reviewed_at: string | null;
created_at: string;
}
// 反馈页「加群二维码」卡配置(运营后台改 → 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 {
id: number;
admin_id: number;
@@ -210,6 +294,8 @@ export interface ComparisonRecordListItem {
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;
@@ -288,35 +374,44 @@ export interface AdRevenueDaily {
actual_coin: number;
}
// 广告收益报表:按 日期 × 用户 × 广告类型 × 应用 × 代码位 聚合
// 广告收益报表:一次广告事件(逐条一行)。激励视频展示+发奖按 ad_session_id 合并;信息流展示/发奖各自成行。
export interface AdRevenueRow {
report_date: string; // 该组所属日期 北京时间 YYYY-MM-DD
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
app_env: string | null; // prod(傻瓜比价) / test(测试);旧数据为空
our_code_id: string | null; // 我们配置的代码位 104xxx;旧数据为空
hour: number | null; // 北京时间小时 0–23(按小时粒度时有值;按天为 null)
impressions: number; // 展示条数(轮播每条各计一次)
revenue_yuan: number; // 收益(元),预估口径
expected_coin: number; // 应发金币(公式复算,与金币审计同源)
actual_coin: number; // 实发金币(实际入账)
matched: boolean; // 该组应发==实发
adns: string[]; // 底层填充 ADN 子渠道集合
impression_records: AdRevenueImpression[]; // 该组逐条展示明细(展开下钻)
records: AdRevenueRecord[]; // 该组逐条发奖复算明细(展开下钻)
created_at: string; // 事件时间(有展示=展示时间,纯发奖=发奖时间)
// ── 展示侧 ──
has_impression: boolean; // 是否有广告展示(信息流逐条展示=true,纯发奖行=false)
impressions: number; // 本行展示条数 1/0(供日汇总、趋势图复用)
ecpm: string | null; // 分/千次;展示行取展示值,纯发奖行取发奖采用值
revenue_yuan: number; // 本次展示预估收益(元);纯发奖行=0
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; // 发奖复算明细(点行展开下钻);纯展示为空
}
export interface AdRevenueReport {
date_from: string; // 起始日 北京时间 YYYY-MM-DD
date_to: string; // 结束日 北京时间 YYYY-MM-DD(闭区间;单日时与 date_from 相同)
daily: AdRevenueDaily[]; // 按日期汇总序列(全量,供按天趋势图)
total: number; // 聚合组总数(全量,不受 limit 影响)
total: number; // 广告事件总数(全量,不受 limit 影响)
truncated: boolean;
total_impressions: number;
total_revenue_yuan: number;
total_expected_coin: number;
total_actual_coin: number;
mismatch_count: number; // 应发≠实发的
mismatch_count: number; // 应发≠实发的发奖条
items: AdRevenueRow[];
}