feat(admin): 各用户用量表加「最近使用」列(bump 0.34.3)

后端 _user_usage_page 加全量(不随 range 筛选)相关子查询
max(created_at) → last_used_at;前端 renderUserUsage 加列,
fmtTimeAgo 显示 + 全时间戳 title,无用量显示「—」。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-01 13:28:34 +08:00
parent d8f71aa7b2
commit eb9ffd654f
4 changed files with 19 additions and 5 deletions

View File

@ -21,6 +21,9 @@
## 已完成关键能力
### 2026-07-01 / admin 各用户用量加「最近使用」列(bump 0.34.3)
用户需求:admin 页面「各用户用量」表加一列展示每个用户的最近使用时间。改动:`web/admin.py _user_usage_page` 加一个**全量**(不随 range 筛选)的相关子查询 `max(usage_events.created_at)`,新字段 `last_used_at`(ISO 或 null);语义上刻意用全量而非跟着 range 走的 join——否则选 7d/30d 会把更早的真实 last-used 藏掉,列就失去意义。前端 `admin.js renderUserUsage` 加「最近使用」表头 + 单元格,用 `fmtTimeAgo`(相对时间)展示、`fmtTime` 全时间戳作 title 悬浮,无用量用户显示「—」;colspan 7→8。
### 2026-07-01 / ppt 页数必须用户显式拍板(bump 0.34.2)
用户反馈:ppt skill 生成时页数总默认到 ~12 张,页数从没被真正确认过。根因是行为层:ah 八条对齐里 b 项(页数)只给「常 815 页」区间,又被打包进整批 BLOCKING 确认,用户一句笼统「OK」就整批过、模型自取区间中位数(~12)。修(纯文档):`SKILL.md` b 项改为推**一个具体数字**+ 标为「独立拍板项」;ah 表后新增「🔒 页数 gate(不可默认放行)」——用户没给/没显式认可具体张数时必须单独追问「就定 N 页?」拿到明确整数才写逐页大纲,禁止用区间中位数当默认(唯一例外:用户明说「页数你随意」时按推荐数走、仍在预览写出数字供否掉);`strategist.md §b` 同步补 Non-defaultable gate 硬约束。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。
__version__ = "0.34.2"
__version__ = "0.34.3"

View File

@ -199,6 +199,14 @@ def _user_usage_page(s: Any, page: int, page_size: int, cutoff, sort: str) -> di
if cutoff is not None:
join_cond = and_(join_cond, UsageEvent.created_at >= cutoff)
# 最近使用时间:取全量(不随 range 筛选变),否则 7d/30d 会把更早的真实 last-used 藏掉。
last_used_sq = (
select(func.max(UsageEvent.created_at))
.where(UsageEvent.user_id == User.user_id)
.correlate(User)
.scalar_subquery()
)
total_users = s.execute(select(func.count()).select_from(User)).scalar_one()
rows = [
{
@ -213,13 +221,15 @@ def _user_usage_page(s: Any, page: int, page_size: int, cutoff, sort: str) -> di
"tokens_out": int(to or 0),
"tokens_cache_hit": int(h or 0),
"n_events": int(n or 0),
"last_used_at": last_used.isoformat() if last_used else None,
}
for uid, email, name, uname, role, plan, c, ti, to, h, n in s.execute(
for uid, email, name, uname, role, plan, c, ti, to, h, n, last_used in s.execute(
select(
User.user_id, User.email, User.name, User.user_name, User.role, User.plan,
cost_sum, tin_sum, tout_sum,
func.coalesce(func.sum(hit).filter(chat), 0),
func.count(UsageEvent.event_id),
last_used_sq.label("last_used_at"),
)
.join(UsageEvent, join_cond, isouter=True)
.group_by(User.user_id, User.email, User.name, User.user_name, User.role, User.plan)

View File

@ -3,7 +3,7 @@
// 结构:左侧目录(点击平滑滚动)+ 右侧内容。overview(固定指标)10s 轮询;
// 「按模型」「各用户用量」带时间筛选+排序、「各用户用量」「存储」分页 —— 各自独立 fetch、
// 自管状态(range/sort/page),overview tick 顺手刷新但不丢状态。导出 PDF 走客户端打印。
import { humanSize, fmtTime, fmtTokens, escapeHtml } from "./format.js";
import { humanSize, fmtTime, fmtTimeAgo, fmtTokens, escapeHtml } from "./format.js";
const LS_TOKEN = "zcbot.token";
const REFRESH_MS = 10000;
@ -200,13 +200,14 @@ function renderUserUsage(d) {
+ `<td class="num">${fmtTokens(r.tokens_out)}</td>`
+ `<td class="num">${hitRate}%</td>`
+ `<td class="num">${r.n_events || 0}</td>`
+ `<td title="${escapeHtml(fmtTime(r.last_used_at))}">${r.last_used_at ? fmtTimeAgo(r.last_used_at) : "—"}</td>`
+ `</tr>`;
}).join("") || `<tr><td colspan="7" class="empty">无数据</td></tr>`;
}).join("") || `<tr><td colspan="8" class="empty">无数据</td></tr>`;
$("s-users").innerHTML = `<div class="card">`
+ `<div class="card-head"><h2>各用户用量(${rangeLabel(d.range)}</h2>${ctrlHTML("u", d.range, d.sort)}</div>`
+ tierLegendHTML()
+ `<div class="scroll-x"><table>`
+ `<thead><tr><th>用户</th><th>档位</th><th>成本</th><th>输入</th><th>输出</th><th>缓存命中</th><th>事件</th></tr></thead>`
+ `<thead><tr><th>用户</th><th>档位</th><th>成本</th><th>输入</th><th>输出</th><th>缓存命中</th><th>事件</th><th>最近使用</th></tr></thead>`
+ `<tbody>${body}</tbody></table></div>`
+ pagerHTML("uu", page, maxPage, from, to, total)
+ `</div>`;