Skip to content

Commit 628cd83

Browse files
committed
feat(web): 列表统一分页(共享组件 + 扁平列表后端分页)
抽共享 `Pagination` 组件(统一页码文案「第 N / M 页」、每页选项 20/50/100、上下页按钮),把「凭证 / 用户 / API Key / 审计 / 告警 / 模型列表」六个扁平列表接到同一套控件上;用量统计明细保留游标分页 (未知总数,文案退化为「第 N 页」)。 无后端分页支持的列表改为真分页: - 仓储 `UserRepository.list_all` / `ApiKeyRepository.list_for` 加 `limit`/`offset`(`limit=None` 仍返回全量,保持内部调用点旧行为), 新增 `count_all` / `count_for`;`AlertRepository.recent` 加 `offset` + `count_all`;`AuditRepository.query` 抽出共用 `_filters` 并新增 `count`(与 query 同一套过滤条件,页数才对得上)。 - 端点 `/api/users`、`/api/api-keys`、`/api/alerts`、`/api/audit` 加 `limit`/`offset` 并在原列表键旁返回 `total`(向后兼容)。 - `/api/model-catalog` 改服务端分页 + 筛选:`q`(大小写不敏感匹配 id / 名称)+ `only_available` + `limit`/`offset`,新增 `total` (筛选后条数,`count` 仍是目录全量);排序键含派生字段故先内存 排序再切片。前端搜索框去抖 250ms。 分组类表(凭证按渠道分组、用量统计分组表)保持一次性全量展示。 测试:前端 506 通过(重写 ModelCatalogPage 的服务端 mock、AuditPage 补 total);后端新增 model-catalog 筛选/分页用例,覆盖率 100%。
1 parent 2318e3f commit 628cd83

19 files changed

Lines changed: 597 additions & 333 deletions

‎README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,14 @@ OpenRouter 收录数百条,其中多数本服务没接。每行带 `available`
421421

422422
适用端点:`/api/credentials`、`/api/users`、`/api/api-keys`、`/api/audit`、`/api/alerts`、`/api/model-catalog`、`/api/credentials/{id}/credit-events`、`/api/credentials/{id}/accounts`、`/api/stats/by-*`、`/api/stats/events`(逐请求明细:默认按 rowid 游标分页返回 `next_before`;按其它列排序或时间升序时改用 `LIMIT/OFFSET` 并返回 `total`)。各端点的白名单键与默认排序见 `src/api/`。
423423

424+
### 列表分页
425+
426+
管理台各列表**统一分页**:共享同一套页码文案(`第 N / M 页`)、每页选项(`20 / 50 / 100`,默认 `50`)与上一页 / 下一页按钮。扁平列表一律由**后端**分页——`GET` 列表端点接受 `limit`(每页条数)+ `offset`(偏移)并返回 `total`(**筛选后**的总条数,供前端算页数);原列表键名不变,只新增 `total`,向后兼容。`/api/users`、`/api/api-keys`、`/api/alerts`、`/api/audit` 在 SQL 层 `LIMIT/OFFSET`;`/api/model-catalog` 因排序键含派生字段(能力分 / 可用性)先内存排序再切片。
427+
428+
`/api/model-catalog` 另接受两个筛选参数(服务端生效,前端搜索框去抖 250ms 后回第一页):`q`(大小写不敏感地匹配模型 `id` 与名称)与 `only_available`(`true` 时只留 `available=true` 的行)。`total` 是筛选后的条数,顶层 `count` 仍是目录全量(页头展示「命中 N / 共 M」)。
429+
430+
**分组类表**(凭证页按渠道分组、用量统计分组表)不分页,保持一次性全量展示——它们的行数由渠道 / 分组维度决定,本就不受明细规模影响。
431+
424432
## 后台任务
425433

426434
额度探测、token 预刷新、每日签到、成长中心、活跃上报、明细清理、数据库备份、渠道模型列表刷新、模型目录刷新(OpenRouter)、运维告警、上游状态页聚合共 11 类,由 `TaskRunner` 自动调度,失败互不影响。每类任务的上次执行时间、最近结果与错误在管理台**「任务与配置」页**查看(30 秒自动刷新),运行态只存在于**本次进程**内,重启归零。每个任务卡片带「立即运行」按钮(`POST /api/tasks/{key}/run`,admin + CSRF):**绕过时点窗口但绕过不了开关**——`due()` 未到点也照跑,而任务被开关关掉时如实回 `ran=false`(不把「关了」谎报成「跑过了」);同一任务已在跑时立刻回 `busy=true`、不排队、不并发打上游;同步等这一轮跑完再返回结果。

