Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 510ce349f7 | |||
| 03ef222b71 | |||
| 6ece274af6 | |||
| d3efb3c487 | |||
| 5f2511e7cd | |||
| 9b7a1aef8a |
@@ -0,0 +1,746 @@
|
||||
# CPS 群详情 · 每日明细「按天 · 按用户」领券下钻 — 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 在 CPS 群详情页每日明细表每行加「明细」按钮,点开弹窗展示该群该天以用户为单位的领券/点击,并在「点击次数」上悬停展示该用户当天点过的优惠券(券名 ×次数)。
|
||||
|
||||
**Architecture:** 后端 `shaguabijia-app-server` 新增只读聚合接口 `/admin/api/cps/groups/{id}/day-users?date=YYYY-MM-DD`(券明细内联返回),并把既有 `/daily` 的 `date` 由 `MM-DD` 改全为 `YYYY-MM-DD` 供下钻定位整天。前端 `shaguabijia-admin-web` 加明细列 + Modal + Tooltip,一次请求渲染。纯读侧聚合,**不动数据库**。
|
||||
|
||||
**Tech Stack:** 后端 FastAPI + SQLAlchemy + pytest(`.venv`);前端 Next.js 15 + Ant Design 5 + TypeScript(无测试框架,门槛为 `npm run build`)。
|
||||
|
||||
**配套 spec:** `docs/superpowers/specs/2026-06-26-cps-day-user-drilldown-design.md`
|
||||
|
||||
---
|
||||
|
||||
## 仓库与分支
|
||||
|
||||
- **前端** `e:\project\shaguabijia-admin-web`:已在分支 `feat/cps-day-user-drilldown`。
|
||||
- **后端** `e:\project\shaguabijia-app-server`:独立 git 仓,需自建同名分支(Task 1 Step 0)。
|
||||
|
||||
后端命令统一**从后端仓根目录运行**;git 用 `git -C e:\project\shaguabijia-app-server ...` 避免切目录。
|
||||
|
||||
## File Structure
|
||||
|
||||
| 文件 | 改动 | 职责 |
|
||||
|---|---|---|
|
||||
| `app/admin/routers/cps.py`(后端) | 改 1 行 + 加 1 个 endpoint | `/daily` 的 `date` 改全日期;新增 `/day-users` 路由(鉴权、日期解析、窗口计算) |
|
||||
| `app/admin/repositories/cps.py`(后端) | 加 1 个函数 + 补 1 处 import | `group_day_users(...)`:按 openid 聚合领券/点击 + 券明细(Python 侧) |
|
||||
| `tests/test_cps_admin.py`(后端) | 新建 | admin CPS 端点测试:`/daily` 日期格式 + `/day-users` 聚合与边界 |
|
||||
| `src/app/(main)/cps/groups/[id]/page.tsx`(前端) | 改 | 日期列加宽、明细列、Modal、Tooltip、按用户表 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 后端 `/daily` 的 `date` 改为 `YYYY-MM-DD`
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/admin/routers/cps.py:472`(`group_daily` 内构行处)
|
||||
- Test: `tests/test_cps_admin.py`(新建)
|
||||
|
||||
- [ ] **Step 0: 在后端仓建分支**
|
||||
|
||||
先看工作区状态,避免把无关改动带入:
|
||||
|
||||
Run:
|
||||
```
|
||||
git -C e:\project\shaguabijia-app-server status --short
|
||||
git -C e:\project\shaguabijia-app-server checkout -b feat/cps-day-user-drilldown
|
||||
```
|
||||
Expected: 切到新分支 `feat/cps-day-user-drilldown`。
|
||||
|
||||
- [ ] **Step 1: 写失败测试(新建测试文件,含共用 fixtures + 种子)**
|
||||
|
||||
新建 `tests/test_cps_admin.py`,内容如下(fixtures 仿 `tests/test_admin_read.py` 自包含;种子函数后续 Task 复用):
|
||||
|
||||
```python
|
||||
"""admin CPS 端点测试:每日明细 date 格式 + 按天按用户领券下钻(/day-users)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.admin.main import admin_app
|
||||
from app.admin.repositories import admin_user as admin_repo
|
||||
from app.admin.repositories import cps as cps_repo
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.cps_link import CpsClick
|
||||
from app.models.cps_wx_user import CpsWxUser
|
||||
from app.repositories import cps_link as cps_link_repo
|
||||
|
||||
_BJ = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def admin_client() -> TestClient:
|
||||
return TestClient(admin_app)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def admin_token() -> str:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if admin_repo.get_by_username(db, "cps_test_admin") is None:
|
||||
admin_repo.create_admin(
|
||||
db, username="cps_test_admin", password="cps-pass", role="super_admin"
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
c = TestClient(admin_app)
|
||||
r = c.post("/admin/api/auth/login", json={"username": "cps_test_admin", "password": "cps-pass"})
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
def _auth(token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _seed_cps_day(day_bj: date) -> tuple[int, str]:
|
||||
"""造淘宝群 + 2 个活动(券) + 2 条 link + day_bj 当天点击(含匿名 + 次日各 1) + 授权画像。
|
||||
|
||||
o_user(本群唯一):券A visit×3、券B visit×2、copy×2 → visit_count=5、copy_count=2。
|
||||
另造 1 条匿名 visit(openid=None)与 1 条次日 visit,均不应计入当天该用户聚合。
|
||||
clicked_at 统一存 tz-aware UTC(SQLite DateTime 按字段渲染、忽略 tzinfo;
|
||||
转 UTC 后字段即 UTC 墙钟,与 repo 侧 _as_utc 比较口径一致)。返回 (group_id, openid)。
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
gid = cps_repo.create_group(db, name="下钻测试群", platforms=["taobao"]).id
|
||||
openid = f"od_user_{gid}" # 按群唯一,规避 CpsWxUser.openid 唯一约束跨用例冲突
|
||||
a1 = cps_repo.create_activity(db, name="618神券", platform="taobao", payload="tkl-1")
|
||||
a2 = cps_repo.create_activity(db, name="买一送一", platform="taobao", payload="tkl-2")
|
||||
link1 = cps_link_repo.create_link(
|
||||
db, group_id=gid, activity_id=a1.id, sid=None, target_url="t1", platform="taobao"
|
||||
)
|
||||
link2 = cps_link_repo.create_link(
|
||||
db, group_id=gid, activity_id=a2.id, sid=None, target_url="t2", platform="taobao"
|
||||
)
|
||||
db.add(CpsWxUser(openid=openid, nickname="张三", headimgurl="https://h/1"))
|
||||
db.commit()
|
||||
|
||||
def at(hour: int) -> datetime:
|
||||
return datetime(day_bj.year, day_bj.month, day_bj.day, hour, tzinfo=_BJ).astimezone(
|
||||
timezone.utc
|
||||
)
|
||||
|
||||
for _ in range(3):
|
||||
db.add(CpsClick(link_id=link1.id, group_id=gid, sid=None,
|
||||
event_type="visit", openid=openid, clicked_at=at(10)))
|
||||
for _ in range(2):
|
||||
db.add(CpsClick(link_id=link2.id, group_id=gid, sid=None,
|
||||
event_type="visit", openid=openid, clicked_at=at(11)))
|
||||
for _ in range(2):
|
||||
db.add(CpsClick(link_id=link1.id, group_id=gid, sid=None,
|
||||
event_type="copy", openid=openid, clicked_at=at(12)))
|
||||
# 匿名点击 — 不计入
|
||||
db.add(CpsClick(link_id=link1.id, group_id=gid, sid=None,
|
||||
event_type="visit", openid=None, clicked_at=at(13)))
|
||||
# 次日点击 — 验证时间窗,不计入当天
|
||||
nxt = (datetime(day_bj.year, day_bj.month, day_bj.day, 10, tzinfo=_BJ)
|
||||
+ timedelta(days=1)).astimezone(timezone.utc)
|
||||
db.add(CpsClick(link_id=link1.id, group_id=gid, sid=None,
|
||||
event_type="visit", openid=openid, clicked_at=nxt))
|
||||
db.commit()
|
||||
return gid, openid
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_daily_date_is_full_iso(admin_client: TestClient, admin_token: str) -> None:
|
||||
"""/daily 每行 date 改为 YYYY-MM-DD(10 字符、两个连字符),不再是 MM-DD。"""
|
||||
gid, _ = _seed_cps_day(date(2026, 6, 25))
|
||||
r = admin_client.get(
|
||||
f"/admin/api/cps/groups/{gid}/daily", params={"days": 3}, headers=_auth(admin_token)
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
rows = r.json()["rows"]
|
||||
assert rows, "应有按天补零行"
|
||||
for row in rows:
|
||||
assert len(row["date"]) == 10 and row["date"].count("-") == 2, row["date"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run(从后端仓根目录 `e:\project\shaguabijia-app-server`):
|
||||
```
|
||||
.\.venv\Scripts\pytest.exe tests\test_cps_admin.py::test_daily_date_is_full_iso -v
|
||||
```
|
||||
Expected: FAIL —— `date` 当前为 `"%m-%d"`(长度 5),断言 `len==10` 不通过。
|
||||
|
||||
- [ ] **Step 3: 改 router 的日期格式**
|
||||
|
||||
`app/admin/routers/cps.py` 的 `group_daily` 内(约 472 行),把构行的日期字段改为全日期:
|
||||
|
||||
```python
|
||||
row = {
|
||||
"date": cur.strftime("%Y-%m-%d"),
|
||||
"click_pv": cp["click_pv"] if cp else 0,
|
||||
"click_uv": cp["click_uv"] if cp else 0,
|
||||
"copy_pv": cp["copy_pv"] if cp else 0,
|
||||
}
|
||||
```
|
||||
|
||||
(仅把原 `cur.strftime("%m-%d")` 改成 `cur.strftime("%Y-%m-%d")`,其余不动。)
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
|
||||
Run:
|
||||
```
|
||||
.\.venv\Scripts\pytest.exe tests\test_cps_admin.py::test_daily_date_is_full_iso -v
|
||||
```
|
||||
Expected: PASS。
|
||||
|
||||
- [ ] **Step 5: 提交(后端仓)**
|
||||
|
||||
```
|
||||
git -C e:\project\shaguabijia-app-server add app/admin/routers/cps.py tests/test_cps_admin.py
|
||||
git -C e:\project\shaguabijia-app-server commit -m "feat(cps): /daily 的 date 改为 YYYY-MM-DD(下钻定位整天用)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 后端新增 `group_day_users` 聚合 + `/day-users` 接口
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/admin/repositories/cps.py:20`(import 补 `CpsLink`)、文件末尾加函数
|
||||
- Modify: `app/admin/routers/cps.py`(`group_wx_users` 路由后追加新路由)
|
||||
- Test: `tests/test_cps_admin.py`(追加)
|
||||
|
||||
- [ ] **Step 1: 写失败测试(聚合 happy path,含匿名/跨天排除、券倒序、合计自洽)**
|
||||
|
||||
在 `tests/test_cps_admin.py` 末尾追加:
|
||||
|
||||
```python
|
||||
def test_day_users_aggregates(admin_client: TestClient, admin_token: str) -> None:
|
||||
"""当天该群:仅授权用户;领券=copy、点击=visit;coupons=visit 券×次数倒序、合计=点击次数。"""
|
||||
gid, openid = _seed_cps_day(date(2026, 6, 25))
|
||||
r = admin_client.get(
|
||||
f"/admin/api/cps/groups/{gid}/day-users",
|
||||
params={"date": "2026-06-25"},
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["group_id"] == gid
|
||||
assert body["date"] == "2026-06-25"
|
||||
users = body["users"]
|
||||
assert len(users) == 1 # 匿名不计、次日不计
|
||||
u = users[0]
|
||||
assert u["openid"] == openid
|
||||
assert u["nickname"] == "张三"
|
||||
assert u["headimgurl"] == "https://h/1"
|
||||
assert u["copy_count"] == 2
|
||||
assert u["visit_count"] == 5
|
||||
assert [c["name"] for c in u["coupons"]] == ["618神券", "买一送一"]
|
||||
assert [c["count"] for c in u["coupons"]] == [3, 2]
|
||||
assert sum(c["count"] for c in u["coupons"]) == u["visit_count"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run:
|
||||
```
|
||||
.\.venv\Scripts\pytest.exe tests\test_cps_admin.py::test_day_users_aggregates -v
|
||||
```
|
||||
Expected: FAIL —— 404/路由不存在(`/day-users` 尚未实现)。
|
||||
|
||||
- [ ] **Step 3: 补 repo import**
|
||||
|
||||
`app/admin/repositories/cps.py` 第 20 行,给 `cps_link` 的 import 补上 `CpsLink`:
|
||||
|
||||
```python
|
||||
from app.models.cps_link import CpsClick, CpsLink
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 加 repo 聚合函数**
|
||||
|
||||
在 `app/admin/repositories/cps.py` 末尾(`group_wx_users` 之后)追加:
|
||||
|
||||
```python
|
||||
def group_day_users(
|
||||
db: Session, *, group_id: int, start: datetime, end: datetime, limit: int = 200,
|
||||
) -> list[dict]:
|
||||
"""该群某天(北京)以用户为单位的领券/点击 + 每人 visit 过的券。
|
||||
|
||||
时间窗为半开区间 [start, end)(end=次日 00:00),避免午夜双计。只统计 openid 非空
|
||||
(可归属到人)的点击 —— 匿名点击(美团/京东 302 多为匿名)不计入。券名 = 该点击 link
|
||||
对应活动名;活动被硬删则兜底 活动#{id}。copy=领券次数、visit=点击次数;coupons 仅
|
||||
取 visit 事件按活动分组、按次数倒序(合计 = visit_count)。排序:领券 desc、再点击 desc。
|
||||
与 group_wx_users 同风格(Python 侧聚合,跨 PG/SQLite 无方言坑)。
|
||||
|
||||
注:每日明细行的 click_pv/copy_pv 计全部点击(含匿名、UV 按 ip,ua);本函数只计 openid
|
||||
用户,故各用户求和 <= 当天行总数,二者口径不同、不必相等。
|
||||
"""
|
||||
rows = db.execute(
|
||||
select(CpsClick.openid, CpsClick.event_type, CpsClick.link_id)
|
||||
.where(CpsClick.group_id == group_id)
|
||||
.where(CpsClick.clicked_at >= _as_utc(start))
|
||||
.where(CpsClick.clicked_at < _as_utc(end))
|
||||
.where(CpsClick.openid.is_not(None))
|
||||
).all()
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
# link_id -> activity_id -> 券名(活动名)
|
||||
link_ids = {r.link_id for r in rows}
|
||||
link_to_act = dict(
|
||||
db.execute(
|
||||
select(CpsLink.id, CpsLink.activity_id).where(CpsLink.id.in_(link_ids))
|
||||
).all()
|
||||
)
|
||||
act_ids = {aid for aid in link_to_act.values() if aid is not None}
|
||||
act_name = (
|
||||
dict(
|
||||
db.execute(
|
||||
select(CpsActivity.id, CpsActivity.name).where(CpsActivity.id.in_(act_ids))
|
||||
).all()
|
||||
)
|
||||
if act_ids
|
||||
else {}
|
||||
)
|
||||
|
||||
def _coupon_name(link_id: int) -> str:
|
||||
aid = link_to_act.get(link_id)
|
||||
if aid is None:
|
||||
return f"链接#{link_id}"
|
||||
return act_name.get(aid) or f"活动#{aid}"
|
||||
|
||||
stat: dict[str, dict] = {}
|
||||
for openid, event_type, link_id in rows:
|
||||
s = stat.setdefault(openid, {"copy": 0, "visit": 0, "coupons": {}})
|
||||
if event_type == "copy":
|
||||
s["copy"] += 1
|
||||
else:
|
||||
s["visit"] += 1
|
||||
name = _coupon_name(link_id)
|
||||
s["coupons"][name] = s["coupons"].get(name, 0) + 1
|
||||
|
||||
openids = list(stat.keys())
|
||||
users = {
|
||||
u.openid: u
|
||||
for u in db.execute(
|
||||
select(CpsWxUser).where(CpsWxUser.openid.in_(openids))
|
||||
).scalars().all()
|
||||
}
|
||||
|
||||
result = [
|
||||
{
|
||||
"openid": openid,
|
||||
"nickname": users[openid].nickname if openid in users else None,
|
||||
"headimgurl": users[openid].headimgurl if openid in users else None,
|
||||
"copy_count": s["copy"],
|
||||
"visit_count": s["visit"],
|
||||
"coupons": [
|
||||
{"name": name, "count": cnt}
|
||||
for name, cnt in sorted(
|
||||
s["coupons"].items(), key=lambda kv: kv[1], reverse=True
|
||||
)
|
||||
],
|
||||
}
|
||||
for openid, s in stat.items()
|
||||
]
|
||||
result.sort(key=lambda x: (x["copy_count"], x["visit_count"]), reverse=True)
|
||||
return result[:limit]
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 加 router 端点**
|
||||
|
||||
`app/admin/routers/cps.py`,在 `group_wx_users` 路由(文件末尾那个)之后追加:
|
||||
|
||||
```python
|
||||
@router.get("/groups/{group_id}/day-users", summary="某天该群按用户的领券/点击 + 每人点过的券")
|
||||
def group_day_users(
|
||||
group_id: int,
|
||||
db: AdminDb,
|
||||
date: Annotated[str, Query(description="北京日期 YYYY-MM-DD")],
|
||||
) -> dict:
|
||||
group = cps_repo.get_group(db, group_id)
|
||||
if group is None:
|
||||
raise HTTPException(status_code=404, detail="群不存在")
|
||||
bj = timezone(timedelta(hours=8))
|
||||
try:
|
||||
day0 = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=bj)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail="date 格式应为 YYYY-MM-DD") from e
|
||||
start = day0.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
end = start + timedelta(days=1)
|
||||
users = cps_repo.group_day_users(db, group_id=group_id, start=start, end=end)
|
||||
return {
|
||||
"group_id": group.id,
|
||||
"group_name": group.name,
|
||||
"date": date,
|
||||
"users": users,
|
||||
}
|
||||
```
|
||||
|
||||
(`datetime`/`timedelta`/`timezone`、`Annotated`、`Query`、`HTTPException` 该文件均已 import;无需新增。)
|
||||
|
||||
- [ ] **Step 6: 跑测试确认通过**
|
||||
|
||||
Run:
|
||||
```
|
||||
.\.venv\Scripts\pytest.exe tests\test_cps_admin.py::test_day_users_aggregates -v
|
||||
```
|
||||
Expected: PASS。
|
||||
|
||||
- [ ] **Step 7: 提交(后端仓)**
|
||||
|
||||
```
|
||||
git -C e:\project\shaguabijia-app-server add app/admin/repositories/cps.py app/admin/routers/cps.py tests/test_cps_admin.py
|
||||
git -C e:\project\shaguabijia-app-server commit -m "feat(cps): 新增 /day-users 按天按用户领券下钻接口"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: 后端 `/day-users` 边界用例(404 / 400 / 401 / 空天 / 跨年)
|
||||
|
||||
**Files:**
|
||||
- Test: `tests/test_cps_admin.py`(追加)
|
||||
|
||||
> 这些断言验证 Task 2 实现已覆盖的边界。多数应直接通过;若某条失败,回到 Task 2 对应分支修实现。
|
||||
|
||||
- [ ] **Step 1: 追加边界测试**
|
||||
|
||||
在 `tests/test_cps_admin.py` 末尾追加:
|
||||
|
||||
```python
|
||||
def test_day_users_group_not_found(admin_client: TestClient, admin_token: str) -> None:
|
||||
r = admin_client.get(
|
||||
"/admin/api/cps/groups/999999/day-users",
|
||||
params={"date": "2026-06-25"},
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_day_users_bad_date(admin_client: TestClient, admin_token: str) -> None:
|
||||
gid, _ = _seed_cps_day(date(2026, 6, 25))
|
||||
r = admin_client.get(
|
||||
f"/admin/api/cps/groups/{gid}/day-users",
|
||||
params={"date": "2026/06/25"}, # 非 YYYY-MM-DD
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_day_users_requires_auth(admin_client: TestClient) -> None:
|
||||
r = admin_client.get(
|
||||
"/admin/api/cps/groups/1/day-users", params={"date": "2026-06-25"}
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_day_users_empty_when_no_clicks(admin_client: TestClient, admin_token: str) -> None:
|
||||
gid, _ = _seed_cps_day(date(2026, 6, 25))
|
||||
r = admin_client.get(
|
||||
f"/admin/api/cps/groups/{gid}/day-users",
|
||||
params={"date": "2026-06-20"}, # 该群当天无任何点击
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["users"] == []
|
||||
|
||||
|
||||
def test_day_users_cross_year(admin_client: TestClient, admin_token: str) -> None:
|
||||
"""跨年:YYYY-MM-DD 才能精确定位 12-31(MM-DD 会丢年份);次日(次年 01-01)不计入。"""
|
||||
gid, openid = _seed_cps_day(date(2025, 12, 31))
|
||||
r = admin_client.get(
|
||||
f"/admin/api/cps/groups/{gid}/day-users",
|
||||
params={"date": "2025-12-31"},
|
||||
headers=_auth(admin_token),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
users = r.json()["users"]
|
||||
assert len(users) == 1
|
||||
assert users[0]["openid"] == openid
|
||||
assert users[0]["visit_count"] == 5 # 次年 01-01 那条被时间窗排除
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑整个测试文件确认全绿**
|
||||
|
||||
Run:
|
||||
```
|
||||
.\.venv\Scripts\pytest.exe tests\test_cps_admin.py -v
|
||||
```
|
||||
Expected: 全部 PASS(共 7 个用例:daily 日期、aggregates、404、bad_date、auth、empty、cross_year)。
|
||||
|
||||
- [ ] **Step 3: 跑后端全量回归(确认没碰坏既有)**
|
||||
|
||||
Run:
|
||||
```
|
||||
.\.venv\Scripts\pytest.exe -q
|
||||
```
|
||||
Expected: 全绿(与改动前同样的通过数 + 新增 7 条)。
|
||||
|
||||
- [ ] **Step 4: 提交(后端仓)**
|
||||
|
||||
```
|
||||
git -C e:\project\shaguabijia-app-server add tests/test_cps_admin.py
|
||||
git -C e:\project\shaguabijia-app-server commit -m "test(cps): /day-users 边界用例(404/400/401/空天/跨年)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 前端日期列显示全日期并加宽
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/(main)/cps/groups/[id]/page.tsx`
|
||||
|
||||
前端无测试框架,门槛为类型/构建通过(`npm run build`)。
|
||||
|
||||
- [ ] **Step 1: 加宽日期列**
|
||||
|
||||
`dailyColumns` 第一列(约 174 行)`width: 64` → `width: 96`:
|
||||
|
||||
```tsx
|
||||
{ title: '日期', dataIndex: 'date', width: 96, fixed: 'left' },
|
||||
```
|
||||
|
||||
(`date` 现由后端给全 `YYYY-MM-DD`,直接显示即可;`DailyRow.date` 类型仍是 `string`,无需改接口。)
|
||||
|
||||
- [ ] **Step 2: 构建确认通过**
|
||||
|
||||
Run(前端仓根目录):
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
Expected: 构建成功,无类型错误。
|
||||
|
||||
- [ ] **Step 3: 提交(前端仓)**
|
||||
|
||||
```
|
||||
git add src/app/(main)/cps/groups/[id]/page.tsx
|
||||
git commit -m "feat(cps): 每日明细日期列改显示全日期并加宽"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 前端下钻 — 明细列 + Modal + Tooltip
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/(main)/cps/groups/[id]/page.tsx`
|
||||
|
||||
- [ ] **Step 1: antd 引入 Modal、Tooltip**
|
||||
|
||||
第 9 行 import 增加 `Modal, Tooltip`(保持字母序即可):
|
||||
|
||||
```tsx
|
||||
import { Button, Card, Col, Empty, Modal, Row, Segmented, Space, Spin, Statistic, Table, Tooltip, message } from 'antd';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 加按天用户类型**
|
||||
|
||||
在 `WxUser` 接口定义之后(约 58 行后)追加:
|
||||
|
||||
```tsx
|
||||
// 某天该群按用户领券下钻(/day-users)
|
||||
interface DayCoupon {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
interface DayUser {
|
||||
openid: string;
|
||||
nickname: string | null;
|
||||
headimgurl: string | null;
|
||||
copy_count: number;
|
||||
visit_count: number;
|
||||
coupons: DayCoupon[];
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 加弹窗状态 + 拉取 handler**
|
||||
|
||||
在组件内 `wxUsers` 相关 `useEffect` 之后(约 140 行后)追加。注意须在 `dailyColumns` 定义之前,供其按钮引用:
|
||||
|
||||
```tsx
|
||||
// 某天领券下钻弹窗
|
||||
const [dayDetail, setDayDetail] = useState<{
|
||||
open: boolean;
|
||||
date: string;
|
||||
loading: boolean;
|
||||
users: DayUser[];
|
||||
}>({ open: false, date: '', loading: false, users: [] });
|
||||
|
||||
const openDayDetail = useCallback(
|
||||
async (date: string) => {
|
||||
setDayDetail({ open: true, date, loading: true, users: [] });
|
||||
try {
|
||||
const r = await api.get<{ users: DayUser[] }>(
|
||||
`/admin/api/cps/groups/${id}/day-users?date=${date}`,
|
||||
);
|
||||
setDayDetail((s) => ({ ...s, loading: false, users: r.data.users || [] }));
|
||||
} catch (e) {
|
||||
message.error(errMsg(e, '明细加载失败'));
|
||||
setDayDetail((s) => ({ ...s, loading: false }));
|
||||
}
|
||||
},
|
||||
[id],
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 每日明细表加「明细」列 + 调大 scroll.x**
|
||||
|
||||
在 `dailyColumns` 数组末尾(`结算佣金` 列之后,约 226 行)追加一列:
|
||||
|
||||
```tsx
|
||||
{
|
||||
title: '明细',
|
||||
key: 'drill',
|
||||
width: 64,
|
||||
fixed: 'right',
|
||||
render: (_, r) => (
|
||||
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => openDayDetail(r.date)}>
|
||||
明细
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
```
|
||||
|
||||
同时,每日明细 `<Table>`(约 304–311 行)列宽合计已升至约 976(日期 96 + 新明细列 64),把其 `scroll={{ x: 900 }}` 改为 `scroll={{ x: 1000 }}`,保证右侧固定列与横向滚动正常:
|
||||
|
||||
```tsx
|
||||
scroll={{ x: 1000 }}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 加按用户表列定义**
|
||||
|
||||
在 `wxColumns` 定义之后(约 259 行后)追加 `dayUserColumns`:
|
||||
|
||||
```tsx
|
||||
const dayUserColumns: ColumnsType<DayUser> = [
|
||||
{
|
||||
title: '用户',
|
||||
key: 'user',
|
||||
render: (_, u) => (
|
||||
<Space>
|
||||
{u.headimgurl ? (
|
||||
<img
|
||||
src={u.headimgurl}
|
||||
alt=""
|
||||
style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
style={{ width: 28, height: 28, borderRadius: '50%', background: '#f0f0f0', display: 'inline-block' }}
|
||||
/>
|
||||
)}
|
||||
<span>{u.nickname || '(未授权昵称)'}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '领券次数',
|
||||
dataIndex: 'copy_count',
|
||||
width: 100,
|
||||
sorter: (a, b) => a.copy_count - b.copy_count,
|
||||
render: (v: number) => (v ? <b style={{ color: '#ff4d4f' }}>{v}</b> : 0),
|
||||
},
|
||||
{
|
||||
title: '点击次数',
|
||||
dataIndex: 'visit_count',
|
||||
width: 100,
|
||||
render: (v: number, u) =>
|
||||
u.coupons.length ? (
|
||||
<Tooltip
|
||||
title={
|
||||
<div>
|
||||
{u.coupons.map((c) => (
|
||||
<div key={c.name}>
|
||||
{c.name} ×{c.count}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span style={{ cursor: 'help', textDecoration: 'underline dotted' }}>{v}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
v
|
||||
),
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 渲染 Modal**
|
||||
|
||||
在最外层 `</div>`(return 末尾,约 329 行)之前、群内微信用户 `Card` 之后,追加:
|
||||
|
||||
```tsx
|
||||
<Modal
|
||||
open={dayDetail.open}
|
||||
title={`群「${data?.group_name ?? ''}」${dayDetail.date} · 用户领券明细`}
|
||||
footer={null}
|
||||
width={640}
|
||||
onCancel={() => setDayDetail((s) => ({ ...s, open: false }))}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: '#999', marginBottom: 8 }}>
|
||||
仅统计微信授权(openid)用户;美团/京东多为匿名跳转,可能为空。悬停「点击次数」看该用户当天点过的券。
|
||||
</div>
|
||||
<Table<DayUser>
|
||||
rowKey="openid"
|
||||
size="small"
|
||||
loading={dayDetail.loading}
|
||||
columns={dayUserColumns}
|
||||
dataSource={dayDetail.users}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '当天无授权用户领券/点击' }}
|
||||
/>
|
||||
</Modal>
|
||||
```
|
||||
|
||||
- [ ] **Step 7: 构建确认通过**
|
||||
|
||||
Run:
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
Expected: 构建成功,无类型错误。
|
||||
|
||||
- [ ] **Step 8: 提交(前端仓)**
|
||||
|
||||
```
|
||||
git add src/app/(main)/cps/groups/[id]/page.tsx
|
||||
git commit -m "feat(cps): 每日明细下钻弹窗(按用户领券/点击 + 券明细 tooltip)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 全栈联调(手动验证)
|
||||
|
||||
**前置:** 两仓本地起服务。后端 admin API(8771)+ 前端(3001)。可用前端仓 `bash start.sh`(拉起 8770/8771/3001),默认登录 `admin` / `admin12345`。
|
||||
|
||||
- [ ] **Step 1: 选一个有授权点击的淘宝群**
|
||||
|
||||
后端起来后,直接验证接口(用上面手动登录拿的 admin token,或浏览器已登录的会话):
|
||||
|
||||
Run(确认接口连通,期望 200 + JSON 结构):
|
||||
```
|
||||
.\.venv\Scripts\pytest.exe tests\test_cps_admin.py -v
|
||||
```
|
||||
(接口逻辑已由自动化测试覆盖;本步主要确认前端联调环境。)
|
||||
|
||||
- [ ] **Step 2: 前端页面手动核对**
|
||||
|
||||
打开 `http://localhost:3001` → CPS 分发 → 进任一群详情 → 每日明细表:
|
||||
- [ ] 日期列完整显示 `YYYY-MM-DD`,列宽不挤。
|
||||
- [ ] 每行末尾有「明细」按钮(右侧固定列)。
|
||||
- [ ] 点「明细」→ 弹窗打开、标题含群名与日期、加载态正常。
|
||||
- [ ] 有数据的淘宝群:用户行显示头像/昵称、领券次数、点击次数;悬停「点击次数」浮出 `券名 ×次数`,各次数之和 = 点击次数。
|
||||
- [ ] 昵称为空的用户显示 `(未授权昵称)`。
|
||||
- [ ] 美团/京东群或无授权用户的天:弹窗走空态文案 `当天无授权用户领券/点击`。
|
||||
|
||||
- [ ] **Step 3: 收尾**
|
||||
|
||||
确认前后端各自分支提交完整:
|
||||
```
|
||||
git -C e:\project\shaguabijia-app-server log --oneline -3
|
||||
git log --oneline -5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review(计划自检结论)
|
||||
|
||||
- **Spec 覆盖:** `/day-users` 接口(Task 2)、券明细 visit×次数倒序(Task 2 test + 前端 Step 5)、`/daily` date 改全日期(Task 1)、匿名排除(Task 2 实现 + Task 2/3 test)、404/400/401(Task 3)、跨年(Task 3)、前端明细列/Modal/Tooltip/日期列加宽(Task 4–5)、不动数据库(无 migration 任务)—— 均有对应任务。
|
||||
- **类型一致:** 后端 `group_day_users(db, *, group_id, start, end, limit)` 在 repo 定义、router 按此调用;响应键 `group_id/group_name/date/users[*].{openid,nickname,headimgurl,copy_count,visit_count,coupons[*].{name,count}}` 与前端 `DayUser/DayCoupon` 接口逐字对应。
|
||||
- **无占位:** 所有步骤含可直接落地的完整代码与确切命令。
|
||||
@@ -0,0 +1,132 @@
|
||||
# CPS 群详情 · 每日明细「按天 · 按用户」领券下钻
|
||||
|
||||
日期:2026-06-26
|
||||
状态:已与用户确认设计,待写实现计划
|
||||
|
||||
## 背景
|
||||
|
||||
`shaguabijia-admin-web`(纯前端)+ 后端 `shaguabijia-app-server`(Admin API,`/admin/api/*`,端口 8771)。
|
||||
|
||||
CPS 群对账详情页 `src/app/(main)/cps/groups/[id]/page.tsx` 现有两张表:
|
||||
|
||||
- **每日明细**(`GET /admin/api/cps/groups/{id}/daily`):按天一行,含点击/复制/订单佣金。行 `date` 原为 `"MM-DD"`(北京),下钻要完整日期 → **本次改为 `"YYYY-MM-DD"`**(见后端改动)。
|
||||
- **群内微信用户**(`GET /admin/api/cps/groups/{id}/wx-users`):**累计**(非按天)每用户画像,列含 用户 / 领券次数(`copy_count`) / 点击次数(`visit_count`)。
|
||||
|
||||
底层数据 `cps_click`(一事件一行):`event_type` = `visit`(进落地页/被跳转) | `copy`(淘宝"复制口令")、`openid`(微信授权才有)、`clicked_at`、`link_id`。一条点击对应的「优惠券」= `link_id → cps_link.activity_id → cps_activity.name`(活动即优惠券)。`cps_wx_user` 按 `openid` 存 `nickname/headimgurl`。
|
||||
|
||||
**既有口径(沿用,不改)**:领券次数 = `copy` 事件数;点击次数 = `visit` 事件数。
|
||||
|
||||
## 目标
|
||||
|
||||
每日明细表每行末尾加「明细」按钮 → 弹窗展示**该群、该天、以用户为单位**的领券情况:
|
||||
|
||||
- 列:用户名、领券次数、点击次数。
|
||||
- 鼠标悬停「点击次数」→ tooltip 展示该用户当天 **visit 过的全部优惠券**,每行 `券名 ×次数`,按次数倒序,**合计 = 点击次数**。
|
||||
|
||||
## 方案:A(一个新接口,券明细内联返回)
|
||||
|
||||
下钻数据量小(单群 / 单天 / 有 openid 的用户)。新接口一次返回每个用户的领券数、点击数及其 visit 过的券列表,前端一次请求渲染表格 + tooltip,无悬停二次加载、无闪烁。
|
||||
|
||||
(已否决方案 B:用户列表与券明细拆两接口、悬停再拉 —— 多一接口、多一往返、有闪烁,对这数据量不值得。)
|
||||
|
||||
## 数据库:无需改动
|
||||
|
||||
纯读侧聚合,所需数据已全部在库,**不加表 / 不加字段 / 无 migration / 无需回填**:
|
||||
|
||||
- `cps_click`:`group_id`(idx) / `clicked_at`(idx) / `openid`(idx) / `event_type` / `link_id` —— 筛群、筛天、分用户、分领券/点击、定位券所需字段齐全。
|
||||
- 券归属 `link_id → cps_link.activity_id → cps_activity.name` 在**写入时**就落了(`record_click` 一直写 `link_id`),历史点击可反查,**无需回填**。
|
||||
- 查询条件 `group_id + clicked_at 区间 + openid IS NOT NULL` 均命中现有单列索引;数据量小、Python 侧聚合,**无需新增复合索引**。
|
||||
|
||||
## 后端改动(`app/admin/routers/cps.py` + `app/admin/repositories/cps.py`)
|
||||
|
||||
### 新增接口 `GET /admin/api/cps/groups/{group_id}/day-users`
|
||||
|
||||
- 入参:`date`(必填,`YYYY-MM-DD`)。
|
||||
- 鉴权:`Depends(get_current_admin)`(只读,登录即可;与 `/daily`、`/wx-users` 同口径,**不加** `require_role`)。
|
||||
- 群不存在 → 404;`date` 非法 → 400(`detail` 说明格式)。
|
||||
- **时间窗**:`date` 按北京时区(UTC+8)解释成 `[当天 00:00, 次日 00:00)` **半开区间**,转 UTC 后查 `clicked_at >= start AND clicked_at < next_day_start`(半开避免午夜双计)。北京日界 → UTC 用既有 `_as_utc` 约定,与 `group_click_timeseries` 分桶口径一致。
|
||||
- **取数**:`cps_click` where `group_id == {group_id}` AND 时间窗内 AND `openid IS NOT NULL`。
|
||||
- **解析券名**:取该群相关 `cps_link`(或按出现的 `link_id` 批量取)建 `link_id → activity_id` 映射,再 `activity_id → cps_activity.name` 映射;Python 侧聚合(与本仓既有风格一致)。活动被硬删 → 券名兜底 `活动#{activity_id}`。
|
||||
- **按 openid 聚合**:
|
||||
- `copy_count` = 该用户 `copy` 事件数(= 领券次数)
|
||||
- `visit_count` = 该用户 `visit` 事件数(= 点击次数)
|
||||
- `coupons` = 该用户 **visit 事件**按活动分组的 `{name, count}` 列表,按 `count` 倒序;`copy` 事件不计入券列表(与用户选择"只算 visit"一致)
|
||||
- 关联 `cps_wx_user`(`openid IN (...)`)取 `nickname/headimgurl`(未授权为 null)。
|
||||
- **排序**:`copy_count` desc,再 `visit_count` desc(沿用 `group_wx_users` 习惯)。
|
||||
- 可选 `limit`(默认 200,与 `group_wx_users` 一致)。
|
||||
|
||||
**响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"group_id": 1,
|
||||
"group_name": "宝妈薅羊毛1群",
|
||||
"date": "2026-06-25",
|
||||
"users": [
|
||||
{
|
||||
"openid": "oABC...",
|
||||
"nickname": "张三",
|
||||
"headimgurl": "https://thirdwx.qlogo.cn/...",
|
||||
"copy_count": 2,
|
||||
"visit_count": 5,
|
||||
"coupons": [
|
||||
{ "name": "618神券", "count": 3 },
|
||||
{ "name": "买一送一", "count": 2 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
实现建议:在 `cps_repo` 加 `group_day_users(db, *, group_id, date_start, date_end, limit=200) -> list[dict]`,router 负责日期解析与窗口计算后调用之(与 `group_daily`/`group_wx_users` 的分工一致)。
|
||||
|
||||
### 微调既有 `/daily`:`date` 改全日期(不新增字段)
|
||||
|
||||
把每行 `date` 从 `"MM-DD"` 改成 **`"YYYY-MM-DD"`**(北京),**不再新增 `date_iso`**。下钻必须知道完整日期(`MM-DD` 丢年份,跨年会错);`date` 直接给全日期,契约更简单、无冗余字段。`group_daily` 已在循环里持有北京日期 `cur`,把 `cur.strftime("%m-%d")` 改成 `cur.strftime("%Y-%m-%d")` 即可。
|
||||
|
||||
- 该 endpoint 唯一消费方是本前端(随本次一起改),故**非破坏性**。
|
||||
- 折线图/时序用的是 `/timeseries` 的 `label` 字段(另一套,仍 `MM-DD`),**不受影响**,本次不动。
|
||||
|
||||
## 前端改动(`src/app/(main)/cps/groups/[id]/page.tsx`)
|
||||
|
||||
1. `DailyRow.date` 现为 `"YYYY-MM-DD"`(类型仍 `string`,无需加字段)。日期列 `{ title:'日期', dataIndex:'date' }` 宽度 64 → **调宽到约 96,完整显示 `YYYY-MM-DD`**(跨月/跨年看范围更直观)。表格 `scroll.x` 随新列 + 加宽日期列相应上调(约 980)。
|
||||
2. 新增类型:
|
||||
```ts
|
||||
interface DayCoupon { name: string; count: number; }
|
||||
interface DayUser {
|
||||
openid: string; nickname: string | null; headimgurl: string | null;
|
||||
copy_count: number; visit_count: number; coupons: DayCoupon[];
|
||||
}
|
||||
```
|
||||
3. `dailyColumns` 末尾加**「明细」列**(`key:'drill'`, `fixed:'right'`, `width≈64`),render 一个 `type="link"` 小按钮,`onClick` → `openDayDetail(row.date)`(全日期既用于查询也用于弹窗标题)。
|
||||
4. 弹窗状态:`{ open, date, loading, users }`。`openDayDetail(date)` 置 open+loading,`api.get('/admin/api/cps/groups/${id}/day-users?date=' + date)` → 填 `users`;失败 `message.error(errMsg(e, ...))`。
|
||||
5. 新增 `<Modal>`(声明式组件,不受 antd5 静态方法 context 限制,无需 `App.useApp()`)含 `<Table>`,三列:
|
||||
- **用户**:头像 + 昵称(空 → `(未授权昵称)`),与既有「群内微信用户」表的用户单元一致(头像直接 `<img src={headimgurl}>`,微信 URL 为绝对地址,非 `/media`)。
|
||||
- **领券次数**:`copy_count`,沿用既有红色加粗样式(0 显示 0)。
|
||||
- **点击次数**:`visit_count`,整格用 `<Tooltip>` 包裹,`title` 渲染 `coupons` 每行 `券名 ×次数`;`coupons` 为空时不挂 tooltip(直接显示数字)。
|
||||
6. Modal 标题:`群「{group_name}」{date} · 用户领券明细`。空态文案:`仅统计微信授权(openid)用户;美团/京东多为匿名跳转,可能为空`。
|
||||
7. antd 引入新增 `Modal, Tooltip`。
|
||||
|
||||
## 默认与边界(已确认)
|
||||
|
||||
- **匿名点击(openid 为空)不计入**(无法归属到人),镜像既有 `/wx-users`。后果:美团/京东群弹窗常为空 —— 空态文案已提示。
|
||||
- **数据口径差异(需在代码注释点明,避免误读为 bug)**:每日明细行的 `click_pv/copy_pv` 计**全部**点击(含匿名、UV 按 `(ip,ua)`);本弹窗只计 `openid` 用户。故弹窗各用户求和 **≤** 当天行的点击/复制总数,二者不必相等。
|
||||
- **谁出现在弹窗**:当天有任意事件(visit 或 copy)的授权用户都列(含领券数为 0 的纯点击用户)。
|
||||
- **领券次数不做 tooltip**:需求只要点击次数的 tooltip,保持最小。
|
||||
- **权限**:不加角色门槛(与本页其它只读统计一致)。
|
||||
|
||||
## 不做(YAGNI)
|
||||
|
||||
- 领券次数的「领了哪些券」tooltip。
|
||||
- 匿名点击的聚合行 / 占位行。
|
||||
- 用户级下单归因(需 user-level sid,另一期)。
|
||||
|
||||
## 验证(本仓无测试框架,手动验证)
|
||||
|
||||
- 后端:选一个有 `cps_click` 数据的淘宝群,`curl .../day-users?date=<某天>`,核对:①只含 openid 非空用户;②某用户 `coupons` 的 `count` 之和 = 其 `visit_count`;③`date` 非法返回 400、群不存在返回 404;④跨年日期(如去年 12-31)能取到正确那天。
|
||||
- 前端:`npm run build` 通过;日期列完整显示 `YYYY-MM-DD`、列宽不挤;明细按钮 → 弹窗加载;悬停点击次数显示券×次数且合计对得上;美团/京东群弹窗走空态文案;昵称为空显示占位。
|
||||
|
||||
## 落点文件
|
||||
|
||||
- 后端:`app/admin/routers/cps.py`(新 endpoint + `/daily` 的 `date` 改 `YYYY-MM-DD`)、`app/admin/repositories/cps.py`(新 `group_day_users` + `group_daily` 的 `date` 改全日期)。
|
||||
- 前端:`src/app/(main)/cps/groups/[id]/page.tsx`(类型、日期列加宽、明细列、Modal、Tooltip)。
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Divider,
|
||||
InputNumber,
|
||||
Modal,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
@@ -32,9 +34,11 @@ 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;
|
||||
@@ -47,6 +51,13 @@ 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,8 +150,8 @@ interface TrendPoint {
|
||||
revenue: number;
|
||||
}
|
||||
|
||||
// 按小时聚合(0–23),数据源是 items(被 limit 截断时会偏少,调用处给警告)
|
||||
function aggregateHourly(rows: AdRevenueRow[]): TrendPoint[] {
|
||||
// 按小时聚合(0–23),数据源是 hourly(后端全量聚合,不受分页影响)
|
||||
function aggregateHourly(rows: AdRevenueHourly[]): 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;
|
||||
@@ -256,8 +267,12 @@ 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 [limit, setLimit] = useState<number>(500);
|
||||
const [sortBy, setSortBy] = useState<'time' | 'ecpm'>('time'); // 明细排序:time=时间倒序 / ecpm=eCPM 倒序
|
||||
const [limit, setLimit] = useState<number>(500); // 每页条数(分页大小)
|
||||
const [page, setPage] = useState<number>(1); // 当前页码(后端分页;1 起)
|
||||
const [queriedGranularity, setQueriedGranularity] = useState<'day' | 'hour'>('day'); // 本次结果对应的粒度,决定是否显示「小时」列
|
||||
const [queriedMultiDay, setQueriedMultiDay] = useState(false); // 本次结果是否跨多天,决定显示「日期」列 + 按天/按小时图
|
||||
const [data, setData] = useState<AdRevenueReport | null>(null);
|
||||
@@ -268,33 +283,41 @@ export default function AdRevenueReportPage() {
|
||||
// 当前选择是否跨多天:跨多天时「按小时」无意义,粒度强制按天
|
||||
const rangeMultiDay = range[0].format('YYYY-MM-DD') !== range[1].format('YYYY-MM-DD');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const from = range[0].format('YYYY-MM-DD');
|
||||
const to = range[1].format('YYYY-MM-DD');
|
||||
const multiDay = from !== to;
|
||||
const gran = multiDay ? 'day' : granularity; // 跨多天强制按天
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<AdRevenueReport>('/admin/api/ad-revenue-report', {
|
||||
params: {
|
||||
date_from: from,
|
||||
date_to: to,
|
||||
user_id: userId ?? undefined,
|
||||
ad_type: adType ?? undefined,
|
||||
granularity: gran,
|
||||
limit,
|
||||
},
|
||||
});
|
||||
setData(res.data);
|
||||
setQueriedLimit(limit);
|
||||
setQueriedGranularity(gran);
|
||||
setQueriedMultiDay(multiDay);
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [range, userId, adType, granularity, limit]);
|
||||
// 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],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
@@ -332,6 +355,16 @@ 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',
|
||||
@@ -342,13 +375,6 @@ 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',
|
||||
@@ -356,6 +382,17 @@ 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',
|
||||
@@ -401,6 +438,13 @@ 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',
|
||||
@@ -410,14 +454,11 @@ export default function AdRevenueReportPage() {
|
||||
},
|
||||
];
|
||||
|
||||
// 派生指标(全部基于全量 total_* 字段,不受 limit 截断影响,准):
|
||||
// 平均 eCPM = 收益÷展示×1000;发奖成本(元)= 实发金币÷汇率;预估毛利 = 收益−发奖成本;
|
||||
// 发奖占收益比 = 发奖成本÷收益;应发实发差额(金币)= 应发−实发(正=少发/负=多发)。
|
||||
// 派生指标(全部基于全量 total_* 字段,不受分页影响,准):
|
||||
// 发奖成本(元)= 实发金币÷汇率;预估毛利 = 收益−发奖成本;发奖占收益比 = 发奖成本÷收益;
|
||||
// 应发实发差额(金币)= 应发−实发(正=少发/负=多发);ARPU = 预估收益÷今日DAU(仅今日)。
|
||||
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:
|
||||
@@ -425,9 +466,20 @@ 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 }}>
|
||||
@@ -497,6 +549,21 @@ 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
|
||||
@@ -512,15 +579,33 @@ export default function AdRevenueReportPage() {
|
||||
/>
|
||||
</Space>
|
||||
<Space size={6}>
|
||||
<Typography.Text type="secondary">条数</Typography.Text>
|
||||
<Typography.Text type="secondary">排序</Typography.Text>
|
||||
<Select
|
||||
value={limit}
|
||||
onChange={setLimit}
|
||||
style={{ width: 110 }}
|
||||
options={[100, 200, 500, 1000].map((n) => ({ value: n, label: `${n} 条` }))}
|
||||
value={sortBy}
|
||||
onChange={(v) => {
|
||||
setSortBy(v);
|
||||
load(1, limit, v); // 改排序:回第 1 页并重查
|
||||
}}
|
||||
style={{ width: 130 }}
|
||||
options={[
|
||||
{ value: 'time', label: '时间倒序' },
|
||||
{ value: 'ecpm', label: 'eCPM 倒序' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
<Button type="primary" onClick={load} loading={loading}>
|
||||
<Space size={6}>
|
||||
<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} 条/页` }))}
|
||||
/>
|
||||
</Space>
|
||||
<Button type="primary" onClick={() => load(1)} loading={loading}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
@@ -530,41 +615,46 @@ 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 span={6}>
|
||||
<Statistic title="展示条数合计" value={data.total_impressions} />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic title="平均 eCPM(元/千次)" value={derived.avgEcpm} precision={2} />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Col flex="1 1 0">
|
||||
<Statistic title="预估收益合计(元)" value={data.total_revenue_yuan} precision={4} />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic title="广告事件数" value={data.total} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Divider orientation="left" plain style={{ marginTop: 20, marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
发奖与对账
|
||||
</Typography.Text>
|
||||
</Divider>
|
||||
<Row gutter={[16, 12]}>
|
||||
<Col span={6}>
|
||||
<Statistic title="应发金币合计" value={data.total_expected_coin} />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic title="实发金币合计" value={data.total_actual_coin} />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic title="发奖成本(元)" value={derived.payoutYuan} precision={4} />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Col flex="1 1 0">
|
||||
<Statistic
|
||||
title="预估毛利(元)"
|
||||
title={
|
||||
<Tooltip title="每活跃用户广告预估收入 = 预估收益 ÷ 今日 DAU;DAU 仅今日口径,历史/多天显示 -">
|
||||
<span>
|
||||
ARPU(今日)
|
||||
<InfoCircleOutlined style={{ marginLeft: 4 }} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
}
|
||||
value={derived.arpu ?? '-'}
|
||||
precision={4}
|
||||
/>
|
||||
</Col>
|
||||
<Col flex="1 1 0">
|
||||
<Statistic
|
||||
title={
|
||||
<Tooltip title="今日活跃用户(复用大盘口径 last_login_at = 今日登录过);仅查询=今日时有值,历史/多天显示 -">
|
||||
<span>
|
||||
今日活跃 DAU
|
||||
<InfoCircleOutlined style={{ marginLeft: 4 }} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
}
|
||||
value={data.dau ?? '-'}
|
||||
/>
|
||||
</Col>
|
||||
<Col flex="1 1 0">
|
||||
<Statistic title="发放金币合计(元)" value={derived.payoutYuan} precision={4} />
|
||||
</Col>
|
||||
<Col flex="1 1 0">
|
||||
<Statistic
|
||||
title="预估广告毛利(元)"
|
||||
value={derived.grossProfit}
|
||||
precision={4}
|
||||
prefix={derived.grossProfit >= 0 ? <RiseOutlined /> : <FallOutlined />}
|
||||
@@ -576,6 +666,31 @@ export default function AdRevenueReportPage() {
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
</Row>
|
||||
<Divider orientation="left" plain style={{ marginTop: 20, marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
分广告类型
|
||||
</Typography.Text>
|
||||
</Divider>
|
||||
<Row gutter={[16, 12]}>
|
||||
<Col span={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>
|
||||
)}
|
||||
|
||||
@@ -592,20 +707,13 @@ 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.items?.length ?? 0) > 0) && (
|
||||
: queriedGranularity === 'hour' && (data.hourly?.length ?? 0) > 0) && (
|
||||
<Card
|
||||
size="small"
|
||||
title={queriedMultiDay ? '按天趋势' : '按小时趋势'}
|
||||
@@ -640,19 +748,11 @@ export default function AdRevenueReportPage() {
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{/* 按小时图由 items 聚合,会被 limit 截断;按天图用 daily(全量)不受影响,故只在按小时时警告 */}
|
||||
{!queriedMultiDay && data.truncated && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message={`图表按小时聚合的是已截断到 ${queriedLimit} 组的明细,可能少算——调大「条数」或缩小筛选可还原真实趋势。`}
|
||||
/>
|
||||
)}
|
||||
{/* 趋势图恒为全量(不受分页影响):按天图用 daily,按小时图用 hourly;「场景」已下推后端,趋势随场景筛选一并变化。 */}
|
||||
{queriedMultiDay ? (
|
||||
<TrendChart points={aggregateDaily(data.date_from, data.date_to, data.daily)} />
|
||||
) : (
|
||||
<TrendChart points={aggregateHourly(data.items)} />
|
||||
<TrendChart points={aggregateHourly(data.hourly)} />
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
@@ -660,15 +760,22 @@ export default function AdRevenueReportPage() {
|
||||
<Table
|
||||
rowKey="event_key"
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
dataSource={items}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: queriedLimit,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: false,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p) => load(p, queriedLimit),
|
||||
}}
|
||||
size="small"
|
||||
scroll={{ x: 1200 }}
|
||||
scroll={{ x: 1380 }}
|
||||
rowClassName={(r) => (r.has_reward && !r.matched ? 'row-mismatch' : '')}
|
||||
expandable={{
|
||||
// 只有发了奖的事件可展开,看「这条广告金币怎么算的」;纯展示行无复算、不可展开。
|
||||
rowExpandable: (r) => r.reward_detail != null,
|
||||
// 每行都可展开(左侧恒有 + 号):有发奖看「金币复算因子」;纯展示看「展示明细」。
|
||||
rowExpandable: () => true,
|
||||
expandedRowRender: (r) =>
|
||||
r.reward_detail ? (
|
||||
<div>
|
||||
@@ -687,7 +794,28 @@ export default function AdRevenueReportPage() {
|
||||
rowClassName={(d) => (d.matched ? '' : 'row-mismatch')}
|
||||
/>
|
||||
</div>
|
||||
) : null,
|
||||
) : (
|
||||
<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>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
@@ -703,7 +831,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 />· 因子2「LT 累计条数」按该账号累计第几条计(不按天重置);激励视频每次 1 条,信息流每满 10 秒折 1 条。
|
||||
<br />· 因子2「LT 累计条数」按该账号累计第几条计(不按天重置);<b>一条广告 = 1 条</b>(激励视频每次 1 条;信息流看满 10 秒即发该条满额,不按时长折多份)。
|
||||
<br />· 本表与发奖后端同源,本页「应发金币」即按此复算并与「实发金币」对账。
|
||||
</Typography.Paragraph>
|
||||
<Row gutter={16}>
|
||||
|
||||
@@ -10,8 +10,8 @@ import { api, errMsg } from '@/lib/api';
|
||||
interface AdCfg {
|
||||
app_id: string;
|
||||
reward_code_id: string;
|
||||
compare_feed_code_id: string;
|
||||
coupon_feed_code_id: string;
|
||||
compare_draw_code_id: string;
|
||||
coupon_draw_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_feed_code_id" label="比价 · 信息流 广告位ID" rules={[{ required: true }]}>
|
||||
<Input placeholder="如 104090333" />
|
||||
<Form.Item name="compare_draw_code_id" label="比价Draw信息流代码位" rules={[{ required: true }]}>
|
||||
<Input placeholder="比价Draw信息流代码位,如 104098712" />
|
||||
</Form.Item>
|
||||
<Form.Item name="coupon_feed_code_id" label="领券 · 信息流 广告位ID" rules={[{ required: true }]}>
|
||||
<Input placeholder="如 104090333" />
|
||||
<Form.Item name="coupon_draw_code_id" label="领券Draw信息流代码位" rules={[{ required: true }]}>
|
||||
<Input placeholder="领券Draw信息流代码位,如 104098712" />
|
||||
</Form.Item>
|
||||
<Form.Item name="reward_enabled" label="福利激励视频开关" valuePropName="checked">
|
||||
<Switch />
|
||||
|
||||
+1
-1
@@ -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[]>([]);
|
||||
+1
-1
@@ -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[]>([]);
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { App, Button, Card, Input, InputNumber, Space, Spin, Switch, Tag, Tooltip } from 'antd';
|
||||
import { App, Button, Card, Input, InputNumber, Space, Spin, Switch, Tabs, Tag, Tooltip } from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import HomeMarqueeSeeds from './HomeMarqueeSeeds';
|
||||
import HomeStatsConfig from './HomeStatsConfig';
|
||||
|
||||
interface ConfigItem {
|
||||
key: string;
|
||||
@@ -80,9 +82,66 @@ export default function ConfigPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Spin style={{ display: 'block', marginTop: 80 }} />;
|
||||
|
||||
const groups = [...new Set(items.map((i) => i.group))];
|
||||
const welfareConfig = loading ? (
|
||||
<Spin style={{ display: 'block', marginTop: 24 }} />
|
||||
) : (
|
||||
groups.map((g) => (
|
||||
<Card key={g} title={g} size="small" style={{ marginBottom: 16 }}>
|
||||
{items
|
||||
.filter((i) => i.group === g)
|
||||
.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
style={{ marginBottom: 12, paddingBottom: 12, borderBottom: '1px solid #f0f0f0' }}
|
||||
>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<b>{item.label}</b> {item.overridden ? <Tag color="blue">已改</Tag> : <Tag>默认</Tag>}
|
||||
{item.help && (
|
||||
<Tooltip title={item.help}>
|
||||
<span style={{ color: '#999', marginLeft: 6, fontSize: 12, cursor: 'help' }}>
|
||||
ⓘ
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<Space wrap>
|
||||
{item.type === 'bool' ? (
|
||||
<Switch
|
||||
checked={!!edits[item.key]}
|
||||
onChange={(v) => setEdits({ ...edits, [item.key]: v })}
|
||||
/>
|
||||
) : item.type === 'int' ? (
|
||||
<InputNumber
|
||||
value={edits[item.key]}
|
||||
onChange={(v) => setEdits({ ...edits, [item.key]: v })}
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={edits[item.key]}
|
||||
onChange={(e) => setEdits({ ...edits, [item.key]: e.target.value })}
|
||||
style={{ width: 380 }}
|
||||
placeholder={
|
||||
item.type === 'int_list' ? '逗号分隔,如 10, 20, 30' : 'JSON,如 {"key": 100}'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
loading={saving === item.key}
|
||||
onClick={() => save(item)}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
<span style={{ color: '#bbb', fontSize: 12 }}>默认 {JSON.stringify(item.default)}</span>
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
))
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -90,64 +149,21 @@ export default function ConfigPage() {
|
||||
<p style={{ color: '#999' }}>
|
||||
改完即生效(业务下次读取用新值)。涉及金额 / 上限,改前请确认。每次改动都进审计日志。
|
||||
</p>
|
||||
{groups.map((g) => (
|
||||
<Card key={g} title={g} size="small" style={{ marginBottom: 16 }}>
|
||||
{items
|
||||
.filter((i) => i.group === g)
|
||||
.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
style={{ marginBottom: 12, paddingBottom: 12, borderBottom: '1px solid #f0f0f0' }}
|
||||
>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<b>{item.label}</b>{' '}
|
||||
{item.overridden ? <Tag color="blue">已改</Tag> : <Tag>默认</Tag>}
|
||||
{item.help && (
|
||||
<Tooltip title={item.help}>
|
||||
<span style={{ color: '#999', marginLeft: 6, fontSize: 12, cursor: 'help' }}>
|
||||
ⓘ
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<Space wrap>
|
||||
{item.type === '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>
|
||||
))}
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'home',
|
||||
label: '首页',
|
||||
children: (
|
||||
<>
|
||||
<HomeStatsConfig />
|
||||
<HomeMarqueeSeeds />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{ key: 'welfare', label: '福利页', children: welfareConfig },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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, Row, Segmented, Space, Spin, Statistic, Table, message } from 'antd';
|
||||
import { Button, Card, Col, Empty, Modal, Row, Segmented, Space, Spin, Statistic, Table, Tooltip, message } from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { formatUtcTime, yuan } from '@/lib/format';
|
||||
|
||||
@@ -57,6 +57,20 @@ 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' },
|
||||
@@ -139,6 +153,30 @@ 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(
|
||||
@@ -171,7 +209,7 @@ export default function GroupDetailPage() {
|
||||
};
|
||||
|
||||
const dailyColumns: ColumnsType<DailyRow> = [
|
||||
{ title: '日期', dataIndex: 'date', width: 64, fixed: 'left' },
|
||||
{ title: '日期', dataIndex: 'date', width: 96, fixed: 'left' },
|
||||
{ title: '点击', dataIndex: 'click_pv', width: 56 },
|
||||
{ title: '独立', dataIndex: 'click_uv', width: 56 },
|
||||
{ title: '复制', dataIndex: 'copy_pv', width: 56, render: (v: number) => v || '-' },
|
||||
@@ -224,6 +262,17 @@ 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> = [
|
||||
@@ -258,6 +307,59 @@ 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>
|
||||
@@ -307,7 +409,7 @@ export default function GroupDetailPage() {
|
||||
columns={dailyColumns}
|
||||
dataSource={daily?.rows ?? []}
|
||||
pagination={false}
|
||||
scroll={{ x: 900 }}
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
@@ -326,6 +428,28 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
+1265
-49
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,253 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { SorterResult } from 'antd/es/table/interface';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
import { Button, Card, Col, Input, Row, Select, Space, Statistic, Table, Tag, Tooltip } from 'antd';
|
||||
import { api } from '@/lib/api';
|
||||
import { formatUtcTime, utcFromNow } from '@/lib/format';
|
||||
import { usePagedList } from '@/lib/usePagedList';
|
||||
import type { DeviceLivenessItem, DeviceLivenessStats } from '@/lib/types';
|
||||
|
||||
type SortField = 'status' | 'last_heartbeat_at' | 'created_at';
|
||||
|
||||
// 存活状态(后端按心跳阈值派生 display_state)→ 标签。operator 口径,不暴露内部 alive/notified/silent。
|
||||
const STATUS_TAG: Record<string, { color: string; label: string }> = {
|
||||
online: { color: 'green', label: '在线' },
|
||||
offline: { color: 'red', label: '已掉线' },
|
||||
never: { color: 'default', label: '未启用' },
|
||||
};
|
||||
|
||||
/** 秒 → 人话时长(掉线时长用)。 */
|
||||
function fmtDuration(sec: number | null): string {
|
||||
if (sec == null) return '';
|
||||
if (sec < 60) return `${sec} 秒`;
|
||||
const m = Math.floor(sec / 60);
|
||||
if (m < 60) return `${m} 分钟`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h} 小时 ${m % 60} 分`;
|
||||
const d = Math.floor(h / 24);
|
||||
return `${d} 天 ${h % 24} 小时`;
|
||||
}
|
||||
|
||||
// 暂无数据的字段统一占位(待客户端上报 / 待加字段)。
|
||||
const pending = (hint: string) => (
|
||||
<Tooltip title={hint}>
|
||||
<Tag color="default">待接入</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
export default function DeviceLivenessPage() {
|
||||
// 筛选草稿:点「查询」才应用
|
||||
const [deviceId, setDeviceId] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [status, setStatus] = useState<string | undefined>();
|
||||
const [applied, setApplied] = useState<Record<string, unknown>>({});
|
||||
// 排序:默认 status = 掉线置顶;点「最近心跳」列头切换
|
||||
const [sortBy, setSortBy] = useState<SortField>('status');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
const filters = { ...applied, sort_by: sortBy, sort_order: sortOrder };
|
||||
const { items, total, page, pageSize, loading, onChange, reload } = usePagedList<DeviceLivenessItem>(
|
||||
'/admin/api/device-liveness',
|
||||
filters,
|
||||
);
|
||||
|
||||
// 顶部卡片统计(全局,不随筛选)
|
||||
const [stats, setStats] = useState<DeviceLivenessStats | null>(null);
|
||||
const loadStats = useCallback(() => {
|
||||
api.get<DeviceLivenessStats>('/admin/api/device-liveness/stats').then((r) => setStats(r.data));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, [loadStats]);
|
||||
|
||||
const search = () =>
|
||||
setApplied({
|
||||
status,
|
||||
device_id: deviceId || undefined,
|
||||
phone: phone || undefined,
|
||||
});
|
||||
|
||||
const resetFilters = () => {
|
||||
setDeviceId('');
|
||||
setPhone('');
|
||||
setStatus(undefined);
|
||||
setSortBy('status');
|
||||
setSortOrder('desc');
|
||||
setApplied({});
|
||||
};
|
||||
|
||||
const refreshAll = () => {
|
||||
reload();
|
||||
loadStats();
|
||||
};
|
||||
|
||||
const onTableChange = (
|
||||
_pagination: unknown,
|
||||
_filters: unknown,
|
||||
sorter: SorterResult<DeviceLivenessItem> | SorterResult<DeviceLivenessItem>[],
|
||||
) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
if (s && s.order && s.field) {
|
||||
setSortBy(s.field as SortField);
|
||||
setSortOrder(s.order === 'ascend' ? 'asc' : 'desc');
|
||||
} else {
|
||||
// 取消列排序 → 回默认「掉线置顶」
|
||||
setSortBy('status');
|
||||
setSortOrder('desc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortOrderOf = (field: SortField) =>
|
||||
sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null;
|
||||
|
||||
const cards = stats
|
||||
? [
|
||||
{ title: '总设备数', value: stats.total, color: undefined as string | undefined },
|
||||
{ title: '在线', value: stats.online, color: '#3f8600' },
|
||||
{ title: '已掉线', value: stats.offline, color: stats.offline > 0 ? '#cf1322' : undefined },
|
||||
{ title: '未启用', value: stats.never, color: undefined },
|
||||
]
|
||||
: [];
|
||||
|
||||
// 列顺序对齐产品给的字段清单
|
||||
const columns: ColumnsType<DeviceLivenessItem> = [
|
||||
{ title: '设备 ID', dataIndex: 'device_id', fixed: 'left', width: 210, ellipsis: true },
|
||||
{
|
||||
title: '归属用户',
|
||||
key: 'user',
|
||||
width: 150,
|
||||
render: (_: unknown, d: DeviceLivenessItem) => (
|
||||
<div>
|
||||
<div>{d.phone || `user#${d.user_id}`}</div>
|
||||
{d.nickname && <div style={{ color: '#888', fontSize: 12 }}>{d.nickname}</div>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ title: '机型', dataIndex: 'device_model', width: 130, render: (v: string | null) => v || '-' },
|
||||
{
|
||||
title: '系统&系统版本',
|
||||
key: 'os',
|
||||
width: 130,
|
||||
render: () => pending('设备未上报系统版本,需客户端在注册/心跳里带上 + 后端加列'),
|
||||
},
|
||||
{ title: 'APP 版本', dataIndex: 'app_version', width: 100, render: (v: string | null) => v || '-' },
|
||||
{
|
||||
title: '最近一次心跳',
|
||||
dataIndex: 'last_heartbeat_at',
|
||||
width: 150,
|
||||
sorter: true,
|
||||
sortOrder: sortOrderOf('last_heartbeat_at'),
|
||||
render: (v: string | null) =>
|
||||
v ? <Tooltip title={formatUtcTime(v, 'YYYY-MM-DD HH:mm:ss')}>{utcFromNow(v)}</Tooltip> : '-',
|
||||
},
|
||||
{
|
||||
title: '存活状态',
|
||||
dataIndex: 'display_state',
|
||||
width: 130,
|
||||
render: (s: string, d: DeviceLivenessItem) => {
|
||||
const t = STATUS_TAG[s] ?? { color: 'default', label: s };
|
||||
return (
|
||||
<div>
|
||||
<Tag color={t.color}>{t.label}</Tag>
|
||||
{s === 'offline' && d.offline_seconds != null && (
|
||||
<div style={{ color: '#cf1322', fontSize: 12 }}>已 {fmtDuration(d.offline_seconds)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '首次无障碍开启时间',
|
||||
dataIndex: 'first_protected_at',
|
||||
width: 150,
|
||||
render: (v: string | null) =>
|
||||
v ? (
|
||||
formatUtcTime(v)
|
||||
) : (
|
||||
<Tooltip title="迁移前已开过无障碍的老设备无此记录;之后首次开无障碍的设备会记录">
|
||||
<span style={{ color: '#bbb' }}>-</span>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>设备存活监控</h2>
|
||||
|
||||
{stats && (
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
{cards.map((c) => (
|
||||
<Col key={c.title} xs={12} sm={6} md={6} xl={4}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={c.title}
|
||||
value={c.value}
|
||||
valueStyle={c.color ? { color: c.color } : undefined}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Select
|
||||
placeholder="存活状态"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: 'offline', label: '已掉线' },
|
||||
{ value: 'online', label: '在线' },
|
||||
{ value: 'never', label: '未启用' },
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
placeholder="设备ID(包含)"
|
||||
value={deviceId}
|
||||
onChange={(e) => setDeviceId(e.target.value)}
|
||||
onPressEnter={search}
|
||||
allowClear
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="归属用户手机号(前缀)"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
onPressEnter={search}
|
||||
allowClear
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
<Button type="primary" onClick={search}>
|
||||
查询
|
||||
</Button>
|
||||
<Button onClick={resetFilters}>重置</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={refreshAll}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 台设备`,
|
||||
onChange,
|
||||
}}
|
||||
scroll={{ x: 1300 }}
|
||||
onChange={onTableChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { SorterResult } from 'antd/es/table/interface';
|
||||
import { Button, DatePicker, Input, Space, Switch, Table, Tag, Typography } from 'antd';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import dayjs from 'dayjs';
|
||||
import { formatUtcTime } from '@/lib/format';
|
||||
import { usePagedList } from '@/lib/usePagedList';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
/** 一条埋点日志(对应后端 analytics_event,五维)。 */
|
||||
type EventLog = {
|
||||
id: number;
|
||||
event: string;
|
||||
device_id: string;
|
||||
user_id: number | null;
|
||||
session_id: string | null;
|
||||
client_ts: number;
|
||||
sent_at: number | null;
|
||||
created_at: string;
|
||||
page: string | null;
|
||||
client_ip: string | null;
|
||||
oem: string | null;
|
||||
os: string | null;
|
||||
model: string | null;
|
||||
app_ver: string | null;
|
||||
network: string | null;
|
||||
channel: string | null;
|
||||
props: Record<string, string> | null;
|
||||
};
|
||||
|
||||
type SortField = 'id' | 'created_at';
|
||||
|
||||
// 端时间是 epoch ms;格式化成「月-日 时:分:秒.毫秒」便于核对节流/顺序
|
||||
const fmtMs = (ms: number | null) => (ms ? dayjs(ms).format('MM-DD HH:mm:ss.SSS') : '-');
|
||||
|
||||
export default function EventLogsPage() {
|
||||
// 筛选草稿:点「查询」才应用
|
||||
const [event, setEvent] = useState('');
|
||||
const [deviceId, setDeviceId] = useState('');
|
||||
const [userId, setUserId] = useState('');
|
||||
const [sessionId, setSessionId] = useState('');
|
||||
const [createdRange, setCreatedRange] = useState<[Dayjs, Dayjs] | null>(null);
|
||||
const [applied, setApplied] = useState<Record<string, unknown>>({});
|
||||
// 排序(服务端)
|
||||
const [sortBy, setSortBy] = useState<SortField>('id');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
// 近实时:自动刷新开关
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
|
||||
const filters: Record<string, unknown> = { ...applied, sort_by: sortBy, sort_order: sortOrder };
|
||||
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
|
||||
usePagedList<EventLog>('/admin/api/event-logs', filters);
|
||||
|
||||
// 开自动刷新后每 5s 拉当前页;reload 闭包随渲染更新,用 ref 取最新,避免 interval captured 过期闭包。
|
||||
const reloadRef = useRef(reload);
|
||||
reloadRef.current = reload;
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const t = setInterval(() => reloadRef.current(), 5000);
|
||||
return () => clearInterval(t);
|
||||
}, [autoRefresh]);
|
||||
|
||||
const search = () =>
|
||||
setApplied({
|
||||
event: event.trim() || undefined,
|
||||
device_id: deviceId.trim() || undefined,
|
||||
user_id: userId.trim() ? Number(userId.trim()) : undefined,
|
||||
session_id: sessionId.trim() || undefined,
|
||||
created_from: createdRange?.[0] ? createdRange[0].startOf('day').toISOString() : undefined,
|
||||
created_to: createdRange?.[1] ? createdRange[1].endOf('day').toISOString() : undefined,
|
||||
});
|
||||
|
||||
const resetFilters = () => {
|
||||
setEvent('');
|
||||
setDeviceId('');
|
||||
setUserId('');
|
||||
setSessionId('');
|
||||
setCreatedRange(null);
|
||||
setSortBy('id');
|
||||
setSortOrder('desc');
|
||||
setApplied({});
|
||||
};
|
||||
|
||||
const onTableChange = (
|
||||
_p: unknown,
|
||||
_f: unknown,
|
||||
sorter: SorterResult<EventLog> | SorterResult<EventLog>[],
|
||||
) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
if (s && s.order && s.field) {
|
||||
setSortBy(s.field as SortField);
|
||||
setSortOrder(s.order === 'ascend' ? 'asc' : 'desc');
|
||||
} else {
|
||||
setSortBy('id');
|
||||
setSortOrder('desc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortOrderOf = (field: SortField) =>
|
||||
sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null;
|
||||
|
||||
const columns: ColumnsType<EventLog> = [
|
||||
{
|
||||
title: '接收时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 170,
|
||||
sorter: true,
|
||||
sortOrder: sortOrderOf('created_at'),
|
||||
render: (v: string) => formatUtcTime(v),
|
||||
},
|
||||
{ title: 'ID', dataIndex: 'id', width: 70, sorter: true, sortOrder: sortOrderOf('id') },
|
||||
{
|
||||
title: '事件',
|
||||
dataIndex: 'event',
|
||||
width: 170,
|
||||
render: (v: string) => <Tag color="blue">{v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '页面',
|
||||
dataIndex: 'page',
|
||||
width: 100,
|
||||
render: (v: string | null) => v || <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'user_id',
|
||||
width: 80,
|
||||
render: (v: number | null) => v ?? <Text type="secondary">未登录</Text>,
|
||||
},
|
||||
{
|
||||
title: '设备',
|
||||
dataIndex: 'device_id',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (v: string) => (
|
||||
<Text style={{ fontSize: 12 }} copyable={{ text: v }}>
|
||||
{v}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{ title: '网络', dataIndex: 'network', width: 70, render: (v: string | null) => v || '-' },
|
||||
{
|
||||
title: '机型',
|
||||
key: 'oem',
|
||||
width: 130,
|
||||
render: (_: unknown, r: EventLog) => (
|
||||
<Text style={{ fontSize: 12 }}>{[r.oem, r.os].filter(Boolean).join(' / ') || '-'}</Text>
|
||||
),
|
||||
},
|
||||
{ title: '版本', dataIndex: 'app_ver', width: 100, render: (v: string | null) => v || '-' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>埋点日志</h2>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Input
|
||||
placeholder="事件名 (如 video_play)"
|
||||
value={event}
|
||||
onChange={(e) => setEvent(e.target.value)}
|
||||
onPressEnter={search}
|
||||
allowClear
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="设备ID(前缀)"
|
||||
value={deviceId}
|
||||
onChange={(e) => setDeviceId(e.target.value)}
|
||||
onPressEnter={search}
|
||||
allowClear
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="用户ID"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value.replace(/\D/g, ''))}
|
||||
onPressEnter={search}
|
||||
allowClear
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="会话ID"
|
||||
value={sessionId}
|
||||
onChange={(e) => setSessionId(e.target.value)}
|
||||
onPressEnter={search}
|
||||
allowClear
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
<RangePicker
|
||||
placeholder={['接收起', '接收止']}
|
||||
value={createdRange}
|
||||
onChange={(v) => setCreatedRange(v as [Dayjs, Dayjs] | null)}
|
||||
allowClear
|
||||
/>
|
||||
<Button type="primary" onClick={search}>
|
||||
查询
|
||||
</Button>
|
||||
<Button onClick={resetFilters}>重置</Button>
|
||||
<Button onClick={reload}>刷新</Button>
|
||||
<Space size={4}>
|
||||
<Switch checked={autoRefresh} onChange={setAutoRefresh} />
|
||||
<Text type="secondary">自动刷新(5s)</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
loading={loading}
|
||||
expandable={{
|
||||
expandedRowRender: (r) => (
|
||||
<Space direction="vertical" size={2} style={{ fontSize: 12 }}>
|
||||
<Text type="secondary">
|
||||
会话: {r.session_id || '-'} | 设备型号: {r.model || '-'} | 渠道: {r.channel || '-'} | IP:{' '}
|
||||
{r.client_ip || '-'}
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
端事件时间: {fmtMs(r.client_ts)} | 端上报时间: {fmtMs(r.sent_at)}
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
属性: {r.props && Object.keys(r.props).length ? JSON.stringify(r.props) : '无'}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
}}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: onPageChange,
|
||||
}}
|
||||
onChange={onTableChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,10 @@ import { usePathname, useRouter } from 'next/navigation';
|
||||
import {
|
||||
BarChartOutlined,
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
FileSearchOutlined,
|
||||
FlagOutlined,
|
||||
HeartOutlined,
|
||||
LogoutOutlined,
|
||||
MessageOutlined,
|
||||
MobileOutlined,
|
||||
@@ -28,6 +30,7 @@ const MENU = [
|
||||
{ key: '/dashboard', icon: <DashboardOutlined />, label: '数据大盘' },
|
||||
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
||||
{ key: '/devices', icon: <MobileOutlined />, label: '设备管理' },
|
||||
{ key: '/device-liveness', icon: <HeartOutlined />, label: '设备存活' },
|
||||
{ key: '/withdraws', icon: <MoneyCollectOutlined />, label: '提现管理' },
|
||||
{ key: '/price-reports', icon: <FlagOutlined />, label: '上报审核' },
|
||||
{ key: '/comparison-records', icon: <ProfileOutlined />, label: '比价记录' },
|
||||
@@ -37,6 +40,7 @@ const MENU = [
|
||||
{ key: '/cps', icon: <ShareAltOutlined />, label: 'CPS 分发' },
|
||||
{ key: '/config', icon: <SettingOutlined />, label: '系统配置' },
|
||||
{ key: '/admins', icon: <TeamOutlined />, label: '管理员', superOnly: true },
|
||||
{ key: '/event-logs', icon: <DatabaseOutlined />, label: '埋点日志' },
|
||||
{ key: '/audit-logs', icon: <FileSearchOutlined />, label: '审计日志' },
|
||||
];
|
||||
|
||||
|
||||
+122
-5
@@ -44,6 +44,39 @@ export interface DeviceOnboardingItem {
|
||||
last_completed_at: string;
|
||||
}
|
||||
|
||||
// 设备存活监控:device_liveness 表一行 + 后端按心跳阈值派生的在线/掉线/掉线时长。
|
||||
export interface DeviceLivenessItem {
|
||||
id: number;
|
||||
user_id: number;
|
||||
phone: string | null;
|
||||
nickname: string | null;
|
||||
device_id: string;
|
||||
device_model: string | null; // 由 device_id 解析的机型
|
||||
platform: string;
|
||||
app_version: string | null;
|
||||
registration_id: string | null; // 非空 = 可极光推送
|
||||
ever_protected: boolean;
|
||||
first_protected_at: string | null; // 首次开无障碍时刻(老设备为 null)
|
||||
last_heartbeat_at: string | null;
|
||||
last_report_protection_on: boolean;
|
||||
liveness_state: string; // unknown / alive / silent(当前未用) / notified
|
||||
notified_at: string | null;
|
||||
kill_alert_pending: boolean; // 掉线召回待客户端 ack
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// 后端派生
|
||||
online: boolean;
|
||||
display_state: string; // online 在线 / offline 已掉线 / never 未启用
|
||||
offline_seconds: number | null; // 掉线时长(秒);仅 offline 有值
|
||||
}
|
||||
|
||||
export interface DeviceLivenessStats {
|
||||
total: number;
|
||||
online: number;
|
||||
offline: number;
|
||||
never: number;
|
||||
}
|
||||
|
||||
export interface UserOverview {
|
||||
user: UserListItem;
|
||||
coin_balance: number;
|
||||
@@ -332,7 +365,7 @@ export interface AdRevenueRecord {
|
||||
matched: boolean;
|
||||
}
|
||||
|
||||
// 广告收益报表:按日期汇总的一天(按天趋势图用;全量,不受 limit 影响)
|
||||
// 广告收益报表:按日期汇总的一天(按天趋势图用;全量,不受分页影响)
|
||||
export interface AdRevenueDaily {
|
||||
date: string; // 北京时间 YYYY-MM-DD
|
||||
impressions: number;
|
||||
@@ -341,6 +374,21 @@ export interface AdRevenueDaily {
|
||||
actual_coin: number;
|
||||
}
|
||||
|
||||
// 广告收益报表:按北京小时(0–23)汇总的一小时(按小时趋势图用;全量,不受分页影响;按天查询时为空)
|
||||
export interface AdRevenueHourly {
|
||||
hour: number; // 北京时间小时 0–23
|
||||
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)
|
||||
@@ -348,6 +396,7 @@ 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)
|
||||
@@ -372,13 +421,23 @@ export interface AdRevenueReport {
|
||||
date_from: string; // 起始日 北京时间 YYYY-MM-DD
|
||||
date_to: string; // 结束日 北京时间 YYYY-MM-DD(闭区间;单日时与 date_from 相同)
|
||||
daily: AdRevenueDaily[]; // 按日期汇总序列(全量,供按天趋势图)
|
||||
total: number; // 广告事件总数(全量,不受 limit 影响)
|
||||
truncated: boolean;
|
||||
hourly: AdRevenueHourly[]; // 按小时汇总序列(全量,供按小时趋势图;按天查询时为空)
|
||||
type_stats: Record<string, AdRevenueTypeStat>; // 按广告类型小计;前端取 draw / reward_video
|
||||
dau: number | null; // 今日活跃(复用大盘 last_login_at);仅查询=今日时有值,否则 null
|
||||
total: number; // 当前筛选下的分页总条数(全量,不受分页影响)
|
||||
truncated: boolean; // 当前页之后是否还有更多事件(分页后前端不再据此报警)
|
||||
total_impressions: number;
|
||||
total_revenue_yuan: number;
|
||||
total_expected_coin: number;
|
||||
total_actual_coin: number;
|
||||
mismatch_count: number; // 应发≠实发的发奖条数
|
||||
by_ad_type?: Record<string, {
|
||||
ad_type: string;
|
||||
impressions: number;
|
||||
revenue_yuan: number;
|
||||
expected_coin: number;
|
||||
actual_coin: number;
|
||||
}>;
|
||||
items: AdRevenueRow[];
|
||||
}
|
||||
|
||||
@@ -391,7 +450,17 @@ export interface DashboardOverview {
|
||||
new_today: number;
|
||||
dau: number;
|
||||
};
|
||||
coins: { granted_total: number };
|
||||
coins: {
|
||||
granted_total: number;
|
||||
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;
|
||||
};
|
||||
cash: {
|
||||
withdraw_success_cents: number;
|
||||
withdraw_pending_count: number;
|
||||
@@ -399,8 +468,56 @@ export interface DashboardOverview {
|
||||
withdraw_failed_count: number;
|
||||
};
|
||||
comparison: { total: number; success: number; success_rate: number };
|
||||
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: Array<{
|
||||
date: string;
|
||||
active_users: number;
|
||||
new_users: number;
|
||||
comparisons: number;
|
||||
}>;
|
||||
};
|
||||
feedback: { new: number };
|
||||
cps: { available: boolean; note: string };
|
||||
cps: {
|
||||
available: boolean;
|
||||
note: string;
|
||||
meituan_order_count?: number;
|
||||
meituan_commission_cents?: number;
|
||||
meituan_hit_count?: number;
|
||||
meituan_miss_count?: number;
|
||||
meituan_unknown_rate_count?: number;
|
||||
meituan_hit_rate?: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PriceReport {
|
||||
|
||||
Reference in New Issue
Block a user