Compare commits

..

1 Commits

Author SHA1 Message Date
zzhyyyyy c83c1421f5 feat(analytics): 新增「埋点日志」管理页
- src/app/(main)/event-logs/page.tsx:埋点日志列表,五维列 + 展开行看 props 扩展字段,
  按 事件/设备/用户/会话/接收时间 筛选,自动刷新(5s),接收时间列置首
- layout 左侧菜单加「埋点日志」入口
- 对接 admin 后端 GET /admin/api/event-logs(见 app-server 同批 PR)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:08:51 +08:00
19 changed files with 363 additions and 4548 deletions
@@ -1,746 +0,0 @@
# CPS 群详情 · 每日明细「按天 · 按用户」领券下钻 — 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在 CPS 群详情页每日明细表每行加「明细」按钮,点开弹窗展示该群该天以用户为单位的领券/点击,并在「点击次数」上悬停展示该用户当天点过的优惠券(券名 ×次数)。
**Architecture:** 后端 `shaguabijia-app-server` 新增只读聚合接口 `/admin/api/cps/groups/{id}/day-users?date=YYYY-MM-DD`(券明细内联返回),并把既有 `/daily``date``MM-DD` 改全为 `YYYY-MM-DD` 供下钻定位整天。前端 `shaguabijia-admin-web` 加明细列 + Modal + Tooltip,一次请求渲染。纯读侧聚合,**不动数据库**。
**Tech Stack:** 后端 FastAPI + SQLAlchemy + pytest`.venv`);前端 Next.js 15 + Ant Design 5 + TypeScript(无测试框架,门槛为 `npm run build`)。
**配套 spec** `docs/superpowers/specs/2026-06-26-cps-day-user-drilldown-design.md`
---
## 仓库与分支
- **前端** `e:\project\shaguabijia-admin-web`:已在分支 `feat/cps-day-user-drilldown`
- **后端** `e:\project\shaguabijia-app-server`:独立 git 仓,需自建同名分支(Task 1 Step 0)。
后端命令统一**从后端仓根目录运行**;git 用 `git -C e:\project\shaguabijia-app-server ...` 避免切目录。
## File Structure
| 文件 | 改动 | 职责 |
|---|---|---|
| `app/admin/routers/cps.py`(后端) | 改 1 行 + 加 1 个 endpoint | `/daily``date` 改全日期;新增 `/day-users` 路由(鉴权、日期解析、窗口计算) |
| `app/admin/repositories/cps.py`(后端) | 加 1 个函数 + 补 1 处 import | `group_day_users(...)`:按 openid 聚合领券/点击 + 券明细(Python 侧) |
| `tests/test_cps_admin.py`(后端) | 新建 | admin CPS 端点测试:`/daily` 日期格式 + `/day-users` 聚合与边界 |
| `src/app/(main)/cps/groups/[id]/page.tsx`(前端) | 改 | 日期列加宽、明细列、Modal、Tooltip、按用户表 |
---
## Task 1: 后端 `/daily` 的 `date` 改为 `YYYY-MM-DD`
**Files:**
- Modify: `app/admin/routers/cps.py:472``group_daily` 内构行处)
- Test: `tests/test_cps_admin.py`(新建)
- [ ] **Step 0: 在后端仓建分支**
先看工作区状态,避免把无关改动带入:
Run:
```
git -C e:\project\shaguabijia-app-server status --short
git -C e:\project\shaguabijia-app-server checkout -b feat/cps-day-user-drilldown
```
Expected: 切到新分支 `feat/cps-day-user-drilldown`
- [ ] **Step 1: 写失败测试(新建测试文件,含共用 fixtures + 种子)**
新建 `tests/test_cps_admin.py`,内容如下(fixtures 仿 `tests/test_admin_read.py` 自包含;种子函数后续 Task 复用):
```python
"""admin CPS 端点测试:每日明细 date 格式 + 按天按用户领券下钻(/day-users)。"""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from app.admin.main import admin_app
from app.admin.repositories import admin_user as admin_repo
from app.admin.repositories import cps as cps_repo
from app.db.session import SessionLocal
from app.models.cps_link import CpsClick
from app.models.cps_wx_user import CpsWxUser
from app.repositories import cps_link as cps_link_repo
_BJ = timezone(timedelta(hours=8))
@pytest.fixture()
def admin_client() -> TestClient:
return TestClient(admin_app)
@pytest.fixture()
def admin_token() -> str:
db = SessionLocal()
try:
if admin_repo.get_by_username(db, "cps_test_admin") is None:
admin_repo.create_admin(
db, username="cps_test_admin", password="cps-pass", role="super_admin"
)
finally:
db.close()
c = TestClient(admin_app)
r = c.post("/admin/api/auth/login", json={"username": "cps_test_admin", "password": "cps-pass"})
return r.json()["access_token"]
def _auth(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
def _seed_cps_day(day_bj: date) -> tuple[int, str]:
"""造淘宝群 + 2 个活动(券) + 2 条 link + day_bj 当天点击(含匿名 + 次日各 1) + 授权画像。
o_user(本群唯一):券A visit×3、券B visit×2、copy×2 → visit_count=5、copy_count=2。
另造 1 条匿名 visit(openid=None)与 1 条次日 visit,均不应计入当天该用户聚合。
clicked_at 统一存 tz-aware UTC(SQLite DateTime 按字段渲染、忽略 tzinfo;
转 UTC 后字段即 UTC 墙钟,与 repo 侧 _as_utc 比较口径一致)。返回 (group_id, openid)。
"""
db = SessionLocal()
try:
gid = cps_repo.create_group(db, name="下钻测试群", platforms=["taobao"]).id
openid = f"od_user_{gid}" # 按群唯一,规避 CpsWxUser.openid 唯一约束跨用例冲突
a1 = cps_repo.create_activity(db, name="618神券", platform="taobao", payload="tkl-1")
a2 = cps_repo.create_activity(db, name="买一送一", platform="taobao", payload="tkl-2")
link1 = cps_link_repo.create_link(
db, group_id=gid, activity_id=a1.id, sid=None, target_url="t1", platform="taobao"
)
link2 = cps_link_repo.create_link(
db, group_id=gid, activity_id=a2.id, sid=None, target_url="t2", platform="taobao"
)
db.add(CpsWxUser(openid=openid, nickname="张三", headimgurl="https://h/1"))
db.commit()
def at(hour: int) -> datetime:
return datetime(day_bj.year, day_bj.month, day_bj.day, hour, tzinfo=_BJ).astimezone(
timezone.utc
)
for _ in range(3):
db.add(CpsClick(link_id=link1.id, group_id=gid, sid=None,
event_type="visit", openid=openid, clicked_at=at(10)))
for _ in range(2):
db.add(CpsClick(link_id=link2.id, group_id=gid, sid=None,
event_type="visit", openid=openid, clicked_at=at(11)))
for _ in range(2):
db.add(CpsClick(link_id=link1.id, group_id=gid, sid=None,
event_type="copy", openid=openid, clicked_at=at(12)))
# 匿名点击 — 不计入
db.add(CpsClick(link_id=link1.id, group_id=gid, sid=None,
event_type="visit", openid=None, clicked_at=at(13)))
# 次日点击 — 验证时间窗,不计入当天
nxt = (datetime(day_bj.year, day_bj.month, day_bj.day, 10, tzinfo=_BJ)
+ timedelta(days=1)).astimezone(timezone.utc)
db.add(CpsClick(link_id=link1.id, group_id=gid, sid=None,
event_type="visit", openid=openid, clicked_at=nxt))
db.commit()
return gid, openid
finally:
db.close()
def test_daily_date_is_full_iso(admin_client: TestClient, admin_token: str) -> None:
"""/daily 每行 date 改为 YYYY-MM-DD(10 字符、两个连字符),不再是 MM-DD。"""
gid, _ = _seed_cps_day(date(2026, 6, 25))
r = admin_client.get(
f"/admin/api/cps/groups/{gid}/daily", params={"days": 3}, headers=_auth(admin_token)
)
assert r.status_code == 200, r.text
rows = r.json()["rows"]
assert rows, "应有按天补零行"
for row in rows:
assert len(row["date"]) == 10 and row["date"].count("-") == 2, row["date"]
```
- [ ] **Step 2: 跑测试确认失败**
Run(从后端仓根目录 `e:\project\shaguabijia-app-server`:
```
.\.venv\Scripts\pytest.exe tests\test_cps_admin.py::test_daily_date_is_full_iso -v
```
Expected: FAIL —— `date` 当前为 `"%m-%d"`(长度 5),断言 `len==10` 不通过。
- [ ] **Step 3: 改 router 的日期格式**
`app/admin/routers/cps.py``group_daily` 内(约 472 行),把构行的日期字段改为全日期:
```python
row = {
"date": cur.strftime("%Y-%m-%d"),
"click_pv": cp["click_pv"] if cp else 0,
"click_uv": cp["click_uv"] if cp else 0,
"copy_pv": cp["copy_pv"] if cp else 0,
}
```
(仅把原 `cur.strftime("%m-%d")` 改成 `cur.strftime("%Y-%m-%d")`,其余不动。)
- [ ] **Step 4: 跑测试确认通过**
Run:
```
.\.venv\Scripts\pytest.exe tests\test_cps_admin.py::test_daily_date_is_full_iso -v
```
Expected: PASS。
- [ ] **Step 5: 提交(后端仓)**
```
git -C e:\project\shaguabijia-app-server add app/admin/routers/cps.py tests/test_cps_admin.py
git -C e:\project\shaguabijia-app-server commit -m "feat(cps): /daily 的 date 改为 YYYY-MM-DD(下钻定位整天用)"
```
---
## Task 2: 后端新增 `group_day_users` 聚合 + `/day-users` 接口
**Files:**
- Modify: `app/admin/repositories/cps.py:20`import 补 `CpsLink`)、文件末尾加函数
- Modify: `app/admin/routers/cps.py``group_wx_users` 路由后追加新路由)
- Test: `tests/test_cps_admin.py`(追加)
- [ ] **Step 1: 写失败测试(聚合 happy path,含匿名/跨天排除、券倒序、合计自洽)**
`tests/test_cps_admin.py` 末尾追加:
```python
def test_day_users_aggregates(admin_client: TestClient, admin_token: str) -> None:
"""当天该群:仅授权用户;领券=copy、点击=visit;coupons=visit 券×次数倒序、合计=点击次数。"""
gid, openid = _seed_cps_day(date(2026, 6, 25))
r = admin_client.get(
f"/admin/api/cps/groups/{gid}/day-users",
params={"date": "2026-06-25"},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
body = r.json()
assert body["group_id"] == gid
assert body["date"] == "2026-06-25"
users = body["users"]
assert len(users) == 1 # 匿名不计、次日不计
u = users[0]
assert u["openid"] == openid
assert u["nickname"] == "张三"
assert u["headimgurl"] == "https://h/1"
assert u["copy_count"] == 2
assert u["visit_count"] == 5
assert [c["name"] for c in u["coupons"]] == ["618神券", "买一送一"]
assert [c["count"] for c in u["coupons"]] == [3, 2]
assert sum(c["count"] for c in u["coupons"]) == u["visit_count"]
```
- [ ] **Step 2: 跑测试确认失败**
Run:
```
.\.venv\Scripts\pytest.exe tests\test_cps_admin.py::test_day_users_aggregates -v
```
Expected: FAIL —— 404/路由不存在(`/day-users` 尚未实现)。
- [ ] **Step 3: 补 repo import**
`app/admin/repositories/cps.py` 第 20 行,给 `cps_link` 的 import 补上 `CpsLink`
```python
from app.models.cps_link import CpsClick, CpsLink
```
- [ ] **Step 4: 加 repo 聚合函数**
`app/admin/repositories/cps.py` 末尾(`group_wx_users` 之后)追加:
```python
def group_day_users(
db: Session, *, group_id: int, start: datetime, end: datetime, limit: int = 200,
) -> list[dict]:
"""该群某天(北京)以用户为单位的领券/点击 + 每人 visit 过的券。
时间窗为半开区间 [start, end)(end=次日 00:00),避免午夜双计。只统计 openid 非空
(可归属到人)的点击 —— 匿名点击(美团/京东 302 多为匿名)不计入。券名 = 该点击 link
对应活动名;活动被硬删则兜底 活动#{id}。copy=领券次数、visit=点击次数;coupons 仅
取 visit 事件按活动分组、按次数倒序(合计 = visit_count)。排序:领券 desc、再点击 desc。
与 group_wx_users 同风格(Python 侧聚合,跨 PG/SQLite 无方言坑)。
注:每日明细行的 click_pv/copy_pv 计全部点击(含匿名、UV 按 ip,ua);本函数只计 openid
用户,故各用户求和 <= 当天行总数,二者口径不同、不必相等。
"""
rows = db.execute(
select(CpsClick.openid, CpsClick.event_type, CpsClick.link_id)
.where(CpsClick.group_id == group_id)
.where(CpsClick.clicked_at >= _as_utc(start))
.where(CpsClick.clicked_at < _as_utc(end))
.where(CpsClick.openid.is_not(None))
).all()
if not rows:
return []
# link_id -> activity_id -> 券名(活动名)
link_ids = {r.link_id for r in rows}
link_to_act = dict(
db.execute(
select(CpsLink.id, CpsLink.activity_id).where(CpsLink.id.in_(link_ids))
).all()
)
act_ids = {aid for aid in link_to_act.values() if aid is not None}
act_name = (
dict(
db.execute(
select(CpsActivity.id, CpsActivity.name).where(CpsActivity.id.in_(act_ids))
).all()
)
if act_ids
else {}
)
def _coupon_name(link_id: int) -> str:
aid = link_to_act.get(link_id)
if aid is None:
return f"链接#{link_id}"
return act_name.get(aid) or f"活动#{aid}"
stat: dict[str, dict] = {}
for openid, event_type, link_id in rows:
s = stat.setdefault(openid, {"copy": 0, "visit": 0, "coupons": {}})
if event_type == "copy":
s["copy"] += 1
else:
s["visit"] += 1
name = _coupon_name(link_id)
s["coupons"][name] = s["coupons"].get(name, 0) + 1
openids = list(stat.keys())
users = {
u.openid: u
for u in db.execute(
select(CpsWxUser).where(CpsWxUser.openid.in_(openids))
).scalars().all()
}
result = [
{
"openid": openid,
"nickname": users[openid].nickname if openid in users else None,
"headimgurl": users[openid].headimgurl if openid in users else None,
"copy_count": s["copy"],
"visit_count": s["visit"],
"coupons": [
{"name": name, "count": cnt}
for name, cnt in sorted(
s["coupons"].items(), key=lambda kv: kv[1], reverse=True
)
],
}
for openid, s in stat.items()
]
result.sort(key=lambda x: (x["copy_count"], x["visit_count"]), reverse=True)
return result[:limit]
```
- [ ] **Step 5: 加 router 端点**
`app/admin/routers/cps.py`,在 `group_wx_users` 路由(文件末尾那个)之后追加:
```python
@router.get("/groups/{group_id}/day-users", summary="某天该群按用户的领券/点击 + 每人点过的券")
def group_day_users(
group_id: int,
db: AdminDb,
date: Annotated[str, Query(description="北京日期 YYYY-MM-DD")],
) -> dict:
group = cps_repo.get_group(db, group_id)
if group is None:
raise HTTPException(status_code=404, detail="群不存在")
bj = timezone(timedelta(hours=8))
try:
day0 = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=bj)
except ValueError as e:
raise HTTPException(status_code=400, detail="date 格式应为 YYYY-MM-DD") from e
start = day0.replace(hour=0, minute=0, second=0, microsecond=0)
end = start + timedelta(days=1)
users = cps_repo.group_day_users(db, group_id=group_id, start=start, end=end)
return {
"group_id": group.id,
"group_name": group.name,
"date": date,
"users": users,
}
```
`datetime`/`timedelta`/`timezone``Annotated``Query``HTTPException` 该文件均已 import;无需新增。)
- [ ] **Step 6: 跑测试确认通过**
Run:
```
.\.venv\Scripts\pytest.exe tests\test_cps_admin.py::test_day_users_aggregates -v
```
Expected: PASS。
- [ ] **Step 7: 提交(后端仓)**
```
git -C e:\project\shaguabijia-app-server add app/admin/repositories/cps.py app/admin/routers/cps.py tests/test_cps_admin.py
git -C e:\project\shaguabijia-app-server commit -m "feat(cps): 新增 /day-users 按天按用户领券下钻接口"
```
---
## Task 3: 后端 `/day-users` 边界用例(404 / 400 / 401 / 空天 / 跨年)
**Files:**
- Test: `tests/test_cps_admin.py`(追加)
> 这些断言验证 Task 2 实现已覆盖的边界。多数应直接通过;若某条失败,回到 Task 2 对应分支修实现。
- [ ] **Step 1: 追加边界测试**
`tests/test_cps_admin.py` 末尾追加:
```python
def test_day_users_group_not_found(admin_client: TestClient, admin_token: str) -> None:
r = admin_client.get(
"/admin/api/cps/groups/999999/day-users",
params={"date": "2026-06-25"},
headers=_auth(admin_token),
)
assert r.status_code == 404
def test_day_users_bad_date(admin_client: TestClient, admin_token: str) -> None:
gid, _ = _seed_cps_day(date(2026, 6, 25))
r = admin_client.get(
f"/admin/api/cps/groups/{gid}/day-users",
params={"date": "2026/06/25"}, # 非 YYYY-MM-DD
headers=_auth(admin_token),
)
assert r.status_code == 400
def test_day_users_requires_auth(admin_client: TestClient) -> None:
r = admin_client.get(
"/admin/api/cps/groups/1/day-users", params={"date": "2026-06-25"}
)
assert r.status_code == 401
def test_day_users_empty_when_no_clicks(admin_client: TestClient, admin_token: str) -> None:
gid, _ = _seed_cps_day(date(2026, 6, 25))
r = admin_client.get(
f"/admin/api/cps/groups/{gid}/day-users",
params={"date": "2026-06-20"}, # 该群当天无任何点击
headers=_auth(admin_token),
)
assert r.status_code == 200
assert r.json()["users"] == []
def test_day_users_cross_year(admin_client: TestClient, admin_token: str) -> None:
"""跨年:YYYY-MM-DD 才能精确定位 12-31(MM-DD 会丢年份);次日(次年 01-01)不计入。"""
gid, openid = _seed_cps_day(date(2025, 12, 31))
r = admin_client.get(
f"/admin/api/cps/groups/{gid}/day-users",
params={"date": "2025-12-31"},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
users = r.json()["users"]
assert len(users) == 1
assert users[0]["openid"] == openid
assert users[0]["visit_count"] == 5 # 次年 01-01 那条被时间窗排除
```
- [ ] **Step 2: 跑整个测试文件确认全绿**
Run:
```
.\.venv\Scripts\pytest.exe tests\test_cps_admin.py -v
```
Expected: 全部 PASS(共 7 个用例:daily 日期、aggregates、404、bad_date、auth、empty、cross_year)。
- [ ] **Step 3: 跑后端全量回归(确认没碰坏既有)**
Run:
```
.\.venv\Scripts\pytest.exe -q
```
Expected: 全绿(与改动前同样的通过数 + 新增 7 条)。
- [ ] **Step 4: 提交(后端仓)**
```
git -C e:\project\shaguabijia-app-server add tests/test_cps_admin.py
git -C e:\project\shaguabijia-app-server commit -m "test(cps): /day-users 边界用例(404/400/401/空天/跨年)"
```
---
## Task 4: 前端日期列显示全日期并加宽
**Files:**
- Modify: `src/app/(main)/cps/groups/[id]/page.tsx`
前端无测试框架,门槛为类型/构建通过(`npm run build`)。
- [ ] **Step 1: 加宽日期列**
`dailyColumns` 第一列(约 174 行)`width: 64``width: 96`
```tsx
{ title: '日期', dataIndex: 'date', width: 96, fixed: 'left' },
```
`date` 现由后端给全 `YYYY-MM-DD`,直接显示即可;`DailyRow.date` 类型仍是 `string`,无需改接口。)
- [ ] **Step 2: 构建确认通过**
Run(前端仓根目录):
```
npm run build
```
Expected: 构建成功,无类型错误。
- [ ] **Step 3: 提交(前端仓)**
```
git add src/app/(main)/cps/groups/[id]/page.tsx
git commit -m "feat(cps): 每日明细日期列改显示全日期并加宽"
```
---
## Task 5: 前端下钻 — 明细列 + Modal + Tooltip
**Files:**
- Modify: `src/app/(main)/cps/groups/[id]/page.tsx`
- [ ] **Step 1: antd 引入 Modal、Tooltip**
第 9 行 import 增加 `Modal, Tooltip`(保持字母序即可):
```tsx
import { Button, Card, Col, Empty, Modal, Row, Segmented, Space, Spin, Statistic, Table, Tooltip, message } from 'antd';
```
- [ ] **Step 2: 加按天用户类型**
`WxUser` 接口定义之后(约 58 行后)追加:
```tsx
// 某天该群按用户领券下钻(/day-users)
interface DayCoupon {
name: string;
count: number;
}
interface DayUser {
openid: string;
nickname: string | null;
headimgurl: string | null;
copy_count: number;
visit_count: number;
coupons: DayCoupon[];
}
```
- [ ] **Step 3: 加弹窗状态 + 拉取 handler**
在组件内 `wxUsers` 相关 `useEffect` 之后(约 140 行后)追加。注意须在 `dailyColumns` 定义之前,供其按钮引用:
```tsx
// 某天领券下钻弹窗
const [dayDetail, setDayDetail] = useState<{
open: boolean;
date: string;
loading: boolean;
users: DayUser[];
}>({ open: false, date: '', loading: false, users: [] });
const openDayDetail = useCallback(
async (date: string) => {
setDayDetail({ open: true, date, loading: true, users: [] });
try {
const r = await api.get<{ users: DayUser[] }>(
`/admin/api/cps/groups/${id}/day-users?date=${date}`,
);
setDayDetail((s) => ({ ...s, loading: false, users: r.data.users || [] }));
} catch (e) {
message.error(errMsg(e, '明细加载失败'));
setDayDetail((s) => ({ ...s, loading: false }));
}
},
[id],
);
```
- [ ] **Step 4: 每日明细表加「明细」列 + 调大 scroll.x**
`dailyColumns` 数组末尾(`结算佣金` 列之后,约 226 行)追加一列:
```tsx
{
title: '明细',
key: 'drill',
width: 64,
fixed: 'right',
render: (_, r) => (
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => openDayDetail(r.date)}>
</Button>
),
},
```
同时,每日明细 `<Table>`(约 304–311 行)列宽合计已升至约 976(日期 96 + 新明细列 64),把其 `scroll={{ x: 900 }}` 改为 `scroll={{ x: 1000 }}`,保证右侧固定列与横向滚动正常:
```tsx
scroll={{ x: 1000 }}
```
- [ ] **Step 5: 加按用户表列定义**
`wxColumns` 定义之后(约 259 行后)追加 `dayUserColumns`
```tsx
const dayUserColumns: ColumnsType<DayUser> = [
{
title: '用户',
key: 'user',
render: (_, u) => (
<Space>
{u.headimgurl ? (
<img
src={u.headimgurl}
alt=""
style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }}
/>
) : (
<span
style={{ width: 28, height: 28, borderRadius: '50%', background: '#f0f0f0', display: 'inline-block' }}
/>
)}
<span>{u.nickname || '(未授权昵称)'}</span>
</Space>
),
},
{
title: '领券次数',
dataIndex: 'copy_count',
width: 100,
sorter: (a, b) => a.copy_count - b.copy_count,
render: (v: number) => (v ? <b style={{ color: '#ff4d4f' }}>{v}</b> : 0),
},
{
title: '点击次数',
dataIndex: 'visit_count',
width: 100,
render: (v: number, u) =>
u.coupons.length ? (
<Tooltip
title={
<div>
{u.coupons.map((c) => (
<div key={c.name}>
{c.name} ×{c.count}
</div>
))}
</div>
}
>
<span style={{ cursor: 'help', textDecoration: 'underline dotted' }}>{v}</span>
</Tooltip>
) : (
v
),
},
];
```
- [ ] **Step 6: 渲染 Modal**
在最外层 `</div>`return 末尾,约 329 行)之前、群内微信用户 `Card` 之后,追加:
```tsx
<Modal
open={dayDetail.open}
title={`群「${data?.group_name ?? ''}${dayDetail.date} · 用户领券明细`}
footer={null}
width={640}
onCancel={() => setDayDetail((s) => ({ ...s, open: false }))}
>
<div style={{ fontSize: 12, color: '#999', marginBottom: 8 }}>
(openid);/,
</div>
<Table<DayUser>
rowKey="openid"
size="small"
loading={dayDetail.loading}
columns={dayUserColumns}
dataSource={dayDetail.users}
pagination={false}
locale={{ emptyText: '当天无授权用户领券/点击' }}
/>
</Modal>
```
- [ ] **Step 7: 构建确认通过**
Run:
```
npm run build
```
Expected: 构建成功,无类型错误。
- [ ] **Step 8: 提交(前端仓)**
```
git add src/app/(main)/cps/groups/[id]/page.tsx
git commit -m "feat(cps): 每日明细下钻弹窗(按用户领券/点击 + 券明细 tooltip)"
```
---
## Task 6: 全栈联调(手动验证)
**前置:** 两仓本地起服务。后端 admin API(8771)+ 前端(3001)。可用前端仓 `bash start.sh`(拉起 8770/8771/3001),默认登录 `admin` / `admin12345`
- [ ] **Step 1: 选一个有授权点击的淘宝群**
后端起来后,直接验证接口(用上面手动登录拿的 admin token,或浏览器已登录的会话):
Run(确认接口连通,期望 200 + JSON 结构):
```
.\.venv\Scripts\pytest.exe tests\test_cps_admin.py -v
```
(接口逻辑已由自动化测试覆盖;本步主要确认前端联调环境。)
- [ ] **Step 2: 前端页面手动核对**
打开 `http://localhost:3001` → CPS 分发 → 进任一群详情 → 每日明细表:
- [ ] 日期列完整显示 `YYYY-MM-DD`,列宽不挤。
- [ ] 每行末尾有「明细」按钮(右侧固定列)。
- [ ] 点「明细」→ 弹窗打开、标题含群名与日期、加载态正常。
- [ ] 有数据的淘宝群:用户行显示头像/昵称、领券次数、点击次数;悬停「点击次数」浮出 `券名 ×次数`,各次数之和 = 点击次数。
- [ ] 昵称为空的用户显示 `(未授权昵称)`
- [ ] 美团/京东群或无授权用户的天:弹窗走空态文案 `当天无授权用户领券/点击`
- [ ] **Step 3: 收尾**
确认前后端各自分支提交完整:
```
git -C e:\project\shaguabijia-app-server log --oneline -3
git log --oneline -5
```
---
## Self-Review(计划自检结论)
- **Spec 覆盖:** `/day-users` 接口(Task 2)、券明细 visit×次数倒序(Task 2 test + 前端 Step 5)、`/daily` date 改全日期(Task 1)、匿名排除(Task 2 实现 + Task 2/3 test)、404/400/401Task 3)、跨年(Task 3)、前端明细列/Modal/Tooltip/日期列加宽(Task 45)、不动数据库(无 migration 任务)—— 均有对应任务。
- **类型一致:** 后端 `group_day_users(db, *, group_id, start, end, limit)` 在 repo 定义、router 按此调用;响应键 `group_id/group_name/date/users[*].{openid,nickname,headimgurl,copy_count,visit_count,coupons[*].{name,count}}` 与前端 `DayUser/DayCoupon` 接口逐字对应。
- **无占位:** 所有步骤含可直接落地的完整代码与确切命令。
@@ -1,132 +0,0 @@
# CPS 群详情 · 每日明细「按天 · 按用户」领券下钻
日期:2026-06-26
状态:已与用户确认设计,待写实现计划
## 背景
`shaguabijia-admin-web`(纯前端)+ 后端 `shaguabijia-app-server`Admin API`/admin/api/*`,端口 8771)。
CPS 群对账详情页 `src/app/(main)/cps/groups/[id]/page.tsx` 现有两张表:
- **每日明细**`GET /admin/api/cps/groups/{id}/daily`):按天一行,含点击/复制/订单佣金。行 `date` 原为 `"MM-DD"`(北京),下钻要完整日期 → **本次改为 `"YYYY-MM-DD"`**(见后端改动)。
- **群内微信用户**`GET /admin/api/cps/groups/{id}/wx-users`):**累计**(非按天)每用户画像,列含 用户 / 领券次数(`copy_count`) / 点击次数(`visit_count`)。
底层数据 `cps_click`(一事件一行):`event_type` = `visit`(进落地页/被跳转) | `copy`(淘宝"复制口令")、`openid`(微信授权才有)、`clicked_at``link_id`。一条点击对应的「优惠券」= `link_id → cps_link.activity_id → cps_activity.name`(活动即优惠券)。`cps_wx_user``openid``nickname/headimgurl`
**既有口径(沿用,不改)**:领券次数 = `copy` 事件数;点击次数 = `visit` 事件数。
## 目标
每日明细表每行末尾加「明细」按钮 → 弹窗展示**该群、该天、以用户为单位**的领券情况:
- 列:用户名、领券次数、点击次数。
- 鼠标悬停「点击次数」→ tooltip 展示该用户当天 **visit 过的全部优惠券**,每行 `券名 ×次数`,按次数倒序,**合计 = 点击次数**。
## 方案:A(一个新接口,券明细内联返回)
下钻数据量小(单群 / 单天 / 有 openid 的用户)。新接口一次返回每个用户的领券数、点击数及其 visit 过的券列表,前端一次请求渲染表格 + tooltip,无悬停二次加载、无闪烁。
(已否决方案 B:用户列表与券明细拆两接口、悬停再拉 —— 多一接口、多一往返、有闪烁,对这数据量不值得。)
## 数据库:无需改动
纯读侧聚合,所需数据已全部在库,**不加表 / 不加字段 / 无 migration / 无需回填**
- `cps_click``group_id`(idx) / `clicked_at`(idx) / `openid`(idx) / `event_type` / `link_id` —— 筛群、筛天、分用户、分领券/点击、定位券所需字段齐全。
- 券归属 `link_id → cps_link.activity_id → cps_activity.name` 在**写入时**就落了(`record_click` 一直写 `link_id`),历史点击可反查,**无需回填**。
- 查询条件 `group_id + clicked_at 区间 + openid IS NOT NULL` 均命中现有单列索引;数据量小、Python 侧聚合,**无需新增复合索引**。
## 后端改动(`app/admin/routers/cps.py` + `app/admin/repositories/cps.py`
### 新增接口 `GET /admin/api/cps/groups/{group_id}/day-users`
- 入参:`date`(必填,`YYYY-MM-DD`)。
- 鉴权:`Depends(get_current_admin)`(只读,登录即可;与 `/daily``/wx-users` 同口径,**不加** `require_role`)。
- 群不存在 → 404`date` 非法 → 400`detail` 说明格式)。
- **时间窗**`date` 按北京时区(UTC+8)解释成 `[当天 00:00, 次日 00:00)` **半开区间**,转 UTC 后查 `clicked_at >= start AND clicked_at < next_day_start`(半开避免午夜双计)。北京日界 → UTC 用既有 `_as_utc` 约定,与 `group_click_timeseries` 分桶口径一致。
- **取数**`cps_click` where `group_id == {group_id}` AND 时间窗内 AND `openid IS NOT NULL`
- **解析券名**:取该群相关 `cps_link`(或按出现的 `link_id` 批量取)建 `link_id → activity_id` 映射,再 `activity_id → cps_activity.name` 映射;Python 侧聚合(与本仓既有风格一致)。活动被硬删 → 券名兜底 `活动#{activity_id}`
- **按 openid 聚合**
- `copy_count` = 该用户 `copy` 事件数(= 领券次数)
- `visit_count` = 该用户 `visit` 事件数(= 点击次数)
- `coupons` = 该用户 **visit 事件**按活动分组的 `{name, count}` 列表,按 `count` 倒序;`copy` 事件不计入券列表(与用户选择"只算 visit"一致)
- 关联 `cps_wx_user``openid IN (...)`)取 `nickname/headimgurl`(未授权为 null)。
- **排序**`copy_count` desc,再 `visit_count` desc(沿用 `group_wx_users` 习惯)。
- 可选 `limit`(默认 200,与 `group_wx_users` 一致)。
**响应**
```json
{
"group_id": 1,
"group_name": "宝妈薅羊毛1群",
"date": "2026-06-25",
"users": [
{
"openid": "oABC...",
"nickname": "张三",
"headimgurl": "https://thirdwx.qlogo.cn/...",
"copy_count": 2,
"visit_count": 5,
"coupons": [
{ "name": "618神券", "count": 3 },
{ "name": "买一送一", "count": 2 }
]
}
]
}
```
实现建议:在 `cps_repo``group_day_users(db, *, group_id, date_start, date_end, limit=200) -> list[dict]`,router 负责日期解析与窗口计算后调用之(与 `group_daily`/`group_wx_users` 的分工一致)。
### 微调既有 `/daily``date` 改全日期(不新增字段)
把每行 `date``"MM-DD"` 改成 **`"YYYY-MM-DD"`**(北京),**不再新增 `date_iso`**。下钻必须知道完整日期(`MM-DD` 丢年份,跨年会错);`date` 直接给全日期,契约更简单、无冗余字段。`group_daily` 已在循环里持有北京日期 `cur`,把 `cur.strftime("%m-%d")` 改成 `cur.strftime("%Y-%m-%d")` 即可。
- 该 endpoint 唯一消费方是本前端(随本次一起改),故**非破坏性**。
- 折线图/时序用的是 `/timeseries``label` 字段(另一套,仍 `MM-DD`),**不受影响**,本次不动。
## 前端改动(`src/app/(main)/cps/groups/[id]/page.tsx`
1. `DailyRow.date` 现为 `"YYYY-MM-DD"`(类型仍 `string`,无需加字段)。日期列 `{ title:'日期', dataIndex:'date' }` 宽度 64 → **调宽到约 96,完整显示 `YYYY-MM-DD`**(跨月/跨年看范围更直观)。表格 `scroll.x` 随新列 + 加宽日期列相应上调(约 980)。
2. 新增类型:
```ts
interface DayCoupon { name: string; count: number; }
interface DayUser {
openid: string; nickname: string | null; headimgurl: string | null;
copy_count: number; visit_count: number; coupons: DayCoupon[];
}
```
3. `dailyColumns` 末尾加**「明细」列**`key:'drill'`, `fixed:'right'`, `width≈64`),render 一个 `type="link"` 小按钮,`onClick` → `openDayDetail(row.date)`(全日期既用于查询也用于弹窗标题)。
4. 弹窗状态:`{ open, date, loading, users }`。`openDayDetail(date)` 置 open+loading`api.get('/admin/api/cps/groups/${id}/day-users?date=' + date)` → 填 `users`;失败 `message.error(errMsg(e, ...))`。
5. 新增 `<Modal>`(声明式组件,不受 antd5 静态方法 context 限制,无需 `App.useApp()`)含 `<Table>`,三列:
- **用户**:头像 + 昵称(空 → `(未授权昵称)`),与既有「群内微信用户」表的用户单元一致(头像直接 `<img src={headimgurl}>`,微信 URL 为绝对地址,非 `/media`)。
- **领券次数**`copy_count`,沿用既有红色加粗样式(0 显示 0)。
- **点击次数**`visit_count`,整格用 `<Tooltip>` 包裹,`title` 渲染 `coupons` 每行 `券名 ×次数``coupons` 为空时不挂 tooltip(直接显示数字)。
6. Modal 标题:`群「{group_name}」{date} · 用户领券明细`。空态文案:`仅统计微信授权(openid)用户;美团/京东多为匿名跳转,可能为空`。
7. antd 引入新增 `Modal, Tooltip`。
## 默认与边界(已确认)
- **匿名点击(openid 为空)不计入**(无法归属到人),镜像既有 `/wx-users`。后果:美团/京东群弹窗常为空 —— 空态文案已提示。
- **数据口径差异(需在代码注释点明,避免误读为 bug)**:每日明细行的 `click_pv/copy_pv` 计**全部**点击(含匿名、UV 按 `(ip,ua)`);本弹窗只计 `openid` 用户。故弹窗各用户求和 **≤** 当天行的点击/复制总数,二者不必相等。
- **谁出现在弹窗**:当天有任意事件(visit 或 copy)的授权用户都列(含领券数为 0 的纯点击用户)。
- **领券次数不做 tooltip**:需求只要点击次数的 tooltip,保持最小。
- **权限**:不加角色门槛(与本页其它只读统计一致)。
## 不做(YAGNI
- 领券次数的「领了哪些券」tooltip。
- 匿名点击的聚合行 / 占位行。
- 用户级下单归因(需 user-level sid,另一期)。
## 验证(本仓无测试框架,手动验证)
- 后端:选一个有 `cps_click` 数据的淘宝群,`curl .../day-users?date=<某天>`,核对:①只含 openid 非空用户;②某用户 `coupons` 的 `count` 之和 = 其 `visit_count`;③`date` 非法返回 400、群不存在返回 404;④跨年日期(如去年 12-31)能取到正确那天。
- 前端:`npm run build` 通过;日期列完整显示 `YYYY-MM-DD`、列宽不挤;明细按钮 → 弹窗加载;悬停点击次数显示券×次数且合计对得上;美团/京东群弹窗走空态文案;昵称为空显示占位。
## 落点文件
- 后端:`app/admin/routers/cps.py`(新 endpoint + `/daily` 的 `date` 改 `YYYY-MM-DD`)、`app/admin/repositories/cps.py`(新 `group_day_users` + `group_daily` 的 `date` 改全日期)。
- 前端:`src/app/(main)/cps/groups/[id]/page.tsx`(类型、日期列加宽、明细列、Modal、Tooltip)。
+104 -344
View File
@@ -9,7 +9,6 @@ import {
Card,
Col,
DatePicker,
Descriptions,
Divider,
InputNumber,
Modal,
@@ -20,7 +19,6 @@ import {
Statistic,
Table,
Tag,
Tooltip,
Typography,
} from 'antd';
import {
@@ -34,11 +32,9 @@ import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format';
import type {
AdRevenueDaily,
AdRevenueHourly,
AdRevenueRecord,
AdRevenueReport,
AdRevenueRow,
AdRevenueTypeStat,
} from '@/lib/types';
const { RangePicker } = DatePicker;
@@ -51,13 +47,6 @@ const TYPE_TAG: Record<string, { color: string; label: string }> = {
withdrawal_video: { color: 'gold', label: '提现激励视频' },
};
// Draw 信息流投放场景标签(后端 AdRevenueRow.feed_scene:comparison/coupon/welfare)
const SCENE_TAG: Record<string, { text: string; color: string }> = {
comparison: { text: '比价', color: 'blue' },
coupon: { text: '领券', color: 'gold' },
welfare: { text: '福利', color: 'green' },
};
// 我们的应用环境标签
const APP_TAG: Record<string, { color: string; label: string }> = {
prod: { color: 'green', label: '傻瓜比价(正式)' },
@@ -139,22 +128,19 @@ const DETAIL_COLUMNS: ColumnsType<AdRevenueRecord> = [
},
];
// 趋势图(纯 SVG,零依赖):柱=展示条数(左轴),线=客户端预估收益元(右轴),
// 绿线=穿山甲后台预估收益元(右轴,仅按天且有数据时出现)。x 轴按传入点序。
// 趋势图(纯 SVG,零依赖):柱=展示条数(左轴),线=预估收益元(右轴)。x 轴按传入点序。
const CHART_BAR = '#69b1ff';
const CHART_LINE = '#fa8c16';
const CHART_LINE2 = '#52c41a'; // 穿山甲后台收益线
interface TrendPoint {
label: string; // x 轴刻度文案(小时数 / MM-DD)
tip: string; // hover 原生 tooltip 全文
impressions: number;
revenue: number;
pangleRevenue: number | null; // 穿山甲后台预估收益(元);无则 null(不画绿线点)
}
// 按小时聚合(0–23),数据源是 hourly(后端全量聚合,不受分页影响)。穿山甲为天级,小时视图无该线。
function aggregateHourly(rows: AdRevenueHourly[]): TrendPoint[] {
// 按小时聚合(0–23),数据源是 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;
@@ -166,12 +152,10 @@ function aggregateHourly(rows: AdRevenueHourly[]): TrendPoint[] {
tip: `${String(b.hour).padStart(2, '0')}:00 展示 ${b.impressions} 条 · 预估收益 ${b.revenue.toFixed(4)}`,
impressions: b.impressions,
revenue: b.revenue,
pangleRevenue: null,
}));
}
// 按天聚合,数据源是 daily(全量,不受 limit 影响);用 from..to 补齐空缺日为 0,轴连续
// 穿山甲收益(pangle_revenue_yuan)为 null 时该日不画绿线点(T+1 未出/非全量视图)。
// 按天聚合,数据源是 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[] = [];
@@ -182,14 +166,11 @@ function aggregateDaily(dateFrom: string, dateTo: string, daily: AdRevenueDaily[
const d = map.get(ds);
const impressions = d?.impressions ?? 0;
const revenue = d?.revenue_yuan ?? 0;
const pangleRevenue = d?.pangle_revenue_yuan ?? null;
const pangleTip = pangleRevenue != null ? ` · 穿山甲收益 ${pangleRevenue.toFixed(4)}` : '';
out.push({
label: ds.slice(5),
tip: `${ds} 展示 ${impressions} 条 · 预估收益 ${revenue.toFixed(4)}${pangleTip}`,
tip: `${ds} 展示 ${impressions} 条 · 预估收益 ${revenue.toFixed(4)}`,
impressions,
revenue,
pangleRevenue,
});
cur = cur.add(1, 'day');
guard += 1;
@@ -200,11 +181,7 @@ function aggregateDaily(dateFrom: string, dateTo: string, daily: AdRevenueDaily[
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),
...points.map((p) => p.pangleRevenue ?? 0),
);
const maxRev = Math.max(1e-9, ...points.map((p) => p.revenue));
const W = 960;
const H = 280;
@@ -260,24 +237,6 @@ function TrendChart({ points }: { points: TrendPoint[] }) {
<title>{p.tip}</title>
</circle>
))}
{/* 穿山甲后台收益线(绿,仅非 null 的天画;按天且已同步 T+1 数据时出现) */}
{(() => {
const pp = points
.map((p, i) => ({ i, v: p.pangleRevenue, tip: p.tip }))
.filter((x): x is { i: number; v: number; tip: string } => x.v != null);
if (pp.length === 0) return null;
const pangleLine = pp.map((x) => `${cx(x.i)},${revY(x.v)}`).join(' ');
return (
<g>
<polyline points={pangleLine} fill="none" stroke={CHART_LINE2} strokeWidth={2} />
{pp.map((x) => (
<circle key={`p-${x.i}`} cx={cx(x.i)} cy={revY(x.v)} r={2.5} fill={CHART_LINE2}>
<title>{x.tip}</title>
</circle>
))}
</g>
);
})()}
{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">
@@ -297,12 +256,8 @@ export default function AdRevenueReportPage() {
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]);
const [userId, setUserId] = useState<number | null>(null);
const [adType, setAdType] = useState<string | undefined>();
// 「场景」作为后端全局筛选(feed_scene):同时影响明细 / 合计 / 趋势,与「用户 / 类型」一致,点「查询」生效。
const [scene, setScene] = useState<string | undefined>();
const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
const [sortBy, setSortBy] = useState<'time' | 'ecpm'>('time'); // 明细排序:time=时间倒序 / ecpm=eCPM 倒序
const [limit, setLimit] = useState<number>(500); // 每页条数(分页大小)
const [page, setPage] = useState<number>(1); // 当前页码(后端分页;1 起)
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);
@@ -313,41 +268,33 @@ export default function AdRevenueReportPage() {
// 当前选择是否跨多天:跨多天时「按小时」无意义,粒度强制按天
const rangeMultiDay = range[0].format('YYYY-MM-DD') !== range[1].format('YYYY-MM-DD');
// load(目标页, 每页条数):分页与筛选都经它。offset 由 页码×每页 算;场景(feed_scene)随筛选下推后端。
const load = useCallback(
async (targetPage = 1, targetLimit = limit, targetSort = sortBy) => {
const from = range[0].format('YYYY-MM-DD');
const to = range[1].format('YYYY-MM-DD');
const multiDay = from !== to;
const gran = multiDay ? 'day' : granularity; // 跨多天强制按天
setLoading(true);
try {
const res = await api.get<AdRevenueReport>('/admin/api/ad-revenue-report', {
params: {
date_from: from,
date_to: to,
user_id: userId ?? undefined,
ad_type: adType ?? undefined,
feed_scene: scene ?? undefined,
granularity: gran,
limit: targetLimit,
offset: (targetPage - 1) * targetLimit,
sort: targetSort,
},
});
setData(res.data);
setPage(targetPage);
setQueriedLimit(targetLimit);
setQueriedGranularity(gran);
setQueriedMultiDay(multiDay);
} catch (e) {
message.error(errMsg(e));
} finally {
setLoading(false);
}
},
[range, userId, adType, scene, granularity, limit, sortBy],
);
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();
@@ -385,16 +332,6 @@ export default function AdRevenueReportPage() {
return <Tag color={t.color}>{t.label}</Tag>;
},
},
{
title: '场景',
dataIndex: 'feed_scene',
width: 80,
render: (v: string | null | undefined) => {
if (!v) return <Typography.Text type="secondary">-</Typography.Text>;
const t = SCENE_TAG[v];
return t ? <Tag color={t.color}>{t.text}</Tag> : <Tag>{v}</Tag>;
},
},
{
title: '来源应用',
dataIndex: 'app_env',
@@ -405,6 +342,13 @@ export default function AdRevenueReportPage() {
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',
@@ -412,17 +356,6 @@ export default function AdRevenueReportPage() {
align: 'right',
render: (v: string | null) => v ?? '-',
},
{
title: 'eCPM(元)',
key: 'ecpm_yuan',
width: 100,
align: 'right',
render: (_: unknown, r: AdRevenueRow) => {
// eCPM 原始值单位「分/千次」,÷100 转「元/千次」(块);非数字 / 空显示 -。
const cents = r.ecpm == null ? null : Number(r.ecpm);
return cents == null || Number.isNaN(cents) ? '-' : (cents / 100).toFixed(2);
},
},
{
title: '预估收益(元)',
dataIndex: 'revenue_yuan',
@@ -468,13 +401,6 @@ export default function AdRevenueReportPage() {
<Typography.Text type="secondary">-</Typography.Text>
),
},
{
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: '底层 ADN',
dataIndex: 'adn',
@@ -484,11 +410,14 @@ export default function AdRevenueReportPage() {
},
];
// 派生指标(全部基于全量 total_* 字段,不受分页影响,准):
// 发奖成本(元)= 实发金币÷汇率;预估毛利 = 收益−发奖成本;发奖占收益比 = 发奖成本÷收益;
// 应发实发差额(金币)= 应发−实发(正=少发/负=多发);ARPU = 预估收益÷今日DAU(仅今日)
// 派生指标(全部基于全量 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:
@@ -496,20 +425,9 @@ export default function AdRevenueReportPage() {
? (data.total_actual_coin / COIN_PER_YUAN / data.total_revenue_yuan) * 100
: null,
coinGap: data.total_expected_coin - data.total_actual_coin,
// ARPU(今日)= 预估广告收益 ÷ 今日 DAU;dau 为 null(历史/多天)或 0 时不可算 → null,前端显示「-」。
arpu: data.dau && data.dau > 0 ? data.total_revenue_yuan / data.dau : null,
}
: null;
// 第二行大盘「分广告类型」:draw / 激励视频 各自 收益 / eCPM / 展示条数;eCPM = 收益÷展示×1000。
const drawStat = data?.type_stats?.draw;
const rvStat = data?.type_stats?.reward_video;
const ecpmOf = (s?: AdRevenueTypeStat) =>
s && s.impressions > 0 ? (s.revenue_yuan / s.impressions) * 1000 : 0;
// 明细直接用后端返回的当前页 items(「场景」已由后端 feed_scene 全局过滤,前端不再二次筛)。
const items = data?.items ?? [];
return (
<div>
<Space align="center" style={{ display: 'flex', marginBottom: 16 }}>
@@ -530,11 +448,6 @@ export default function AdRevenueReportPage() {
广(onAdShow) eCPM ( ÷1000
),<b>广</b>;穿/,,
<b>穿</b> 0广ID / ,
<br />
<br />
<b>穿(T+1)</b>穿 GroMore API(,):
<b>穿</b>= revenue<b>API</b>= ADN 穿//,
<b></b>(//);,
</div>
}
>
@@ -584,21 +497,6 @@ export default function AdRevenueReportPage() {
]}
/>
</Space>
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<Select
placeholder="全部"
value={scene}
onChange={setScene}
allowClear
style={{ width: 130 }}
options={[
{ value: 'comparison', label: '比价' },
{ value: 'coupon', label: '领券' },
{ value: 'welfare', label: '福利' },
]}
/>
</Space>
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<Select
@@ -614,33 +512,15 @@ export default function AdRevenueReportPage() {
/>
</Space>
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<Select
value={sortBy}
onChange={(v) => {
setSortBy(v);
load(1, limit, v); // 改排序:回第 1 页并重查
}}
style={{ width: 130 }}
options={[
{ value: 'time', label: '时间倒序' },
{ value: 'ecpm', label: 'eCPM 倒序' },
]}
/>
</Space>
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text type="secondary"></Typography.Text>
<Select
value={limit}
onChange={(v) => {
setLimit(v);
load(1, v); // 改每页大小:回到第 1 页并重查
}}
style={{ width: 120 }}
options={[20, 50, 100, 200, 500, 1000].map((n) => ({ value: n, label: `${n} 条/页` }))}
onChange={setLimit}
style={{ width: 110 }}
options={[100, 200, 500, 1000].map((n) => ({ value: n, label: `${n}` }))}
/>
</Space>
<Button type="primary" onClick={() => load(1)} loading={loading}>
<Button type="primary" onClick={load} loading={loading}>
</Button>
</Space>
@@ -650,46 +530,41 @@ export default function AdRevenueReportPage() {
<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 flex="1 1 0">
<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 flex="1 1 0">
<Statistic
title={
<Tooltip title="每活跃用户广告预估收入 = 预估收益 ÷ 今日 DAU;DAU 仅今日口径,历史/多天显示 -">
<span>
ARPU()
<InfoCircleOutlined style={{ marginLeft: 4 }} />
</span>
</Tooltip>
}
value={derived.arpu ?? '-'}
precision={4}
/>
<Col span={6}>
<Statistic title="广告事件数" value={data.total} />
</Col>
<Col flex="1 1 0">
<Statistic
title={
<Tooltip title="今日活跃用户(复用大盘口径 last_login_at = 今日登录过);仅查询=今日时有值,历史/多天显示 -">
<span>
DAU
<InfoCircleOutlined style={{ marginLeft: 4 }} />
</span>
</Tooltip>
}
value={data.dau ?? '-'}
/>
</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 flex="1 1 0">
<Statistic title="发金币合计(元)" value={derived.payoutYuan} precision={4} />
<Col span={6}>
<Statistic title="发金币合计" value={data.total_actual_coin} />
</Col>
<Col flex="1 1 0">
<Col span={6}>
<Statistic title="发奖成本(元)" value={derived.payoutYuan} precision={4} />
</Col>
<Col span={6}>
<Statistic
title="预估广告毛利(元)"
title="预估毛利(元)"
value={derived.grossProfit}
precision={4}
prefix={derived.grossProfit >= 0 ? <RiseOutlined /> : <FallOutlined />}
@@ -701,94 +576,6 @@ export default function AdRevenueReportPage() {
</Typography.Text>
</Col>
</Row>
<Divider orientation="left" plain style={{ marginTop: 20, marginBottom: 16 }}>
<Tooltip title="来自穿山甲 GroMore 数据 API(后台结算口径,T+1 次日出数)。穿山甲不提供分用户/类型/场景维度,故仅在「全量视图」(未按用户/类型/场景筛选)展示;按代码位×应用×日期汇总。revenue=预估收益、收益API=各 ADN 回传更接近结算。">
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
穿(T+1)
<InfoCircleOutlined style={{ marginLeft: 4 }} />
</Typography.Text>
</Tooltip>
</Divider>
{data.pangle_revenue_available ? (
<Row gutter={[16, 12]}>
<Col flex="1 1 0">
<Statistic
title="穿山甲预估收益(元)"
value={data.total_pangle_revenue_yuan ?? 0}
precision={4}
/>
</Col>
<Col flex="1 1 0">
<Statistic
title="穿山甲收益API(元)"
value={data.total_pangle_api_revenue_yuan ?? '-'}
precision={data.total_pangle_api_revenue_yuan == null ? undefined : 4}
/>
{data.total_pangle_api_revenue_yuan == null && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Reporting /
</Typography.Text>
)}
</Col>
<Col flex="1 1 0">
<Statistic
title={
<Tooltip title="客户端预估相对穿山甲预估的高估幅度 =(客户端预估 − 穿山甲预估)÷ 穿山甲预估;客户端按 onAdShow 计、不滤无效曝光,通常偏高">
<span>
<InfoCircleOutlined style={{ marginLeft: 4 }} />
</span>
</Tooltip>
}
value={
data.total_pangle_revenue_yuan && data.total_pangle_revenue_yuan > 0
? ((data.total_revenue_yuan - data.total_pangle_revenue_yuan) /
data.total_pangle_revenue_yuan) *
100
: '-'
}
precision={
data.total_pangle_revenue_yuan && data.total_pangle_revenue_yuan > 0
? 1
: undefined
}
suffix={
data.total_pangle_revenue_yuan && data.total_pangle_revenue_yuan > 0 ? '%' : ''
}
/>
</Col>
</Row>
) : (
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
本视图无穿山甲后台收益:(//)
穿 T+1 , <Typography.Text code>scripts/sync_pangle_revenue</Typography.Text>
</Typography.Text>
)}
<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={4}>
<Statistic title="Draw 信息流收益(元)" value={drawStat?.revenue_yuan ?? 0} precision={4} />
</Col>
<Col span={4}>
<Statistic title="Draw 信息流 eCPM(元/千次)" value={ecpmOf(drawStat)} precision={2} />
</Col>
<Col span={4}>
<Statistic title="Draw 信息流条数" value={drawStat?.impressions ?? 0} />
</Col>
<Col span={4}>
<Statistic title="激励视频收益(元)" value={rvStat?.revenue_yuan ?? 0} precision={4} />
</Col>
<Col span={4}>
<Statistic title="激励视频 eCPM(元/千次)" value={ecpmOf(rvStat)} precision={2} />
</Col>
<Col span={4}>
<Statistic title="激励视频条数" value={rvStat?.impressions ?? 0} />
</Col>
</Row>
</Card>
)}
@@ -805,13 +592,20 @@ export default function AdRevenueReportPage() {
) : (
<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.hourly?.length ?? 0) > 0) && (
: queriedGranularity === 'hour' && (data.items?.length ?? 0) > 0) && (
<Card
size="small"
title={queriedMultiDay ? '按天趋势' : '按小时趋势'}
@@ -841,30 +635,24 @@ export default function AdRevenueReportPage() {
verticalAlign: 'middle',
}}
/>
()
()
</span>
{queriedMultiDay && (
<span style={{ fontSize: 12, color: '#666' }}>
<span
style={{
display: 'inline-block',
width: 14,
borderTop: `2px solid ${CHART_LINE2}`,
marginRight: 4,
verticalAlign: 'middle',
}}
/>
穿()
</span>
)}
</Space>
}
>
{/* 趋势图恒为全量(不受分页影响):按天图用 daily,按小时图用 hourly;「场景」已下推后端,趋势随场景筛选一并变化。 */}
{/* 按小时图由 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.hourly)} />
<TrendChart points={aggregateHourly(data.items)} />
)}
</Card>
)}
@@ -872,22 +660,15 @@ export default function AdRevenueReportPage() {
<Table
rowKey="event_key"
columns={columns}
dataSource={items}
dataSource={data?.items ?? []}
loading={loading}
pagination={{
current: page,
pageSize: queriedLimit,
total: data?.total ?? 0,
showSizeChanger: false,
showTotal: (t) => `${t}`,
onChange: (p) => load(p, queriedLimit),
}}
pagination={false}
size="small"
scroll={{ x: 1380 }}
scroll={{ x: 1200 }}
rowClassName={(r) => (r.has_reward && !r.matched ? 'row-mismatch' : '')}
expandable={{
// 每行都可展开(左侧恒有 + 号):有发奖看「金币复算因子」;纯展示看「展示明细」
rowExpandable: () => true,
// 只有发了奖的事件可展开,看「这条广告金币怎么算的」;纯展示行无复算、不可展开
rowExpandable: (r) => r.reward_detail != null,
expandedRowRender: (r) =>
r.reward_detail ? (
<div>
@@ -906,28 +687,7 @@ export default function AdRevenueReportPage() {
rowClassName={(d) => (d.matched ? '' : 'row-mismatch')}
/>
</div>
) : (
<div>
<Typography.Text strong></Typography.Text>{' '}
<Typography.Text type="secondary">(,,)</Typography.Text>
<Descriptions size="small" column={3} bordered style={{ marginTop: 8 }}>
<Descriptions.Item label="时间">{formatUtcTime(r.created_at)}</Descriptions.Item>
<Descriptions.Item label="eCPM(分)">{r.ecpm ?? '-'}</Descriptions.Item>
<Descriptions.Item label="eCPM(元)">
{r.ecpm != null && !Number.isNaN(Number(r.ecpm))
? (Number(r.ecpm) / 100).toFixed(2)
: '-'}
</Descriptions.Item>
<Descriptions.Item label="预估收益(元)">
{r.has_impression ? r.revenue_yuan.toFixed(4) : '-'}
</Descriptions.Item>
<Descriptions.Item label="底层 ADN">{r.adn ?? '-'}</Descriptions.Item>
<Descriptions.Item label="底层代码位(rit)">{r.slot_id ?? '-'}</Descriptions.Item>
<Descriptions.Item label="我方代码位">{r.our_code_id ?? '-'}</Descriptions.Item>
<Descriptions.Item label="来源应用">{r.app_env ?? '-'}</Descriptions.Item>
</Descriptions>
</div>
),
) : null,
}}
/>
<Modal
@@ -943,7 +703,7 @@ export default function AdRevenueReportPage() {
<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 ();<b>广 = 1 </b>( 1 ; 10 ,)
<br />· 2LT (); 1 , 10 1
<br />· ,
</Typography.Paragraph>
<Row gutter={16}>
+6 -6
View File
@@ -10,8 +10,8 @@ import { api, errMsg } from '@/lib/api';
interface AdCfg {
app_id: string;
reward_code_id: string;
compare_draw_code_id: string;
coupon_draw_code_id: string;
compare_feed_code_id: string;
coupon_feed_code_id: string;
reward_mkey: string;
reward_enabled: boolean;
compare_ad_enabled: boolean;
@@ -74,11 +74,11 @@ export default function AdConfigPage() {
>
<Input.Password placeholder="GroMore S2S 回调验签密钥" autoComplete="off" />
</Form.Item>
<Form.Item name="compare_draw_code_id" label="比价Draw信息流代码位" rules={[{ required: true }]}>
<Input placeholder="比价Draw信息流代码位,如 104098712" />
<Form.Item name="compare_feed_code_id" label="比价 · 信息流 广告位ID" rules={[{ required: true }]}>
<Input placeholder="如 104090333" />
</Form.Item>
<Form.Item name="coupon_draw_code_id" label="领券Draw信息流代码位" rules={[{ required: true }]}>
<Input placeholder="领券Draw信息流代码位,如 104098712" />
<Form.Item name="coupon_feed_code_id" label="领券 · 信息流 广告位ID" rules={[{ required: true }]}>
<Input placeholder="如 104090333" />
</Form.Item>
<Form.Item name="reward_enabled" label="福利激励视频开关" valuePropName="checked">
<Switch />
+61 -79
View File
@@ -1,11 +1,8 @@
'use client';
import { useEffect, useState } from 'react';
import { App, Button, Card, Input, InputNumber, Space, Spin, Switch, Tabs, Tag, Tooltip } from 'antd';
import { App, Button, Card, Input, InputNumber, Space, Spin, Switch, Tag, Tooltip } from 'antd';
import { api, errMsg } from '@/lib/api';
import HomeMarqueeSeeds from './HomeMarqueeSeeds';
import HomeStatsConfig from './HomeStatsConfig';
import FeedbackQrConfig from './FeedbackQrConfig';
interface ConfigItem {
key: string;
@@ -83,66 +80,9 @@ export default function ConfigPage() {
}
};
if (loading) return <Spin style={{ display: 'block', marginTop: 80 }} />;
const groups = [...new Set(items.map((i) => i.group))];
const welfareConfig = loading ? (
<Spin style={{ display: 'block', marginTop: 24 }} />
) : (
groups.map((g) => (
<Card key={g} title={g} size="small" style={{ marginBottom: 16 }}>
{items
.filter((i) => i.group === g)
.map((item) => (
<div
key={item.key}
style={{ marginBottom: 12, paddingBottom: 12, borderBottom: '1px solid #f0f0f0' }}
>
<div style={{ marginBottom: 4 }}>
<b>{item.label}</b> {item.overridden ? <Tag color="blue"></Tag> : <Tag></Tag>}
{item.help && (
<Tooltip title={item.help}>
<span style={{ color: '#999', marginLeft: 6, fontSize: 12, cursor: 'help' }}>
</span>
</Tooltip>
)}
</div>
<Space wrap>
{item.type === 'bool' ? (
<Switch
checked={!!edits[item.key]}
onChange={(v) => setEdits({ ...edits, [item.key]: v })}
/>
) : item.type === 'int' ? (
<InputNumber
value={edits[item.key]}
onChange={(v) => setEdits({ ...edits, [item.key]: v })}
style={{ width: 180 }}
/>
) : (
<Input
value={edits[item.key]}
onChange={(e) => setEdits({ ...edits, [item.key]: e.target.value })}
style={{ width: 380 }}
placeholder={
item.type === 'int_list' ? '逗号分隔,如 10, 20, 30' : 'JSON,如 {"key": 100}'
}
/>
)}
<Button
type="primary"
size="small"
loading={saving === item.key}
onClick={() => save(item)}
>
</Button>
<span style={{ color: '#bbb', fontSize: 12 }}> {JSON.stringify(item.default)}</span>
</Space>
</div>
))}
</Card>
))
);
return (
<div>
@@ -150,22 +90,64 @@ export default function ConfigPage() {
<p style={{ color: '#999' }}>
() / ,
</p>
<Tabs
items={[
{
key: 'home',
label: '首页',
children: (
<>
<HomeStatsConfig />
<HomeMarqueeSeeds />
</>
),
},
{ key: 'welfare', label: '福利页', children: welfareConfig },
{ key: 'feedback-qr', label: '反馈二维码', children: <FeedbackQrConfig /> },
]}
/>
{groups.map((g) => (
<Card key={g} title={g} size="small" style={{ marginBottom: 16 }}>
{items
.filter((i) => i.group === g)
.map((item) => (
<div
key={item.key}
style={{ marginBottom: 12, paddingBottom: 12, borderBottom: '1px solid #f0f0f0' }}
>
<div style={{ marginBottom: 4 }}>
<b>{item.label}</b>{' '}
{item.overridden ? <Tag color="blue"></Tag> : <Tag></Tag>}
{item.help && (
<Tooltip title={item.help}>
<span style={{ color: '#999', marginLeft: 6, fontSize: 12, cursor: 'help' }}>
</span>
</Tooltip>
)}
</div>
<Space wrap>
{item.type === 'bool' ? (
<Switch
checked={!!edits[item.key]}
onChange={(v) => setEdits({ ...edits, [item.key]: v })}
/>
) : item.type === 'int' ? (
<InputNumber
value={edits[item.key]}
onChange={(v) => setEdits({ ...edits, [item.key]: v })}
style={{ width: 180 }}
/>
) : (
<Input
value={edits[item.key]}
onChange={(e) => setEdits({ ...edits, [item.key]: e.target.value })}
style={{ width: 380 }}
placeholder={
item.type === 'int_list' ? '逗号分隔,如 10, 20, 30' : 'JSON,如 {"key": 100}'
}
/>
)}
<Button
type="primary"
size="small"
loading={saving === item.key}
onClick={() => save(item)}
>
</Button>
<span style={{ color: '#bbb', fontSize: 12 }}>
{JSON.stringify(item.default)}
</span>
</Space>
</div>
))}
</Card>
))}
</div>
);
}
-677
View File
@@ -1,677 +0,0 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import {
App,
Button,
Card,
Col,
DatePicker,
Divider,
Input,
Row,
Select,
Space,
Statistic,
Table,
Tag,
Typography,
} from 'antd';
import dayjs, { type Dayjs } from 'dayjs';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format';
import UserRecordsDrawer from '@/components/UserRecordsDrawer';
const { RangePicker } = DatePicker;
// ── 领券数据看板类型(仅本页用,内联定义)──
interface CouponDataSummary {
started_count: number;
completed_count: number;
avg_elapsed_ms: number | null;
p5_ms: number | null;
p50_ms: number | null;
p95_ms: number | null;
p99_ms: number | null;
}
interface CouponDataDaily {
date: string;
started_count: number;
completed_count: number;
avg_elapsed_ms: number | null;
}
interface CouponDataHourly {
hour: number;
started_count: number;
completed_count: number;
avg_elapsed_ms: number | null;
}
interface CouponDataRow {
id: number;
trace_id: string;
user_id: number | null;
user_phone: string | null;
user_nickname: string | null;
status: string;
platforms: string[] | null;
origin_package: string | null;
elapsed_ms: number | null;
platform_elapsed: Record<string, number> | null;
device_model: string | null;
rom: string | null;
app_env: string | null;
started_at: string;
claimed_count: number | null;
trace_url: string | null;
}
interface CouponDataReport {
date_from: string;
date_to: string;
summary: CouponDataSummary;
daily: CouponDataDaily[];
hourly: CouponDataHourly[];
total: number;
items: CouponDataRow[];
}
// 发起来源 App 包名 → 中文(「发起平台」列):空=傻瓜比价首页发起,外卖 App 包名→对应平台。
function originLabel(pkg: string | null): string {
if (!pkg) return '傻瓜比价';
if (pkg.includes('meituan') || pkg.includes('sankuai')) return '美团';
if (pkg.includes('taobao') || pkg.includes('ele')) return '淘宝';
if (pkg.includes('jingdong') || pkg.includes('jd')) return '京东';
return pkg;
}
// 领券状态 → 颜色 + 中文(started 无终态 = 中途流失)
const STATUS_TAG: Record<string, { color: string; label: string }> = {
started: { color: 'default', label: '未完成' },
completed: { color: 'green', label: '完成' },
failed: { color: 'red', label: '失败' },
abandoned: { color: 'orange', label: '中途退出' },
};
// ms → "1.5s"(空值显示 -)
const fmtSec = (ms: number | null | undefined): string =>
ms == null ? '-' : `${(ms / 1000).toFixed(1)}s`;
// 点手机号弹出的「该用户全部领券」抽屉列(精简版:单用户,不含用户/手机号列)。
const RECORD_COLUMNS: ColumnsType<CouponDataRow> = [
{ title: '时间', dataIndex: 'started_at', width: 160, render: (v: string) => formatUtcTime(v) },
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s: string) => {
const t = STATUS_TAG[s] ?? { color: 'default', label: s };
return <Tag color={t.color}>{t.label}</Tag>;
},
},
{ title: '发起平台', dataIndex: 'origin_package', width: 90, render: (pkg: string | null) => originLabel(pkg) },
{ title: '耗时', dataIndex: 'elapsed_ms', width: 80, align: 'right', render: (v: number | null) => fmtSec(v) },
{ title: '美团', key: 'mt', width: 70, align: 'right', render: (_: unknown, r: CouponDataRow) => fmtSec(r.platform_elapsed?.['meituan-waimai']) },
{ title: '淘宝', key: 'tb', width: 70, align: 'right', render: (_: unknown, r: CouponDataRow) => fmtSec(r.platform_elapsed?.['taobao-shanguang']) },
{ title: '京东', key: 'jd', width: 70, align: 'right', render: (_: unknown, r: CouponDataRow) => fmtSec(r.platform_elapsed?.['jd-waimai']) },
{
title: 'trace',
dataIndex: 'trace_id',
width: 90,
render: (v: string, r: CouponDataRow) =>
r.trace_url ? (
<a href={r.trace_url} target="_blank" rel="noreferrer" title={v}>trace</a>
) : (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{v.slice(0, 6)}</Typography.Text>
),
},
];
// ── 趋势图(纯 SVG,零依赖,复用广告报表同款):浅蓝柱=发起数、深蓝柱=完成数(左轴 次数),橙线=平均耗时(右轴 秒)──
const CHART_BAR_STARTED = '#bae0ff';
const CHART_BAR_COMPLETED = '#1677ff';
const CHART_LINE = '#fa8c16';
interface TrendPoint {
label: string; // x 轴刻度(小时数 / MM-DD)
tip: string; // hover 原生 tooltip
started: number;
completed: number;
avgSec: number | null; // 平均耗时(秒);该桶无完成为 null(不画线点)
}
// 按天聚合:用 from..to 补齐空缺日为 0,轴连续。
function aggregateDaily(dateFrom: string, dateTo: string, daily: CouponDataDaily[]): TrendPoint[] {
const map = new Map(daily.map((d) => [d.date, d]));
const out: TrendPoint[] = [];
let cur = dayjs(dateFrom);
let guard = 0;
while (cur.format('YYYY-MM-DD') <= dateTo && guard < 400) {
const ds = cur.format('YYYY-MM-DD');
const d = map.get(ds);
const started = d?.started_count ?? 0;
const completed = d?.completed_count ?? 0;
const avgSec = d?.avg_elapsed_ms != null ? d.avg_elapsed_ms / 1000 : null;
out.push({
label: ds.slice(5),
tip: `${ds} 发起 ${started} · 完成 ${completed} · 平均耗时 ${avgSec == null ? '-' : avgSec.toFixed(1) + 's'}`,
started,
completed,
avgSec,
});
cur = cur.add(1, 'day');
guard += 1;
}
return out;
}
// 按小时聚合(023)。
function aggregateHourly(rows: CouponDataHourly[]): TrendPoint[] {
const byHour = new Map(rows.map((r) => [r.hour, r]));
return Array.from({ length: 24 }, (_, h) => {
const r = byHour.get(h);
const started = r?.started_count ?? 0;
const completed = r?.completed_count ?? 0;
const avgSec = r?.avg_elapsed_ms != null ? r.avg_elapsed_ms / 1000 : null;
return {
label: String(h),
tip: `${String(h).padStart(2, '0')}:00 发起 ${started} · 完成 ${completed} · 平均耗时 ${avgSec == null ? '-' : avgSec.toFixed(1) + 's'}`,
started,
completed,
avgSec,
};
});
}
function TrendChart({ points }: { points: TrendPoint[] }) {
const n = points.length;
const maxBar = Math.max(1, ...points.map((p) => p.started));
const maxSec = Math.max(1e-9, ...points.map((p) => p.avgSec ?? 0));
const W = 960;
const H = 280;
const padL = 48;
const padR = 56;
const padT = 16;
const padB = 32;
const plotW = W - padL - padR;
const plotH = H - padT - padB;
const step = plotW / Math.max(1, n);
const barW = Math.min(24, step * 0.5);
const yBase = padT + plotH;
const barX = (i: number) => padL + i * step + (step - barW) / 2;
const cx = (i: number) => padL + i * step + step / 2;
const barH = (v: number) => (v / maxBar) * plotH;
const secY = (v: number) => yBase - (v / maxSec) * plotH;
// X 轴刻度按天/按小时尽量每格都标(≤31 个点每个都标,超出才抽稀),避免跨度稍大就隔天显示。
const labelEvery = Math.max(1, Math.ceil(n / 31));
// 平均耗时线:仅 avgSec 非 null 的点连线(无完成的桶断开)。
const linePts = points
.map((p, i) => (p.avgSec == null ? null : `${cx(i)},${secY(p.avgSec)}`))
.filter((x): x is string => x !== null)
.join(' ');
return (
<svg viewBox={`0 0 ${W} ${H}`} width="100%" style={{ display: 'block' }} role="img" aria-label="趋势图">
{[0, 0.25, 0.5, 0.75, 1].map((t) => {
const y = yBase - t * plotH;
return (
<g key={t}>
<line x1={padL} y1={y} x2={W - padR} y2={y} stroke="#f0f0f0" />
<text x={padL - 8} y={y + 4} textAnchor="end" fontSize={11} fill={CHART_BAR_COMPLETED}>
{Math.round(maxBar * t)}
</text>
<text x={W - padR + 8} y={y + 4} textAnchor="start" fontSize={11} fill={CHART_LINE}>
{(maxSec * t).toFixed(1)}
</text>
</g>
);
})}
{/* 发起数柱(浅) */}
{points.map((p, i) => (
<rect
key={`s-${i}`}
x={barX(i)}
y={yBase - barH(p.started)}
width={barW}
height={barH(p.started)}
fill={CHART_BAR_STARTED}
rx={2}
>
<title>{p.tip}</title>
</rect>
))}
{/* 完成数柱(深,窄,叠中间) */}
{points.map((p, i) => {
const cw = barW * 0.55;
return (
<rect
key={`c-${i}`}
x={cx(i) - cw / 2}
y={yBase - barH(p.completed)}
width={cw}
height={barH(p.completed)}
fill={CHART_BAR_COMPLETED}
rx={1}
>
<title>{p.tip}</title>
</rect>
);
})}
{/* 平均耗时线(橙,右轴) */}
<polyline points={linePts} fill="none" stroke={CHART_LINE} strokeWidth={2} />
{points.map((p, i) =>
p.avgSec == null ? null : (
<circle key={`p-${i}`} cx={cx(i)} cy={secY(p.avgSec)} r={2.5} fill={CHART_LINE}>
<title>{p.tip}</title>
</circle>
),
)}
{points.map((p, i) =>
i % labelEvery === 0 || i === n - 1 ? (
<text key={`x-${i}`} x={cx(i)} y={H - 14} textAnchor="middle" fontSize={11} fill="#999">
{p.label}
</text>
) : null,
)}
</svg>
);
}
// 领券数据看板:上半汇总卡 + 按天/小时趋势(参考广告收益大盘),下半逐条领券明细。
// 数据源 coupon_session(客户端 /api/v1/coupon/session 两段上报);耗时统计只算 completed。
export default function CouponDataPage() {
const { message } = App.useApp();
const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(6, 'day'), dayjs()]);
const [user, setUser] = useState<string>('');
const [appEnv, setAppEnv] = useState<string>('prod');
const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
const [sortBy, setSortBy] = useState<'time' | 'elapsed'>('time');
const [limit, setLimit] = useState<number>(100);
const [page, setPage] = useState<number>(1);
const [queriedGranularity, setQueriedGranularity] = useState<'day' | 'hour'>('day');
const [queriedMultiDay, setQueriedMultiDay] = useState(false);
const [queriedLimit, setQueriedLimit] = useState<number>(100);
const [data, setData] = useState<CouponDataReport | null>(null);
const [loading, setLoading] = useState(false);
// 点手机号:抽屉看该用户全部领券(总次数 + 记录)
const [recordsUser, setRecordsUser] = useState<{ userId: number; phone: string | null } | null>(null);
const openUserRecords = (r: CouponDataRow) => {
if (r.user_id != null) setRecordsUser({ userId: r.user_id, phone: r.user_phone });
};
// 跨多天时「按小时」无意义,粒度强制按天(同广告报表)。
const rangeMultiDay = range[0].format('YYYY-MM-DD') !== range[1].format('YYYY-MM-DD');
const load = useCallback(
async (targetPage = 1, targetLimit = limit, targetSort = sortBy) => {
const from = range[0].format('YYYY-MM-DD');
const to = range[1].format('YYYY-MM-DD');
const multiDay = from !== to;
const gran = multiDay ? 'day' : granularity;
setLoading(true);
try {
const res = await api.get<CouponDataReport>('/admin/api/coupon-data', {
params: {
date_from: from,
date_to: to,
user: user.trim() || undefined,
app_env: appEnv,
granularity: gran,
limit: targetLimit,
offset: (targetPage - 1) * targetLimit,
sort: targetSort,
},
});
setData(res.data);
setPage(targetPage);
setQueriedLimit(targetLimit);
setQueriedGranularity(gran);
setQueriedMultiDay(multiDay);
} catch (e) {
message.error(errMsg(e));
} finally {
setLoading(false);
}
},
[range, user, appEnv, granularity, limit, sortBy, message],
);
useEffect(() => {
load();
// 仅首次自动拉近 7 天;之后由「查询」按钮触发
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const columns: ColumnsType<CouponDataRow> = [
{
title: '用户',
dataIndex: 'user_nickname',
width: 140,
render: (_: unknown, r: CouponDataRow) => {
if (r.user_nickname) {
return (
<span>
{r.user_nickname}
{r.user_id != null && (
<Typography.Text type="secondary" style={{ fontSize: 11, marginLeft: 6 }}>
#{r.user_id}
</Typography.Text>
)}
</span>
);
}
if (r.user_id != null) return <Typography.Text type="secondary">#{r.user_id}</Typography.Text>;
return <Typography.Text type="secondary"></Typography.Text>;
},
},
{
title: '手机号',
dataIndex: 'user_phone',
width: 130,
// 点手机号 → 抽屉看该用户全部领券(参考低价审核/反馈页)。游客(无 user_id)不可点。
render: (phone: string | null, r: CouponDataRow) =>
r.user_id != null ? (
<a onClick={() => openUserRecords(r)}>{phone || `#${r.user_id}`}</a>
) : (
<Typography.Text type="secondary"></Typography.Text>
),
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s: string) => {
const t = STATUS_TAG[s] ?? { color: 'default', label: s };
return <Tag color={t.color}>{t.label}</Tag>;
},
},
{
title: '发起平台',
dataIndex: 'origin_package',
width: 110,
render: (pkg: string | null) => originLabel(pkg),
},
{
title: '耗时',
dataIndex: 'elapsed_ms',
width: 90,
align: 'right',
render: (v: number | null) => fmtSec(v),
},
{
title: '美团耗时',
key: 'mt_elapsed',
width: 90,
align: 'right',
render: (_: unknown, r: CouponDataRow) => fmtSec(r.platform_elapsed?.['meituan-waimai']),
},
{
title: '淘宝耗时',
key: 'tb_elapsed',
width: 90,
align: 'right',
render: (_: unknown, r: CouponDataRow) => fmtSec(r.platform_elapsed?.['taobao-shanguang']),
},
{
title: '京东耗时',
key: 'jd_elapsed',
width: 90,
align: 'right',
render: (_: unknown, r: CouponDataRow) => fmtSec(r.platform_elapsed?.['jd-waimai']),
},
{
title: '机型/ROM',
key: 'device',
width: 200,
render: (_: unknown, r: CouponDataRow) => {
const parts = [r.device_model, r.rom].filter(Boolean);
return parts.length ? parts.join(' / ') : <Typography.Text type="secondary">-</Typography.Text>;
},
},
{
title: '时间',
dataIndex: 'started_at',
width: 165,
render: (v: string) => formatUtcTime(v),
},
{
title: '领券 trace',
dataIndex: 'trace_id',
width: 150,
// 有 trace_url(pricebot 上云,仅 completed)→ 可点链接打开完整现场;否则降级为可复制 trace_id。
render: (v: string, r: CouponDataRow) =>
r.trace_url ? (
<a href={r.trace_url} target="_blank" rel="noreferrer" title={v}>
trace{v.slice(0, 8)}
</a>
) : (
<Typography.Text copyable={{ text: v }} style={{ fontSize: 12 }}>
{v.slice(0, 8)}
</Typography.Text>
),
},
];
const summary = data?.summary;
const items = data?.items ?? [];
return (
<div>
<Space align="center" style={{ display: 'flex', marginBottom: 16 }}>
<h2 style={{ margin: 0 }}></h2>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
;/
</Typography.Text>
</Space>
<Card size="small" style={{ marginBottom: 16 }}>
<Space wrap size="middle" align="center">
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<RangePicker
value={range}
onChange={(v) => v && v[0] && v[1] && setRange([v[0], v[1]])}
allowClear={false}
presets={[
{ label: '今天', value: [dayjs(), dayjs()] },
{ label: '近 7 天', value: [dayjs().subtract(6, 'day'), dayjs()] },
{ label: '近 30 天', value: [dayjs().subtract(29, 'day'), dayjs()] },
]}
/>
</Space>
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<Input
placeholder="手机号/昵称"
value={user}
onChange={(e) => setUser(e.target.value)}
onPressEnter={() => load(1)}
allowClear
style={{ width: 150 }}
/>
</Space>
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<Select
value={appEnv}
onChange={setAppEnv}
style={{ width: 120 }}
options={[
{ value: 'prod', label: '正式(prod)' },
{ value: 'dev', label: '测试(dev)' },
{ value: 'all', label: '全部' },
]}
/>
</Space>
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<Select
value={rangeMultiDay ? 'day' : granularity}
onChange={setGranularity}
disabled={rangeMultiDay}
style={{ width: 110 }}
title={rangeMultiDay ? '跨多天仅支持按天' : undefined}
options={[
{ value: 'day', label: '按天' },
{ value: 'hour', label: '按小时' },
]}
/>
</Space>
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<Select
value={sortBy}
onChange={(v) => {
setSortBy(v);
load(1, limit, v);
}}
style={{ width: 130 }}
options={[
{ value: 'time', label: '时间倒序' },
{ value: 'elapsed', label: '耗时倒序' },
]}
/>
</Space>
<Space size={6}>
<Typography.Text type="secondary"></Typography.Text>
<Select
value={limit}
onChange={(v) => {
setLimit(v);
load(1, v);
}}
style={{ width: 120 }}
options={[20, 50, 100, 200, 500].map((nn) => ({ value: nn, label: `${nn} 条/页` }))}
/>
</Space>
<Button type="primary" onClick={() => load(1)} loading={loading}>
</Button>
</Space>
</Card>
{summary && (
<Card size="small" style={{ marginBottom: 16 }}>
<Row gutter={[16, 12]}>
<Col flex="1 1 0">
<Statistic title="领券发起数" value={summary.started_count} />
</Col>
<Col flex="1 1 0">
<Statistic title="领券完成数" value={summary.completed_count} />
</Col>
<Col flex="1 1 0">
<Statistic title="平均耗时" value={fmtSec(summary.avg_elapsed_ms)} />
</Col>
</Row>
<Divider style={{ marginTop: 16, marginBottom: 16 }} />
<Row gutter={[16, 12]}>
<Col flex="1 1 0">
<Statistic title="耗时 5 分位" value={fmtSec(summary.p5_ms)} />
</Col>
<Col flex="1 1 0">
<Statistic title="耗时 50 分位" value={fmtSec(summary.p50_ms)} />
</Col>
<Col flex="1 1 0">
<Statistic title="耗时 95 分位" value={fmtSec(summary.p95_ms)} />
</Col>
<Col flex="1 1 0">
<Statistic title="耗时 99 分位" value={fmtSec(summary.p99_ms)} />
</Col>
</Row>
</Card>
)}
{data &&
(queriedMultiDay
? (data.daily?.length ?? 0) > 0
: queriedGranularity === 'hour' && (data.hourly?.length ?? 0) > 0) && (
<Card
size="small"
title={queriedMultiDay ? '按天趋势' : '按小时趋势'}
style={{ marginBottom: 16 }}
extra={
<Space size={16}>
<span style={{ fontSize: 12, color: '#666' }}>
<span
style={{
display: 'inline-block',
width: 10,
height: 10,
background: CHART_BAR_STARTED,
borderRadius: 2,
marginRight: 4,
}}
/>
</span>
<span style={{ fontSize: 12, color: '#666' }}>
<span
style={{
display: 'inline-block',
width: 10,
height: 10,
background: CHART_BAR_COMPLETED,
borderRadius: 2,
marginRight: 4,
}}
/>
</span>
<span style={{ fontSize: 12, color: '#666' }}>
<span
style={{
display: 'inline-block',
width: 14,
borderTop: `2px solid ${CHART_LINE}`,
marginRight: 4,
verticalAlign: 'middle',
}}
/>
()
</span>
</Space>
}
>
{queriedMultiDay ? (
<TrendChart points={aggregateDaily(data.date_from, data.date_to, data.daily)} />
) : (
<TrendChart points={aggregateHourly(data.hourly)} />
)}
</Card>
)}
<Table
rowKey="trace_id"
columns={columns}
dataSource={items}
loading={loading}
pagination={{
current: page,
pageSize: queriedLimit,
total: data?.total ?? 0,
showSizeChanger: false,
showTotal: (t) => `${t}`,
onChange: (p) => load(p, queriedLimit),
}}
size="small"
scroll={{ x: 1450 }}
/>
<UserRecordsDrawer<CouponDataRow>
open={recordsUser != null}
onClose={() => setRecordsUser(null)}
userId={recordsUser?.userId ?? null}
phone={recordsUser?.phone ?? null}
endpoint="/admin/api/coupon-data/user-records"
columns={RECORD_COLUMNS}
recordLabel="领券"
countLabel="领券次数"
rewardLabel="领到券张数"
rewardSuffix="张"
rewardOf={(r) => r.claimed_count ?? 0}
/>
</div>
);
}
+3 -127
View File
@@ -6,7 +6,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import dynamic from 'next/dynamic';
import type { ColumnsType } from 'antd/es/table';
import { Button, Card, Col, Empty, Modal, Row, Segmented, Space, Spin, Statistic, Table, Tooltip, message } from 'antd';
import { Button, Card, Col, Empty, Row, Segmented, Space, Spin, Statistic, Table, message } from 'antd';
import { api, errMsg } from '@/lib/api';
import { formatUtcTime, yuan } from '@/lib/format';
@@ -57,20 +57,6 @@ interface WxUser {
copy_count: number;
}
// 某天该群按用户领券下钻(/day-users)
interface DayCoupon {
name: string;
count: number;
}
interface DayUser {
openid: string;
nickname: string | null;
headimgurl: string | null;
copy_count: number;
visit_count: number;
coupons: DayCoupon[];
}
const RANGES = [
{ label: '近 3 天', value: 'd3' },
{ label: '近 7 天', value: 'd7' },
@@ -153,30 +139,6 @@ export default function GroupDetailPage() {
loadWxUsers();
}, [loadWxUsers]);
// 某天领券下钻弹窗
const [dayDetail, setDayDetail] = useState<{
open: boolean;
date: string;
loading: boolean;
users: DayUser[];
}>({ open: false, date: '', loading: false, users: [] });
const openDayDetail = useCallback(
async (date: string) => {
setDayDetail({ open: true, date, loading: true, users: [] });
try {
const r = await api.get<{ users: DayUser[] }>(
`/admin/api/cps/groups/${id}/day-users?date=${date}`,
);
setDayDetail((s) => ({ ...s, loading: false, users: r.data.users || [] }));
} catch (e) {
message.error(errMsg(e, '明细加载失败'));
setDayDetail((s) => ({ ...s, loading: false }));
}
},
[id],
);
const points = useMemo(() => data?.points ?? [], [data]);
// 展平成「长表」给折线图:每个 (时间桶 × 指标) 一行,colorField 按指标分 4 条线。
const chartData = useMemo(
@@ -209,7 +171,7 @@ export default function GroupDetailPage() {
};
const dailyColumns: ColumnsType<DailyRow> = [
{ title: '日期', dataIndex: 'date', width: 96, fixed: 'left' },
{ title: '日期', dataIndex: 'date', width: 64, fixed: 'left' },
{ title: '点击', dataIndex: 'click_pv', width: 56 },
{ title: '独立', dataIndex: 'click_uv', width: 56 },
{ title: '复制', dataIndex: 'copy_pv', width: 56, render: (v: number) => v || '-' },
@@ -262,17 +224,6 @@ export default function GroupDetailPage() {
width: 84,
render: (v: number | null) => (v == null ? '-' : <span style={{ color: '#3f8600' }}>{yuan(v)}</span>),
},
{
title: '明细',
key: 'drill',
width: 64,
fixed: 'right',
render: (_, r) => (
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => openDayDetail(r.date)}>
</Button>
),
},
];
const wxColumns: ColumnsType<WxUser> = [
@@ -307,59 +258,6 @@ export default function GroupDetailPage() {
{ title: '首次进入', dataIndex: 'first_seen', width: 160, render: (v: string) => formatUtcTime(v) },
];
const dayUserColumns: ColumnsType<DayUser> = [
{
title: '用户',
key: 'user',
render: (_, u) => (
<Space>
{u.headimgurl ? (
<img
src={u.headimgurl}
alt=""
style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }}
/>
) : (
<span
style={{ width: 28, height: 28, borderRadius: '50%', background: '#f0f0f0', display: 'inline-block' }}
/>
)}
<span>{u.nickname || '(未授权昵称)'}</span>
</Space>
),
},
{
title: '领券次数',
dataIndex: 'copy_count',
width: 100,
sorter: (a, b) => a.copy_count - b.copy_count,
render: (v: number) => (v ? <b style={{ color: '#ff4d4f' }}>{v}</b> : 0),
},
{
title: '点击次数',
dataIndex: 'visit_count',
width: 100,
render: (v: number, u) =>
u.coupons.length ? (
<Tooltip
title={
<div>
{u.coupons.map((c) => (
<div key={c.name}>
{c.name} ×{c.count}
</div>
))}
</div>
}
>
<span style={{ cursor: 'help', textDecoration: 'underline dotted' }}>{v}</span>
</Tooltip>
) : (
v
),
},
];
return (
<div>
<Space style={{ marginBottom: 16 }} align="center" wrap>
@@ -409,7 +307,7 @@ export default function GroupDetailPage() {
columns={dailyColumns}
dataSource={daily?.rows ?? []}
pagination={false}
scroll={{ x: 1000 }}
scroll={{ x: 900 }}
/>
</Card>
)}
@@ -428,28 +326,6 @@ export default function GroupDetailPage() {
locale={{ emptyText: '暂无授权用户' }}
/>
</Card>
<Modal
open={dayDetail.open}
title={`群「${data?.group_name ?? ''}${dayDetail.date} · 用户领券明细`}
footer={null}
width={640}
height={600}
onCancel={() => setDayDetail((s) => ({ ...s, open: false }))}
>
<div style={{ fontSize: 12, color: '#999', marginBottom: 8 }}>
(openid);/,
</div>
<Table<DayUser>
rowKey="openid"
size="small"
loading={dayDetail.loading}
columns={dayUserColumns}
dataSource={dayDetail.users}
pagination={false}
locale={{ emptyText: '当天无授权用户领券/点击' }}
/>
</Modal>
</div>
);
}
@@ -49,7 +49,7 @@ interface PreviewItem {
const yuan = (cents: number) => (cents / 100).toFixed(2);
/** 首页轮播「种子」管理(真实记录不足时的兜底假数据,现为「生成规则」)。「系统配置 / 首页」里的一个区块。 */
/** 首页轮播「种子」管理(真实记录不足时的兜底假数据,现为「生成规则」)。「数据大盘」页的一个区块。 */
export default function HomeMarqueeSeeds() {
const { message } = App.useApp();
const [seeds, setSeeds] = useState<Seed[]>([]);
@@ -246,7 +246,7 @@ function buildBody(metric: string, e: Edit, immediate: boolean): Record<string,
return body;
}
/** 首页三门面数字(帮助用户/完成比价/累计节省)的展示模式配置。「系统配置 / 首页」里的一个区块。 */
/** 首页三门面数字(帮助用户/完成比价/累计节省)的展示模式配置。作为「数据大盘」页的一个区块。 */
export default function HomeStatsConfig() {
const { message, modal } = App.useApp();
const [items, setItems] = useState<StatItem[]>([]);
File diff suppressed because it is too large Load Diff
-253
View File
@@ -1,253 +0,0 @@
'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>
);
}
@@ -19,7 +19,7 @@ import {
} from 'antd';
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { formatWallTime } from '@/lib/format';
import { formatUtcTime } from '@/lib/format';
import { mediaUrl } from '@/lib/media';
import type { CursorPage, Feedback } from '@/lib/types';
@@ -153,7 +153,7 @@ export default function FeedbackHandleDrawer({ feedback, open, onClose, onDone }
{/* 本条反馈 */}
<div>
<Text type="secondary">
{feedback.user_id} · {formatWallTime(feedback.created_at)}
{feedback.user_id} · {formatUtcTime(feedback.created_at)}
</Text>
<Paragraph style={{ whiteSpace: 'pre-wrap', marginTop: 8, marginBottom: 8 }}>
{feedback.content || '-'}
@@ -185,7 +185,7 @@ export default function FeedbackHandleDrawer({ feedback, open, onClose, onDone }
<div>
:{statusTag(feedback.status)}
{feedback.reviewed_at ? (
<Text type="secondary"> · {formatWallTime(feedback.reviewed_at)}</Text>
<Text type="secondary"> · {formatUtcTime(feedback.reviewed_at)}</Text>
) : null}
</div>
{feedback.status === 'adopted' && (
@@ -292,7 +292,7 @@ export default function FeedbackHandleDrawer({ feedback, open, onClose, onDone }
>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Text type="secondary">
#{h.id} · {formatWallTime(h.created_at)}
#{h.id} · {formatUtcTime(h.created_at)}
</Text>
{statusTag(h.status)}
</div>
@@ -12,7 +12,7 @@ const { Text } = Typography;
const ACCEPT = ['image/jpeg', 'image/png', 'image/webp'];
/** 反馈页「加群二维码」卡配置。改完 App 意见反馈页下次进入即同步。「系统配置 → 反馈二维码」tab 的一个区块。 */
/** 反馈页「加群二维码」卡配置。改完 App 意见反馈页下次进入即同步。「反馈工单」页的一个区块。 */
export default function FeedbackQrConfig() {
const [cfg, setCfg] = useState<QrConfig | null>(null);
const [loading, setLoading] = useState(true);
+42 -162
View File
@@ -1,29 +1,26 @@
'use client';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import type { ColumnsType } from 'antd/es/table';
import type { SorterResult } from 'antd/es/table/interface';
import {
Button,
Card,
DatePicker,
Image,
Input,
Select,
Space,
Statistic,
Table,
Tag,
Typography,
} from 'antd';
import type { Dayjs } from 'dayjs';
import { api } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { formatWallTime } from '@/lib/format';
import { formatUtcTime } from '@/lib/format';
import { usePagedList } from '@/lib/usePagedList';
import type { Feedback, FeedbackSummary } from '@/lib/types';
import type { Feedback } from '@/lib/types';
import FeedbackQrConfig from './FeedbackQrConfig';
import FeedbackHandleDrawer from './FeedbackHandleDrawer';
import UserRecordsDrawer from '@/components/UserRecordsDrawer';
const { Text, Paragraph } = Typography;
const { RangePicker } = DatePicker;
@@ -44,75 +41,6 @@ const STATUS_META: Record<string, { label: string; color: string }> = {
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>;
};
// 审核结果展示(采纳=金币+批注 / 未采纳=原因 / 待审核=-),主表与「该用户全部反馈」抽屉共用
const renderReview = (f: Feedback) => {
if (f.status === 'adopted') {
return (
<Space direction="vertical" size={0}>
<Tag color="gold">+{f.reward_coins ?? 0} </Tag>
{f.review_note ? (
<Text type="secondary" style={{ fontSize: 12 }} ellipsis>
{f.review_note}
</Text>
) : null}
</Space>
);
}
if (f.status === 'rejected') {
return (
<Text type="secondary" style={{ fontSize: 12 }} ellipsis>
:{f.reject_reason || '-'}
</Text>
);
}
return <Text type="secondary">-</Text>;
};
// 机型 / OS版本(提交反馈时的端环境;改版前历史反馈无 → -)
const renderDeviceOs = (f: Feedback) => {
const os = [f.android_version ? `Android ${f.android_version}` : null, f.rom_name]
.filter(Boolean)
.join(' · ');
if (!f.device_model && !os) return <Text type="secondary">-</Text>;
return (
<Space direction="vertical" size={0}>
<Text>{f.device_model || '-'}</Text>
{os ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{os}
</Text>
) : null}
</Space>
);
};
// 点手机号弹出的「该用户全部反馈」抽屉列(精简版:不含操作/手机号,避免抽屉里再套抽屉)
const RECORD_COLUMNS: ColumnsType<Feedback> = [
{ title: '提交时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatWallTime(v) },
{ title: '状态', dataIndex: 'status', width: 80, render: statusTag },
{
title: '内容',
dataIndex: 'content',
render: (v: string) =>
v ? (
<Paragraph
style={{ marginBottom: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
ellipsis={{ rows: 2, expandable: true, symbol: '展开' }}
>
{v}
</Paragraph>
) : (
<Text type="secondary">-</Text>
),
},
{ title: '审核结果', key: 'review', width: 160, render: (_: unknown, f: Feedback) => renderReview(f) },
];
export default function FeedbacksPage() {
// 筛选草稿:点「查询」才应用(避免输入即刷新)
const [status, setStatus] = useState<string | undefined>();
@@ -130,19 +58,6 @@ export default function FeedbacksPage() {
const canReview = canDo(['operator']);
const [summary, setSummary] = useState<FeedbackSummary | null>(null);
const loadSummary = async () => {
try {
const { data } = await api.get<FeedbackSummary>('/admin/api/feedbacks/summary');
setSummary(data);
} catch {
/* 统计失败不阻塞列表 */
}
};
useEffect(() => {
loadSummary();
}, []);
// 审核/查看抽屉(采纳发金币 / 拒绝填原因 + 看该用户历史反馈)
const [drawerFb, setDrawerFb] = useState<Feedback | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
@@ -151,10 +66,6 @@ export default function FeedbacksPage() {
setDrawerOpen(true);
};
// 点手机号:看该用户全部反馈
const [recordsUser, setRecordsUser] = useState<{ userId: number; phone: string | null } | null>(null);
const openUserRecords = (f: Feedback) => setRecordsUser({ userId: f.user_id, phone: f.phone });
const search = () =>
setApplied({
status,
@@ -193,41 +104,8 @@ export default function FeedbacksPage() {
sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null;
const columns: ColumnsType<Feedback> = [
{
title: '用户ID',
dataIndex: 'user_id',
width: 80,
render: (v: number) => <Text strong>#{v}</Text>,
},
{
title: '手机号',
dataIndex: 'phone',
width: 130,
render: (_: unknown, f: Feedback) =>
f.phone ? (
<a onClick={() => openUserRecords(f)}>{f.phone}</a>
) : (
<Text type="secondary"></Text>
),
},
{
title: '用户昵称',
dataIndex: 'nickname',
width: 120,
render: (v: string | null) => v || <Text type="secondary"></Text>,
},
{
title: '提交版本号',
dataIndex: 'app_version',
width: 100,
render: (v: string | null) => v || <Text type="secondary">-</Text>,
},
{
title: '机型 / OS版本',
key: 'device',
width: 150,
render: (_: unknown, f: Feedback) => renderDeviceOs(f),
},
{ title: 'ID', dataIndex: 'id', width: 70, sorter: true, sortOrder: sortOrderOf('id') },
{ title: '用户', dataIndex: 'user_id', width: 80 },
{
title: '内容',
dataIndex: 'content',
@@ -267,20 +145,49 @@ export default function FeedbacksPage() {
<Text type="secondary"></Text>
),
},
{ title: '状态', dataIndex: 'status', width: 90, render: statusTag },
{
title: '状态',
dataIndex: 'status',
width: 90,
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) => renderReview(f),
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: '提交时间',
title: '时间',
dataIndex: 'created_at',
width: 170,
sorter: true,
sortOrder: sortOrderOf('created_at'),
render: (v: string) => formatWallTime(v),
render: (v: string) => formatUtcTime(v),
},
{
title: '操作',
@@ -297,21 +204,8 @@ export default function FeedbacksPage() {
return (
<div>
<h2></h2>
<div style={{ display: 'flex', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
<Card size="small" style={{ minWidth: 120 }}>
<Statistic title="待审核" value={summary?.pending ?? 0} valueStyle={{ color: '#fa8c16' }} />
</Card>
<Card size="small" style={{ minWidth: 120 }}>
<Statistic title="已采纳" value={summary?.adopted ?? 0} valueStyle={{ color: '#52c41a' }} />
</Card>
<Card size="small" style={{ minWidth: 120 }}>
<Statistic title="未采纳" value={summary?.rejected ?? 0} valueStyle={{ color: '#8c8c8c' }} />
</Card>
<Card size="small" style={{ minWidth: 120 }}>
<Statistic title="合计" value={summary?.total ?? 0} />
</Card>
</div>
<h2></h2>
<FeedbackQrConfig />
<Space style={{ marginBottom: 16 }} wrap>
<Select
placeholder="状态"
@@ -367,27 +261,13 @@ export default function FeedbacksPage() {
onChange: onPageChange,
}}
onChange={onTableChange}
scroll={{ x: 1610 }}
/>
<FeedbackHandleDrawer
feedback={drawerFb}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
onDone={() => { reload(); loadSummary(); }}
/>
<UserRecordsDrawer<Feedback>
open={recordsUser != null}
onClose={() => setRecordsUser(null)}
userId={recordsUser?.userId ?? null}
phone={recordsUser?.phone ?? null}
endpoint="/admin/api/feedbacks"
columns={RECORD_COLUMNS}
recordLabel="反馈"
countLabel="提交总计"
rewardLabel="提交奖励总计"
rewardOf={(f) => (f.status === 'adopted' ? (f.reward_coins ?? 0) : 0)}
onDone={reload}
/>
</div>
);
+2 -6
View File
@@ -8,8 +8,6 @@ import {
DatabaseOutlined,
FileSearchOutlined,
FlagOutlined,
GiftOutlined,
HeartOutlined,
LogoutOutlined,
MessageOutlined,
MobileOutlined,
@@ -31,12 +29,10 @@ 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: '/price-reports', icon: <FlagOutlined />, label: '上报审核' },
{ key: '/comparison-records', icon: <ProfileOutlined />, label: '比价记录' },
{ key: '/feedbacks', icon: <MessageOutlined />, label: '用户反馈' },
{ key: '/coupon-data', icon: <GiftOutlined />, label: '领券数据' },
{ key: '/feedbacks', icon: <MessageOutlined />, label: '反馈工单' },
{ key: '/ad-revenue', icon: <NotificationOutlined />, label: '广告配置' },
{ key: '/ad-revenue-report', icon: <BarChartOutlined />, label: '广告收益' },
{ key: '/cps', icon: <ShareAltOutlined />, label: 'CPS 分发' },
+67 -201
View File
@@ -16,7 +16,6 @@ import {
Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { SorterResult } from 'antd/es/table/interface';
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { api, errMsg } from '@/lib/api';
@@ -24,7 +23,6 @@ import { canDo } from '@/lib/auth';
import { mediaUrl } from '@/lib/media';
import { usePagedList } from '@/lib/usePagedList';
import type { PriceReport, PriceReportSummary } from '@/lib/types';
import UserRecordsDrawer from '@/components/UserRecordsDrawer';
const { Text } = Typography;
@@ -57,112 +55,19 @@ function statusTag(status: string) {
return <Tag color={STATUS_COLOR[status]}>{STATUS_LABEL[status] || status}</Tag>;
}
type SortField = 'id' | 'created_at';
// 门店/菜品、原价→上报价、审核结果:主表与「该用户全部上报」抽屉共用
const renderStore = (r: PriceReport) => (
<Space direction="vertical" size={0}>
<Text>{r.store_name || '-'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{r.dish_summary || '-'}
</Text>
</Space>
);
const renderPrice = (r: PriceReport) => (
<Space direction="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 12 }}>
{r.original_platform_name || '原'}: {yuan(r.original_price_cents)}
</Text>
<Text strong style={{ color: '#fa541c' }}>
{r.reported_platform_name}: {yuan(r.reported_price_cents)}
</Text>
</Space>
);
// 只读审核结果(「审核结果」列 + 抽屉共用,不带操作按钮;待审核在「状态」列已显示,这里给 -)
const renderResult = (r: PriceReport) => {
if (r.status === 'approved') return <Text type="success"> {r.reward_coins ?? 0} </Text>;
if (r.status === 'rejected') return <Text type="secondary">: {r.reject_reason || '-'}</Text>;
return <Text type="secondary">-</Text>;
};
// Trace:与「比价记录」页一致 —— 有 trace_url(本地比价=localhost debug viewer,线上=公网)
// 就给可点的「trace」链接,否则 -。
const traceLink = (r: PriceReport) =>
r.trace_url ? (
<a href={r.trace_url} target="_blank" rel="noreferrer">
trace
</a>
) : (
<Text type="secondary">-</Text>
);
// 机型 / OS版本(取自关联比价记录;无关联记录时为 -)
const renderDeviceOs = (r: PriceReport) => {
const os = [r.android_version ? `Android ${r.android_version}` : null, r.rom_name]
.filter(Boolean)
.join(' · ');
if (!r.device_model && !os) return <Text type="secondary">-</Text>;
return (
<Space direction="vertical" size={0}>
<Text>{r.device_model || '-'}</Text>
{os ? (
<Text type="secondary" style={{ fontSize: 12 }}>
{os}
</Text>
) : null}
</Space>
);
};
// 点手机号弹出的「该用户全部低价上报」抽屉列(精简版:不含操作/手机号)
const RECORD_COLUMNS: ColumnsType<PriceReport> = [
{ title: '提交时间', dataIndex: 'created_at', width: 150, render: (v: string) => dt(v) },
{ title: '门店 / 菜品', key: 'store', width: 180, render: (_: unknown, r: PriceReport) => renderStore(r) },
{ title: '原价 → 上报价', key: 'price', width: 200, render: (_: unknown, r: PriceReport) => renderPrice(r) },
{ title: '状态', dataIndex: 'status', width: 90, render: statusTag },
{ title: 'Trace', key: 'trace', width: 80, render: (_: unknown, r: PriceReport) => traceLink(r) },
{ title: '结果', key: 'result', width: 150, render: (_: unknown, r: PriceReport) => renderResult(r) },
];
export default function PriceReportsPage() {
const { message, modal } = App.useApp();
const [activeStatus, setActiveStatus] = useState('pending');
const [summary, setSummary] = useState<PriceReportSummary | null>(null);
const [rejecting, setRejecting] = useState<PriceReport | null>(null);
const [rejectReason, setRejectReason] = useState('');
// 提交时间列服务端排序(默认按提交时间倒序,最新在前)
const [sortBy, setSortBy] = useState<SortField>('created_at');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// 点手机号:看该用户全部低价上报
const [recordsUser, setRecordsUser] = useState<{ userId: number; phone: string | null } | null>(null);
const openUserRecords = (r: PriceReport) => setRecordsUser({ userId: r.user_id, phone: r.phone });
const filters: Record<string, unknown> = { sort_by: sortBy, sort_order: sortOrder };
const filters: Record<string, unknown> = {};
if (activeStatus !== 'all') filters.status = activeStatus;
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
usePagedList<PriceReport>('/admin/api/price-reports', filters);
const sortOrderOf = (field: SortField) =>
sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null;
const onTableChange = (
_pagination: unknown,
_filters: unknown,
sorter: SorterResult<PriceReport> | SorterResult<PriceReport>[],
) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter;
if (s && s.order && s.field) {
setSortBy(s.field as SortField);
setSortOrder(s.order === 'ascend' ? 'asc' : 'desc');
} else {
setSortBy('created_at');
setSortOrder('desc');
}
};
const canReview = canDo(['operator']);
const loadSummary = async () => {
@@ -231,51 +136,38 @@ export default function PriceReportsPage() {
const columns: ColumnsType<PriceReport> = [
{
title: '用户ID',
title: '用户',
dataIndex: 'user_id',
width: 80,
width: 90,
render: (v: number) => <Text strong>#{v}</Text>,
},
{
title: '手机号',
dataIndex: 'phone',
width: 130,
render: (_: unknown, r: PriceReport) =>
r.phone ? (
<a onClick={() => openUserRecords(r)}>{r.phone}</a>
) : (
<Text type="secondary"></Text>
),
},
{
title: '用户昵称',
dataIndex: 'nickname',
width: 120,
render: (v: string | null) => v || <Text type="secondary"></Text>,
},
{
title: '提交版本号',
dataIndex: 'app_version',
width: 100,
render: (v: string | null) => v || <Text type="secondary">-</Text>,
},
{
title: '机型 / OS版本',
key: 'device',
width: 150,
render: (_: unknown, r: PriceReport) => renderDeviceOs(r),
},
{
title: '门店 / 菜品',
key: 'store',
width: 200,
render: (_: unknown, r: PriceReport) => renderStore(r),
render: (_: unknown, r: PriceReport) => (
<Space direction="vertical" size={0}>
<Text>{r.store_name || '-'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{r.dish_summary || '-'}
</Text>
</Space>
),
},
{
title: '原最低价 → 上报价',
key: 'price',
width: 220,
render: (_: unknown, r: PriceReport) => renderPrice(r),
render: (_: unknown, r: PriceReport) => (
<Space direction="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 12 }}>
{r.original_platform_name || '原'}: {yuan(r.original_price_cents)}
</Text>
<Text strong style={{ color: '#fa541c' }}>
{r.reported_platform_name}: {yuan(r.reported_price_cents)}
</Text>
</Space>
),
},
{
title: '截图证明',
@@ -301,58 +193,52 @@ export default function PriceReportsPage() {
),
},
{ title: '状态', dataIndex: 'status', width: 90, render: statusTag },
{
title: '审核结果',
key: 'review',
width: 160,
render: (_: unknown, r: PriceReport) => renderResult(r),
},
{
title: '提交时间',
dataIndex: 'created_at',
width: 150,
sorter: true,
sortOrder: sortOrderOf('created_at'),
render: (v: string) => dt(v),
},
{
title: 'Trace',
key: 'trace',
width: 80,
render: (_: unknown, r: PriceReport) => traceLink(r),
},
{
title: '操作',
title: '操作 / 结果',
key: 'op',
fixed: 'right',
width: 150,
width: 200,
render: (_: unknown, r: PriceReport) => {
if (r.status !== 'pending') return <Text type="secondary">-</Text>;
if (!canReview) return <Text type="secondary"></Text>;
return (
<Space>
<Button
size="small"
type="link"
icon={<CheckCircleOutlined />}
onClick={() => approve(r)}
>
</Button>
<Button
size="small"
type="link"
danger
icon={<CloseCircleOutlined />}
onClick={() => {
setRejecting(r);
setRejectReason('');
}}
>
</Button>
</Space>
);
if (r.status === 'pending') {
if (!canReview) return <Text type="secondary"></Text>;
return (
<Space>
<Button
size="small"
type="link"
icon={<CheckCircleOutlined />}
onClick={() => approve(r)}
>
</Button>
<Button
size="small"
type="link"
danger
icon={<CloseCircleOutlined />}
onClick={() => {
setRejecting(r);
setRejectReason('');
}}
>
</Button>
</Space>
);
}
if (r.status === 'approved') {
return <Text type="success"> {r.reward_coins ?? 0} </Text>;
}
if (r.status === 'rejected') {
return <Text type="secondary">: {r.reject_reason || '-'}</Text>;
}
return <Text type="secondary">-</Text>;
},
},
];
@@ -361,7 +247,7 @@ export default function PriceReportsPage() {
<div>
<Space direction="vertical" style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Text type="secondary">
+ 1000 app
@@ -369,20 +255,14 @@ export default function PriceReportsPage() {
</Text>
</Space>
<div style={{ display: 'flex', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
<Card size="small" style={{ minWidth: 120 }}>
<Statistic title="待审核" value={summary?.pending ?? 0} valueStyle={{ color: '#fa8c16' }} />
</Card>
<Card size="small" style={{ minWidth: 120 }}>
<Statistic title="已通过" value={summary?.approved ?? 0} valueStyle={{ color: '#52c41a' }} />
</Card>
<Card size="small" style={{ minWidth: 120 }}>
<Statistic title="已拒绝" value={summary?.rejected ?? 0} valueStyle={{ color: '#8c8c8c' }} />
</Card>
<Card size="small" style={{ minWidth: 120 }}>
<Statistic title="合计" value={summary?.total ?? 0} />
</Card>
</div>
{summary && (
<Space size="large" style={{ marginBottom: 16 }}>
<Statistic title="待审核" value={summary.pending} valueStyle={{ color: '#faad14' }} />
<Statistic title="已通过" value={summary.approved} valueStyle={{ color: '#52c41a' }} />
<Statistic title="已拒绝" value={summary.rejected} />
<Statistic title="合计" value={summary.total} />
</Space>
)}
<Card>
<Tabs
@@ -395,7 +275,6 @@ export default function PriceReportsPage() {
columns={columns}
dataSource={items}
loading={loading}
onChange={onTableChange}
pagination={{
current: page,
pageSize,
@@ -404,7 +283,7 @@ export default function PriceReportsPage() {
showTotal: (t) => `${t}`,
onChange: onPageChange,
}}
scroll={{ x: 1790 }}
scroll={{ x: 1100 }}
/>
</Card>
@@ -441,19 +320,6 @@ export default function PriceReportsPage() {
</Space>
)}
</Modal>
<UserRecordsDrawer<PriceReport>
open={recordsUser != null}
onClose={() => setRecordsUser(null)}
userId={recordsUser?.userId ?? null}
phone={recordsUser?.phone ?? null}
endpoint="/admin/api/price-reports"
columns={RECORD_COLUMNS}
recordLabel="低价上报"
countLabel="上报总计"
rewardLabel="上报奖励总计"
rewardOf={(r) => (r.status === 'approved' ? (r.reward_coins ?? 0) : 0)}
/>
</div>
);
}
+23 -42
View File
@@ -3,7 +3,7 @@
// 调金币 / 调现金弹窗(受控)。列表页与用户详情页共用,避免两处各写一套。
// 每个弹窗自带「增减 / 设为指定值」两种模式;打开时拉一次 360 概览展示当前余额。
import { useEffect, useState } from 'react';
import { App, Form, Input, InputNumber, Modal, Radio, Segmented } from 'antd';
import { App, Form, Input, InputNumber, Modal, Segmented } from 'antd';
import { api, errMsg } from '@/lib/api';
import { yuan } from '@/lib/format';
import type { UserOverview } from '@/lib/types';
@@ -17,19 +17,15 @@ interface ModalProps {
onDone?: () => void; // 调整成功后回调(刷新余额/列表)
}
type Balances = { coin: number; cashCents: number; inviteCashCents: number };
const balanceLine = (balances: Balances | null) => (
const balanceLine = (balances: { coin: number; cashCents: number } | null) => (
<p style={{ color: '#888', marginBottom: 12 }}>
当前余额:金币 {balances ? balances.coin : '…'} (){' '}
{balances ? yuan(balances.cashCents) : '…'} {' '}
{balances ? yuan(balances.inviteCashCents) : '…'}
当前余额:金币 {balances ? balances.coin : '…'} {balances ? yuan(balances.cashCents) : ''}
</p>
);
// 弹窗打开时拉一次该用户余额;用户切换/关闭时丢弃过期响应(alive 标记防竞态)。
function useBalances(userId: number | undefined, open: boolean) {
const [balances, setBalances] = useState<Balances | null>(null);
const [balances, setBalances] = useState<{ coin: number; cashCents: number } | null>(null);
useEffect(() => {
if (!open || userId == null) {
setBalances(null);
@@ -40,12 +36,7 @@ function useBalances(userId: number | undefined, open: boolean) {
api
.get<UserOverview>(`/admin/api/users/${userId}`)
.then((r) => {
if (alive)
setBalances({
coin: r.data.coin_balance,
cashCents: r.data.cash_balance_cents,
inviteCashCents: r.data.invite_cash_balance_cents,
});
if (alive) setBalances({ coin: r.data.coin_balance, cashCents: r.data.cash_balance_cents });
})
.catch(() => {});
return () => {
@@ -122,39 +113,39 @@ export function AdjustCoinModal({ user, open, onClose, onDone }: ModalProps) {
export function AdjustCashModal({ user, open, onClose, onDone }: ModalProps) {
const { message } = App.useApp();
const [form] = Form.useForm();
// 操作方式改为表单必选项(无默认、手动选);用 watch 驱动金额栏文案/最小值
const mode = Form.useWatch('mode', form) as 'delta' | 'set' | undefined;
const [mode, setMode] = useState<'delta' | 'set'>('delta');
const balances = useBalances(user?.id, open);
useEffect(() => {
if (open) form.resetFields(); // 目标账户 / 操作方式 都重置为「未选」
if (open) {
setMode('delta');
form.resetFields();
}
}, [open, form]);
// 输入元,×100 转分后调后端(主要给无现金用户发钱直接测试提现)
const submit = async () => {
if (!user) return;
const v = await form.validateFields(); // 任一必填/必选缺失 → 该项下方红字 + 阻断提交
const v = await form.validateFields();
const amountCents = Math.round(Number(v.amount_yuan) * 100);
if (v.mode === 'delta' && amountCents === 0) {
if (mode === 'delta' && amountCents === 0) {
message.warning('金额不能为 0(且不小于 1 分)');
return;
}
if (v.mode === 'set' && amountCents < 0) {
if (mode === 'set' && amountCents < 0) {
message.warning('目标金额不能为负');
return;
}
const acctName = v.account === 'invite_cash' ? '邀请奖励金' : '现金';
try {
await api.post(`/admin/api/users/${user.id}/cash`, {
account: v.account,
mode: v.mode,
mode,
amount_cents: amountCents,
reason: v.reason,
});
message.success(
v.mode === 'set'
? `已设为 ¥${(amountCents / 100).toFixed(2)} ${acctName}`
: `${amountCents > 0 ? '发放' : '扣减'} ¥${(Math.abs(amountCents) / 100).toFixed(2)} ${acctName}`,
mode === 'set'
? `已设为 ¥${(amountCents / 100).toFixed(2)} 现金`
: `${amountCents > 0 ? '发放' : '扣减'} ¥${(Math.abs(amountCents) / 100).toFixed(2)} 现金`,
);
onClose();
onDone?.();
@@ -173,20 +164,10 @@ export function AdjustCashModal({ user, open, onClose, onDone }: ModalProps) {
>
{balanceLine(balances)}
<Form form={form} layout="vertical">
<Form.Item name="account" label="目标账户" rules={[{ required: true, message: '请选择目标账户' }]}>
<Radio.Group
optionType="button"
buttonStyle="solid"
options={[
{ label: '金币兑现金账户', value: 'coin_cash' },
{ label: '邀请账户', value: 'invite_cash' },
]}
/>
</Form.Item>
<Form.Item name="mode" label="操作方式" rules={[{ required: true, message: '请选择操作方式' }]}>
<Radio.Group
optionType="button"
buttonStyle="solid"
<Form.Item label="操作方式">
<Segmented
value={mode}
onChange={(v) => setMode(v as 'delta' | 'set')}
options={[
{ label: '增减', value: 'delta' },
{ label: '设为指定值', value: 'set' },
@@ -201,7 +182,7 @@ export function AdjustCashModal({ user, open, onClose, onDone }: ModalProps) {
: '现金变动(元,正=发放,负=扣减;不可扣成负)'
}
rules={[
{ required: true, message: '请输入现金变动金额' },
{ required: true },
...(mode === 'set' ? [{ type: 'number' as const, min: 0, message: '目标值不能为负' }] : []),
]}
>
@@ -213,7 +194,7 @@ export function AdjustCashModal({ user, open, onClose, onDone }: ModalProps) {
min={mode === 'set' ? 0 : undefined}
/>
</Form.Item>
<Form.Item name="reason" label="原因(入审计)" rules={[{ required: true, message: '请填写原因' }]}>
<Form.Item name="reason" label="原因(入审计)" rules={[{ required: true }]}>
<Input.TextArea rows={2} />
</Form.Item>
</Form>
-113
View File
@@ -1,113 +0,0 @@
'use client';
// 点列表里的手机号 → 抽屉列出该用户「同类型」的全部记录(反馈页=全部反馈;低价审核页=全部上报)。
// 顶部汇总该用户的「提交/上报总计」+「奖励总计」;复用各自的 list 接口按 user_id 过滤(无需新后端接口)。
import { useEffect, useState } from 'react';
import { Drawer, Empty, Space, Spin, Statistic, Table, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { api } from '@/lib/api';
import type { CursorPage } from '@/lib/types';
const { Text } = Typography;
interface Props<T> {
open: boolean;
onClose: () => void;
userId: number | null;
phone: string | null;
endpoint: string; // 列表接口,按 user_id 过滤,如 /admin/api/feedbacks
columns: ColumnsType<T>;
recordLabel: string; // 「反馈」/「低价上报」,用于标题与空态文案
countLabel: string; // 顶部计数卡标题,如「提交总计」/「上报总计」
rewardLabel: string; // 顶部奖励卡标题,如「提交奖励总计」/「上报奖励总计」
rewardOf: (item: T) => number; // 单条已发奖励金币(未采纳/未通过返回 0)
rewardSuffix?: string; // 第二个汇总卡单位,默认「金币」;领券场景可传「张」(领到券张数)等
}
export default function UserRecordsDrawer<T extends { id: number }>({
open,
onClose,
userId,
phone,
endpoint,
columns,
recordLabel,
countLabel,
rewardLabel,
rewardOf,
rewardSuffix = '金币',
}: Props<T>) {
const [items, setItems] = useState<T[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open || userId == null) return;
let alive = true;
setLoading(true);
api
.get<CursorPage<T>>(endpoint, {
params: { user_id: userId, limit: 100, sort_by: 'created_at', sort_order: 'desc' },
})
.then((r) => {
if (!alive) return;
setItems(r.data.items);
setTotal(r.data.total ?? r.data.items.length);
})
.catch(() => {
if (!alive) return;
setItems([]);
setTotal(0);
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [open, userId, endpoint]);
// 奖励总计在已加载记录上累加(单用户记录数远小于 100 上限,够用)
const rewardTotal = items.reduce((sum, it) => sum + rewardOf(it), 0);
return (
<Drawer
title={
<span>
#{userId ?? '-'} {recordLabel}
{phone ? (
<Text type="secondary" style={{ fontSize: 13, marginLeft: 8 }}>
{phone}
</Text>
) : null}
</span>
}
width={760}
open={open}
onClose={onClose}
destroyOnHidden
>
{loading ? (
<Spin style={{ display: 'block', margin: '40px auto' }} />
) : (
<>
<Space size={48} style={{ marginBottom: 20 }}>
<Statistic title={countLabel} value={total} />
<Statistic title={rewardLabel} value={rewardTotal} suffix={rewardSuffix} />
</Space>
{items.length === 0 ? (
<Empty description={`该用户暂无${recordLabel}`} style={{ marginTop: 24 }} />
) : (
<Table<T>
rowKey="id"
size="small"
columns={columns}
dataSource={items}
pagination={false}
/>
)}
</>
)}
</Drawer>
);
}
+6 -158
View File
@@ -44,44 +44,10 @@ 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;
cash_balance_cents: number;
invite_cash_balance_cents: number; // 邀请奖励金余额(与 cash_balance_cents 物理隔离)
total_coin_earned: number;
comparison_total: number;
comparison_success: number;
@@ -248,22 +214,6 @@ export interface Feedback {
reviewed_by_admin_id: number | null;
reviewed_at: string | null;
created_at: string;
// 提交端环境快照:提交版本号 / 机型OS版本;改版前的历史反馈为 null
app_version: string | null;
device_model: string | null;
rom_name: string | null;
android_version: string | null;
// 联表瞬态:展示完整手机号(点手机号查该用户全部反馈)
phone: string | null;
nickname: string | null;
}
// 反馈审核台顶部各状态计数(pending 含历史 new 态)。后端 GET /admin/api/feedbacks/summary。
export interface FeedbackSummary {
pending: number;
adopted: number;
rejected: number;
total: number;
}
// 反馈页「加群二维码」卡配置(运营后台改 → App 意见反馈页同步)。
@@ -382,32 +332,15 @@ export interface AdRevenueRecord {
matched: boolean;
}
// 广告收益报表:按日期汇总的一天(按天趋势图用;全量,不受分页影响)
// 广告收益报表:按日期汇总的一天(按天趋势图用;全量,不受 limit 影响)
export interface AdRevenueDaily {
date: string; // 北京时间 YYYY-MM-DD
impressions: number;
revenue_yuan: number; // 客户端预估收益(eCPM 折算)
pangle_revenue_yuan: number | null; // 穿山甲后台预估收益(GroMore revenue);非全量视图/无数据为 null
pangle_api_revenue_yuan: number | null; // 穿山甲收益API(更接近结算);未配/当天/无数据为 null
expected_coin: number;
actual_coin: number;
}
// 广告收益报表:按北京小时(0–23)汇总的一小时(按小时趋势图用;全量,不受分页影响;按天查询时为空)
export interface AdRevenueHourly {
hour: number; // 北京时间小时 023
impressions: number;
revenue_yuan: number;
expected_coin: number;
actual_coin: number;
}
// 广告收益报表:按广告类型(ad_type)的小计(展示条数 + 预估收益;eCPM 前端用 收益÷展示×1000 算)
export interface AdRevenueTypeStat {
impressions: number; // 该类型展示条数合计
revenue_yuan: number; // 该类型预估收益合计(元)
}
// 广告收益报表:一次广告事件(逐条一行)。激励视频展示+发奖按 ad_session_id 合并;信息流展示/发奖各自成行。
export interface AdRevenueRow {
event_key: string; // 事件稳定唯一键(前端 rowKey)
@@ -415,7 +348,6 @@ export interface AdRevenueRow {
user_id: number;
user_phone: string | null; // 用户手机号(admin 展示;查不到为空)
ad_type: string; // reward_video / feed / draw
feed_scene?: string | null; // Draw 信息流投放场景 comparison(比价) / coupon(领券) / welfare(福利);非信息流或旧数据为 null
app_env: string | null; // prod(傻瓜比价) / test(测试);旧数据为空
our_code_id: string | null; // 我们配置的代码位 104xxx;旧数据为空
hour: number | null; // 北京时间小时 0–23(按小时粒度时有值;按天为 null)
@@ -440,22 +372,10 @@ export interface AdRevenueReport {
date_from: string; // 起始日 北京时间 YYYY-MM-DD
date_to: string; // 结束日 北京时间 YYYY-MM-DD(闭区间;单日时与 date_from 相同)
daily: AdRevenueDaily[]; // 按日期汇总序列(全量,供按天趋势图)
hourly: AdRevenueHourly[]; // 按小时汇总序列(全量,供按小时趋势图;按天查询时为空)
type_stats: Record<string, AdRevenueTypeStat>; // 按广告类型小计;前端取 draw / reward_video
// 可选:后端预聚合的「按广告类型」富小计(含发放金币明细)。当前后端未下发 → 数据大盘页
// (dashboard adTypeSummary)用 `?.` 探测、缺失时按 items 现算兜底;后端补上即自动启用。
by_ad_type?: Record<
string,
{ ad_type: string; impressions: number; revenue_yuan: number; expected_coin: number; actual_coin: number }
>;
dau: number | null; // 今日活跃(复用大盘 last_login_at);仅查询=今日时有值,否则 null
total: number; // 当前筛选下的分页总条数(全量,不受分页影响)
truncated: boolean; // 当前页之后是否还有更多事件(分页后前端不再据此报警)
total: number; // 广告事件总数(全量,不受 limit 影响)
truncated: boolean;
total_impressions: number;
total_revenue_yuan: number; // 客户端预估收益合计(eCPM 折算)
total_pangle_revenue_yuan: number | null; // 穿山甲后台预估收益合计(GroMore revenue);非全量视图/无数据为 null
total_pangle_api_revenue_yuan: number | null; // 穿山甲收益API合计(更接近结算);未配/当天/非全量视图为 null
pangle_revenue_available: boolean; // 本次结果是否带穿山甲后台收益(全量视图且已同步到数据)
total_revenue_yuan: number;
total_expected_coin: number;
total_actual_coin: number;
mismatch_count: number; // 应发≠实发的发奖条数
@@ -471,17 +391,7 @@ export interface DashboardOverview {
new_today: number;
dau: number;
};
coins: {
reward_video_coin_total: number;
reward_video_watch_count: number;
feed_ad_coin_total: number;
feed_ad_watch_count: number;
signin_coin_total: number;
signin_count: number;
signin_boost_coin_total: number;
signin_boost_watch_count: number;
granted_total: number;
};
coins: { granted_total: number };
cash: {
withdraw_success_cents: number;
withdraw_pending_count: number;
@@ -489,60 +399,8 @@ export interface DashboardOverview {
withdraw_failed_count: number;
};
comparison: { total: number; success: number; success_rate: number };
// 时段大盘(对应后端 app/admin/schemas/dashboard.py DashboardPeriod);新版数据大盘页用
period: {
date_from: string;
date_to: string;
users: {
new: number;
active: number;
retained_new_users: number;
retention_rate: number | null;
retention_note: string;
};
comparison: {
total: number;
success: number;
success_rate: number;
ordered: number;
average_duration_ms: number | null;
average_saved_cents: number | null;
};
coins: {
granted_total: number;
reward_video_coin_total: number;
feed_ad_coin_total: number;
signin_coin_total: number;
signin_boost_coin_total: number;
task_coin_total: number;
coupon_reward_coin_total: number;
comparison_reward_coin_total: number;
regular_task_coin_total: number;
};
cash: { withdraw_success_cents: number };
trend: {
date: string;
active_users: number;
new_users: number;
comparisons: number;
}[];
};
feedback: { new: number };
cps: {
available: boolean;
note: string;
meituan_order_count: number;
meituan_commission_cents: number;
meituan_hit_count: number;
meituan_miss_count: number;
meituan_unknown_rate_count: number;
meituan_hit_rate: number | null;
jd_order_count?: number;
jd_commission_cents?: number;
jd_actual_commission_cents?: number;
jd_estimated_commission_cents?: number;
jd_invalid_count?: number;
};
cps: { available: boolean; note: string };
}
export interface PriceReport {
@@ -563,16 +421,6 @@ export interface PriceReport {
reward_coins: number | null;
reviewed_at: string | null;
created_at: string;
// 联表瞬态:phone/nickname 展示完整手机号(点手机号查该用户全部上报);
// 其余取自关联比价记录(无关联记录时为 null):trace 调试链接、机型OS版本、提交版本号
phone: string | null;
nickname: string | null;
trace_id: string | null;
trace_url: string | null;
device_model: string | null; // 机型,如 PEEM00
rom_name: string | null; // ROM/OS 名,如 ColorOS / MIUI / HarmonyOS
android_version: string | null; // Android 版本号,如 13
app_version: string | null; // 提交时我们 app 的 versionName,如 1.2.3
}
export interface PriceReportSummary {