‎TECHNICAL.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,11 @@ PRAGMA foreign_keys = ON; -- 仅 api_keys 之外无外键(users 与 api_k
493493
### 9.8 列表排序
494494
所有 `GET` 列表端点接受统一的 `sort`(白名单键)+ `order`(`asc`/`desc`)。**未知 `sort` 键或非法 `order` 值回落到默认排序,不报错**;`sort` 是白名单键而非 SQL 列名,SQL 片段只由受控常量拼出,用户输入永不进 SQL,注入面为零。契约在 `src/sorting.py`(db 与 api 共用,避免 db → api 反向依赖):`parse_sort_order`(唯一解析入口)、`sql_order`(`tiebreak` 兜底稳定次序)、`sort_rows`(组装后再排,`None` 值无论升降序都排**最后**)。逐请求明细默认(`time` 降序)走 rowid 游标分页返回 `next_before`;按其它列排序 / 时间升序 / 显式 `offset` 时改用 `LIMIT/OFFSET` 并返回 `total`。适用端点:`/api/credentials`、`/api/users`、`/api/api-keys`、`/api/audit`、`/api/alerts`、`/api/model-catalog`、`/api/credentials/{id}/credit-events`、`/api/credentials/{id}/accounts`、`/api/stats/by-*`、`/api/stats/events`。
495495

496+
### 9.9 列表分页
497+
扁平列表**统一后端分页**,前端共享 `Pagination` 组件(`web/src/components/Pagination.tsx`)——同一套页码文案(`第 N / M 页`)、每页选项(`PAGE_SIZE_OPTIONS = [20, 50, 100]`,默认 `DEFAULT_PAGE_SIZE = 50`)与上下页按钮,`data-testid` 前缀按表区分(`${prefix}-page-info` / `-page-size` / `-prev` / `-next`)。
498+
499+
契约:`GET` 列表端点接受 `limit` + `offset`,响应在**原列表键旁**新增 `total`(**筛选后**条数,前端据此算页数;键名不变、向后兼容)。仓储层 `limit=None` 表示不分页返回全量,保持内部调用点旧行为。实现分流:`users` / `api-keys` / `alerts` / `audit` 在 SQL 层 `LIMIT/OFFSET` + 独立 `count`;`model-catalog` 排序键含派生字段(能力分 / `available`),先 `sort_rows` 再内存切片。`/api/model-catalog` 的服务端筛选收敛在 `_filter_catalog`(`q` 大小写不敏感匹配 `id` / 名称,`only_available` 只留 `available=true`),`total` 是筛选后条数、顶层 `count` 仍是目录全量。游标分页的明细端点(`/api/stats/events` 默认 `time` 降序)无总页数,页码文案退化为 `第 N 页`(未知总数)。分组类表(凭证按渠道分组、用量统计分组表)不分页。
500+
496501
## 10. 后台任务
497502
`TaskRunner`(`runner.py`)每类任务一个独立 asyncio 循环,失败只记日志不拖垮服务;间隔有安全下限,避免打爆上游。`Pacer` 有两种模式:后台任务用**严格模式**(默认,单锁串行化跨任务/跨凭证的相邻请求);聊天用**并发模式**(`allow_concurrent=True`),按「渠道 + 凭证身份」分桶并在桶内放行并发——同渠道同模型的并发请求不再被逐级串行化,只有同凭证「上一请求已结束、紧接着又来一个」的顺序连发才补足最小间隔。请求结束由 provider `stream_chat` 的 `finally` 调 `release(key)` 归还名额。**间隔抖动(`JITTER_RATIO = 0.25`)**:定值间隔(`min == max`,聊天节流器的形态,如 `Pacer(5, 5)`)逐个账号精确同拍,多实例 / 多账号会同时刻撞上游,故 `next_interval()` 对定值**只向上**抖 `[low, low × 1.25]`——`low` 是频率风控下限,不可击穿;`min < max` 的区间本就均匀随机(文档承诺的 `[min, max]`),不再叠加。抖动源是构造期的 `random.Random(0)`(测试可复现),非热更项。
498503

‎src/api/admin_alerts.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ def create_router(services: Services) -> APIRouter:
1717
alerts = services.alerts
1818

1919
@router.get("/api/alerts")
20-
async def list_alerts(limit: int = 50, sort: str | None = None,
20+
async def list_alerts(limit: int = 50, offset: int = 0,
21+
sort: str | None = None,
2122
order: str | None = None,
2223
principal=Depends(principal_from_request)):
23-
"""最近告警(默认倒序)。limit 由仓储收敛到 [1, 200]。"""
24+
"""最近告警(默认倒序)。limit 由仓储收敛到 [1, 200];total 供前端算页数。"""
2425
require_admin(principal)
25-
return {"alerts": alerts.recent(limit, sort=sort, order=order)}
26+
return {"alerts": alerts.recent(limit, sort=sort, order=order, offset=offset),
27+
"total": alerts.count_all()}
2628

2729
return router

‎src/api/admin_audit.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ async def list_audit(actor: str = "", action: str = "", since: int = 0,
2626
actor=actor or None, action=action or None,
2727
since=since or None, before=before or None,
2828
limit=limit, offset=offset, sort=sort, order=order)
29-
return {"events": rows, "actions": list(ACTIONS), "labels": ACTION_LABELS}
29+
# total 与 events 同一套过滤条件(见 AuditRepository.count 的说明)
30+
total = services.audit.count(
31+
actor=actor or None, action=action or None,
32+
since=since or None, before=before or None)
33+
return {"events": rows, "total": total,
34+
"actions": list(ACTIONS), "labels": ACTION_LABELS}
3035

3136
return router

‎src/api/admin_keys.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,12 @@ def create_router(services: Services) -> APIRouter:
5353

5454
@router.get("/api/api-keys")
5555
async def list_keys(sort: str | None = None, order: str | None = None,
56+
limit: int = 50, offset: int = 0,
5657
principal=Depends(principal_from_request)):
57-
return {"api_keys": api_keys.list_for(principal.username, sort=sort, order=order)}
58+
# 分页:total 供前端算页数
59+
return {"api_keys": api_keys.list_for(principal.username, sort=sort, order=order,
60+
limit=limit, offset=offset),
61+
"total": api_keys.count_for(principal.username)}
5862

5963
@router.post("/api/api-keys")
6064
async def create_key(payload: dict,

‎src/api/admin_model_catalog.py‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,27 +99,62 @@ def _with_availability(rows: list[dict[str, Any]],
9999
for row in rows]
100100

101101

102+
def _filter_catalog(rows: list[dict[str, Any]], q: str | None,
103+
only_available: bool) -> list[dict[str, Any]]:
104+
"""目录的服务端筛选:`q` 匹配 id / 名称,`only_available` 只留可用行。
105+
106+
provider 不参与匹配:它恒为 id 的厂商前缀(id 的子串),匹配它等于匹配 id。
107+
与前端旧实现同口径,避免搜索行为在迁移后悄悄变化。
108+
"""
109+
needle = (q or "").strip().lower()
110+
out: list[dict[str, Any]] = []
111+
for row in rows:
112+
if only_available and not row.get("available"):
113+
continue
114+
if needle and not (
115+
str(row.get("id") or "").lower().find(needle) >= 0
116+
or str(row.get("name") or "").lower().find(needle) >= 0
117+
):
118+
continue
119+
out.append(row)
120+
return out
121+
122+
102123
def create_router(services: Services) -> APIRouter:
103124
router = APIRouter()
104125

105126
@router.get("/api/model-catalog")
106127
async def get_model_catalog(request: Request, sort: str | None = None,
107128
order: str | None = None,
129+
q: str | None = None,
130+
only_available: bool = False,
131+
limit: int = 50, offset: int = 0,
108132
_principal=Depends(principal_from_request)):
109-
"""当前生效模型目录 + 元信息;仅要求登录(OpenRouter 是公开数据,各角色可看)。"""
133+
"""当前生效模型目录 + 元信息;仅要求登录(OpenRouter 是公开数据,各角色可看)。
134+
135+
分页与筛选都在服务端:目录数百条,全量下发会拖慢首屏,而「搜索 id/名称」
136+
与「只看当前渠道」是两个常用收窄手段。`q` 大小写不敏感地匹配 id 与名称;
137+
`only_available` 只留 `available=true` 的行。返回的 `total` 是**筛选后**
138+
的总条数(供前端算页数),`count` 仍是目录全量(页头展示「N / 总数」)。
139+
"""
110140
state = request.app.state
111141
catalog: dict[str, dict] = state.models_dev_catalog
112142
# 先补能力分再排序:能力分排序键读 `row["benchmarks"]`,若在 enrich 之前
113143
# 排,所有行都拿不到分数、按能力分排会退化成不动。
114144
rows = _with_benchmarks([catalog[model] for model in sorted(catalog)],
115145
getattr(state, "model_benchmarks", None))
146+
rows = _with_availability(rows, _available_keys(services))
147+
rows = _filter_catalog(rows, q, only_available)
148+
total = len(rows)
116149
# 默认按模型 id 升序:结果确定(前端无需再排),与历史行为一致。
117150
models = sort_rows(rows, sort, order,
118151
MODEL_CATALOG_SORT_KEYS, default_key="id", default_desc=False)
119-
models = _with_availability(models, _available_keys(services))
152+
# 排序后切片:排序键含派生字段,SQL 排不了,故先排再分页。
153+
models = models[max(0, offset):max(0, offset) + max(1, min(500, limit))]
120154
return {
121155
"models": models,
122156
"count": len(catalog),
157+
"total": total,
123158
"currency": "USD",
124159
"usd_cny_rate": float(services.settings.usd_cny_rate),
125160
# 快照时刻:价格、明细与能力分同一次抓取,故只有一个时刻。缺失即

‎src/api/admin_users.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,14 @@ def _guard_last_admin(username: str) -> None:
8282

8383
@router.get("/api/users")
8484
async def list_users(sort: str | None = None, order: str | None = None,
85+
limit: int = 50, offset: int = 0,
8586
principal=Depends(principal_from_request)):
8687
_require(principal)
88+
# limit/offset 分页;total 供前端算页数(仓储与端点的收敛口径一致)
8789
return {"users": [_public_view(row) for row in
88-
services.user_repo.list_all(sort=sort, order=order)]}
90+
services.user_repo.list_all(sort=sort, order=order,
91+
limit=limit, offset=offset)],
92+
"total": services.user_repo.count_all()}
8993

9094
@router.post("/api/users")
9195
async def create_user(request: Request, payload: dict,

0 commit comments

Comments
 (0)