diff --git a/apps/web/src/components.d.ts b/apps/web/src/components.d.ts index 4a5c562..a1a07ef 100644 --- a/apps/web/src/components.d.ts +++ b/apps/web/src/components.d.ts @@ -11,9 +11,13 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { + NAlert: typeof import('naive-ui')['NAlert'] NButton: typeof import('naive-ui')['NButton'] NCard: typeof import('naive-ui')['NCard'] + NDataTable: typeof import('naive-ui')['NDataTable'] + NDatePicker: typeof import('naive-ui')['NDatePicker'] NDialogProvider: typeof import('naive-ui')['NDialogProvider'] + NDynamicTags: typeof import('naive-ui')['NDynamicTags'] NEmpty: typeof import('naive-ui')['NEmpty'] NInput: typeof import('naive-ui')['NInput'] NLayout: typeof import('naive-ui')['NLayout'] @@ -22,8 +26,11 @@ declare module 'vue' { NMenu: typeof import('naive-ui')['NMenu'] NMessageProvider: typeof import('naive-ui')['NMessageProvider'] NNotificationProvider: typeof import('naive-ui')['NNotificationProvider'] + NPagination: typeof import('naive-ui')['NPagination'] + NSkeleton: typeof import('naive-ui')['NSkeleton'] NSpace: typeof import('naive-ui')['NSpace'] NText: typeof import('naive-ui')['NText'] + NTooltip: typeof import('naive-ui')['NTooltip'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] } diff --git a/apps/web/src/utils/__tests__/format.spec.ts b/apps/web/src/utils/__tests__/format.spec.ts new file mode 100644 index 0000000..73ce430 --- /dev/null +++ b/apps/web/src/utils/__tests__/format.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' + +import { formatAmount, formatCount, formatTime, formatTimeShort } from '@/utils/format' + +describe('formatTime', () => { + it('ISO(+08:00)→ `YYYY-MM-DD HH:mm:ss`', () => { + expect(formatTime('2026-09-11T19:37:12+08:00')).toBe('2026-09-11 19:37:12') + }) + + it('空值 → 「—」;无法识别原样返回', () => { + expect(formatTime(null)).toBe('—') + expect(formatTime(undefined)).toBe('—') + expect(formatTime('not-a-time')).toBe('not-a-time') + }) +}) + +describe('formatTimeShort(当天简写)', () => { + const now = new Date('2026-09-11T13:00:00Z') // 北京时间 21:00 + + it('当天 → `HH:mm:ss`', () => { + expect(formatTimeShort('2026-09-11T19:37:12+08:00', now)).toBe('19:37:12') + }) + + it('跨日 → 完整形式', () => { + expect(formatTimeShort('2026-09-10T23:59:59+08:00', now)).toBe('2026-09-10 23:59:59') + }) +}) + +describe('formatCount / formatAmount', () => { + it('千分位;null → 「—」', () => { + expect(formatCount(53939847)).toBe('53,939,847') + expect(formatCount(0)).toBe('0') + expect(formatCount(null)).toBe('—') + }) + + it('金额:汇总 2 位 / 明细 4 位;null → 「—」(不退化为额度美元)', () => { + expect(formatAmount(23.456)).toBe('23.46') + expect(formatAmount(0.008079785625, 4)).toBe('0.0081') + expect(formatAmount(0)).toBe('0.00') + expect(formatAmount(null)).toBe('—') + }) +}) diff --git a/apps/web/src/utils/format.ts b/apps/web/src/utils/format.ts new file mode 100644 index 0000000..44ef432 --- /dev/null +++ b/apps/web/src/utils/format.ts @@ -0,0 +1,39 @@ +/** + * 展示格式化(交互设计 §2 #4/#5): + * - 时间统一到秒:`2026-09-11 19:37:12`(当天语境简写 `HH:mm:ss`) + * - 金额分层:汇总 2 位小数 / 明细行 4 位小数;`null` → 「—」(绝不退化为额度美元) + * - token 数与 quota 原值千分位;不标币种 + */ + +/** ISO 8601(后端统一 `+08:00`)→ `2026-09-11 19:37:12`;无法识别原样返回;空值 → 「—」 */ +export function formatTime(iso: string | null | undefined): string { + if (!iso) return '—' + const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/.exec(iso) + if (!m) return iso + return `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}:${m[6]}` +} + +/** 「今天」的北京时间日期串(`YYYY-MM-DD`)——输出时间自带 +08:00,按字符串前缀比较即可 */ +function beijingDateString(now: Date): string { + return new Date(now.getTime() + 8 * 3_600_000).toISOString().slice(0, 10) +} + +/** 当天语境简写 `HH:mm:ss`;跨日回落到完整形式 */ +export function formatTimeShort(iso: string | null | undefined, now: Date = new Date()): string { + if (!iso) return '—' + const full = formatTime(iso) + if (!/^\d{4}-\d{2}-\d{2} /.test(full)) return full + return iso.slice(0, 10) === beijingDateString(now) ? full.slice(11) : full +} + +/** 千分位整数(token 数 / quota 原值 / 条数);null → 「—」 */ +export function formatCount(n: number | null | undefined): string { + if (n === null || n === undefined) return '—' + return n.toLocaleString('en-US') +} + +/** 实付人民币:汇总 2 位 / 明细行 4 位;null → 「—」 */ +export function formatAmount(v: number | null | undefined, digits = 2): string { + if (v === null || v === undefined) return '—' + return v.toFixed(digits) +} diff --git a/apps/web/src/views/UsageLogView.vue b/apps/web/src/views/UsageLogView.vue index 7db99ea..5f9f3d1 100644 --- a/apps/web/src/views/UsageLogView.vue +++ b/apps/web/src/views/UsageLogView.vue @@ -1,13 +1,412 @@ - 用量日志 - + 用量查询 + + + + + + + 查询 + + 粘贴 sk-key(多行 / 逗号一次输入多个);时间范围默认最近 7 天 + + + + + + + + + + + + {{ error }} + 重试 + + + + + + + + + 调用次数 + {{ formatCount(summary?.request_count ?? 0) }} + + 消费 {{ formatCount(summary?.consume_count ?? 0) }} · 错误 + {{ formatCount(summary?.error_count ?? 0) }} + + + + + + + + Token 用量 + + {{ + formatCount( + (summary?.prompt_tokens ?? 0) + (summary?.completion_tokens ?? 0), + ) + }} + + 缓存命中 {{ formatCount(summary?.cache_tokens ?? 0) }} + + + + + + + 预估消费 + {{ formatAmount(summary?.amount_rmb ?? 0) }} + 额度记账 {{ formatCount(summary?.quota ?? 0) }} + + + + + + + 数据截止 {{ dataUntilText }} + + + + + {{ k.token_name }}: + {{ k.log_synced_at ? formatTimeShort(k.log_synced_at) : '数据同步中…' }} + + ({{ k.status === 'disabled' ? '已停用' : '运行中' }}) + + + {{ k.key_hint }}:未收录 + + + + + + + + + 以下 key 未收录:{{ unregistered.map((k) => k.key_hint).join('、') }}(请联系管理员登记) + + + + + 其中 {{ formatCount(summary?.unpriced_count) }} 条未登记购买、未计入金额(明细中显示「—」) + + + + + + + + + + + + + + @@ -17,4 +416,85 @@ margin: 0 auto; padding: 24px; } +.page-title { + margin: 0 0 16px; + font-size: 20px; + font-weight: 600; +} +.filter-row { + display: flex; + gap: 12px; + align-items: flex-start; + flex-wrap: wrap; +} +.key-input { + flex: 1 1 420px; + min-width: 320px; +} +.range-input { + width: 380px; +} +.filter-hint { + margin-top: 8px; + color: #999; + font-size: 12px; +} +.initial-empty { + padding: 120px 0; +} +.alert { + margin-top: 16px; +} +.alert-body { + display: flex; + align-items: center; + gap: 12px; +} +.cards { + display: flex; + gap: 12px; + align-items: stretch; + margin-top: 16px; +} +.card { + flex: 1 1 0; +} +.card-label { + color: #666; + font-size: 13px; +} +.card-value { + margin-top: 6px; + font-size: 26px; + font-weight: 600; + line-height: 1.2; +} +.card-sub { + margin-top: 6px; + color: #999; + font-size: 12px; +} +.data-until { + display: flex; + align-items: flex-end; + padding-bottom: 6px; + white-space: nowrap; +} +.data-until-text { + color: #999; + font-size: 12px; + cursor: default; +} +.until-detail { + font-size: 12px; + line-height: 1.7; +} +.until-status { + color: #bbb; +} +.pager { + display: flex; + justify-content: flex-end; + margin-top: 12px; +} diff --git a/docs/design/实施计划.md b/docs/design/实施计划.md index 24630c0..001e12b 100644 --- a/docs/design/实施计划.md +++ b/docs/design/实施计划.md @@ -7,7 +7,7 @@ | 状态 | 定稿(2026-09-14;**进度表随 loop 持续更新**) | | 关联 | `docs/design/API契约.md` · `docs/design/同步引擎设计.md` · `docs/design/交互设计.md` · 四张表字段设计 · `AGENTS.md`(命令) | | 范围 | 阶段划分、每批验收标准、loop 机制与推进规则;技术细节一律以设计文档为准 | -| 当前进度 | 阶段 0–5 已完成;下一批:**阶段 6**(前端 `/usage-log`) | +| 当前进度 | 阶段 0–6 已完成;下一批:**阶段 7**(前端管理面:口令门帘 + 侧边栏 + 三页) | --- @@ -44,6 +44,8 @@ | 16 | seed 脚本用假 key 常量 `sk-seed-demo-key-0001`(`SEED_KEY` 可覆盖);`apps/server/scripts/seed.ts` 幂等可重跑 | 实施计划 §6 已定「假 key 常量」;仅开发期 | | 17 | **jsonb 列写入统一走 `sql\`${obj}::jsonb\``**(`toJsonb()`,见 `src/sync/log-row.ts`)——drizzle + `bun-sql` 组合直接传 JS 对象会**双重编码**成 `jsonb` 的 *string* 值,SQL 侧 `other->>'k'` / `? 'k'` 全部失效(2026-09-15 实测定位) | 已在 `other` / `model_limits` 落地;新增 jsonb 列时照此办理 | | 18 | seed 为「日志行数最多的订阅」登记示例购买,其余订阅保持未登记(保留 `amount_rmb = null` 的展示样本) | 样例实测含两个订阅(89 / 90);可改 | +| 19 | naive-ui composable(`useMessage` 等)在页面里**显式 import**——`auto-imports.d.ts` 的 `declare global` 在 vue-tsc 6 / TS 6 下未被识别(2026-09-15 实测;`components.d.ts` 的模板组件自动导入不受影响) | 若将来工具链修复可回退;显式 import 无副作用 | +| 20 | 表格展开行写在列定义里(`{ type: 'expand', renderExpand }`)——naive-ui 的表级 `render-expand` prop 不生成展开列(2026-09-15 实测) | naive-ui 行为,非项目决策 | ## 2. 前提(2026-09-14 定) @@ -61,7 +63,7 @@ | 3 | **管理面其余**:purchases 五端点 + qpu 一端点 | curl 实测:CRUD 全流程 + 观测聚合(`matched_purchase_id` 判定正确) | ✅ 2026-09-15(33 项单测绿;curl 16 步——登记 201 / 缺必填与非法值与期间顺序 400 / 部分更新与显式清空 / 排序 / 404 / 物理删除 / 观测聚合 `matched_purchase_id` 判定正确) | | 4 | **同步引擎**:tick(5 分钟 + 防重入 + 启动即跑)· 日志整批事务入库 · 额度采集 · QPU 采样 · 401 状态机(N=3)· **seed 脚本**(§6) | 库中出现数据(seed 或真实采集);`[sync]` 日志符合引擎设计;401 状态机单测(连续 401 → 摘除且 `disabled_source = auto_401`) | ✅ 2026-09-15(45 项单测绿;seed 灌入 984 条日志 + QPU 基线 + 示例购买;引擎实测——幂等重跑新增 0 条、摘要日志符合设计、**401 端到端:1→2→3 次摘除且摘除后不再轮询**) | | 5 | **查看面数据面**:`logs/query` + `summary` + 折算链(按购买期间分段取尺) | curl 实测:字段齐全、`meta.data_until` 取最慢、金额按购买分段累加;**无归属购买的行 `amount_rmb = null` 且计入 `unpriced_count`**(2026-09-14 定) | ✅ 2026-09-15(56 项单测绿;curl 17 步——明细字段齐全 / 分页 / type 过滤 / 参数校验 6 类 400 / 未收录 200 空结果 / `data_until` 取最慢 / 金额按归属购买折算、未折算行 `null` + `unpriced_count` 计数正确) | -| 6 | **前端 `/usage-log`**:筛选区(key 标签 + datetimerange + 快捷项)· 卡片 · 表格 + 展开行 · 分页器 · 六种状态模式 | 浏览器实测:seed 数据走一遍;六态逐一可见(加载 / 初始 / 未收录 / 无记录 / 同步中 / 失败);金额为 `null` 显示「—」 | ☐ | +| 6 | **前端 `/usage-log`**:筛选区(key 标签 + datetimerange + 快捷项)· 卡片 · 表格 + 展开行 · 分页器 · 六种状态模式 | 浏览器实测:seed 数据走一遍;六态逐一可见(加载 / 初始 / 未收录 / 无记录 / 同步中 / 失败);金额为 `null` 显示「—」 | ✅ 2026-09-15(Playwright + 系统 Chrome 逐态断言:初始 / 加载 / 查询(卡片 984 · 298,041,838 · 4.90)/ 展开行 / 翻页 / 未收录 / 无记录 / 同步中 / 失败,无 console 错误;金额 `null` 显示「—」并伴未折算提示条) | | 7 | **前端管理面**:口令门帘 + 侧边栏 + 三页(key 管理 / 购买记录含核对提示与补录 / 换算参数) | 浏览器实测:登记、启停、CRUD、补录、时间线全操作 | ☐ | | 8 | **收尾**:部署文档(`docs/delivery/`)· 端到端走查 · 文档状态更新(设计文档 → 已实现) | 一条命令链可部署(前后端独立 + 后端单实例注记);走查无阻 | ☐ |