From 199972a2dca4ff7d7dc1dcfafc9bd5221e01d163 Mon Sep 17 00:00:00 2001 From: skycurtain Date: Wed, 14 Oct 2026 08:01:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=B0=83=E7=94=A8=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E8=A1=A5=E6=A8=A1=E5=9E=8B=E7=AD=9B=E9=80=89=E4=B8=8E=E6=B1=87?= =?UTF-8?q?=E6=80=BB=E6=9D=A1=E2=80=94=E2=80=94=E5=8F=AF=E6=8C=89=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=94=B6=E7=AA=84=E6=9F=A5=E8=AF=A2=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E5=9C=A8=E5=BD=93=E5=89=8D=E7=AD=9B=E9=80=89=E4=B8=8B=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E8=AF=BB=E5=87=BA=E8=AF=B7=E6=B1=82=E6=AC=A1=E6=95=B0?= =?UTF-8?q?=E4=B8=8E=E6=B6=88=E8=B4=B9=E7=94=A8=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +- apps/server/src/routes/admin-usage.ts | 38 ++- apps/server/src/routes/member-view.ts | 40 ++- apps/web/src/api/admin/logs.ts | 9 +- apps/web/src/api/member/me.ts | 8 +- apps/web/src/api/query-keys.ts | 2 + .../components/common/logs-summary-bar.vue | 74 +++++ apps/web/src/views/admin/logs-view.vue | 31 ++- apps/web/src/views/member/logs-view.vue | 31 ++- docs/design/API契约-成员视图.md | 6 +- docs/design/API契约-管理面.md | 25 +- docs/design/交互设计-成员视图.md | 5 +- docs/design/交互设计-调用日志.md | 22 +- docs/design/实现方案-日志模型筛选与汇总条.md | 260 ++++++++++++++++++ packages/core/src/schemas.ts | 10 + packages/core/src/types.ts | 15 + packages/db/src/repos.ts | 210 ++++++++++---- 17 files changed, 705 insertions(+), 84 deletions(-) create mode 100644 apps/web/src/components/common/logs-summary-bar.vue create mode 100644 docs/design/实现方案-日志模型筛选与汇总条.md diff --git a/README.md b/README.md index 0ae53f4..c1a0212 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,8 @@ docker compose ps # migrate 应为 exited(0),其余 running/healthy | [`docs/design/交互设计-总览与共性.md`](docs/design/交互设计-总览与共性.md) | 交互总纲:访问拓扑、导航、跨页组件、状态与数值呈现约定 | 定稿(2026-10-01) | | [`docs/design/交互设计-登录与会话.md`](docs/design/交互设计-登录与会话.md) | 登录页、引导页、改密、成员登录壳 | 同上 | | [`docs/design/交互设计-全员总览.md`](docs/design/交互设计-全员总览.md) | 管理面首页:健康带 + 按成员 / key 汇总 | 同上 | -| [`docs/design/交互设计-调用日志.md`](docs/design/交互设计-调用日志.md) | 调用日志:筛选、明细表、与 key/成员/总览的跳转链 | 定稿(2026-10-08) | +| [`docs/design/交互设计-调用日志.md`](docs/design/交互设计-调用日志.md) | 调用日志:筛选、汇总条、明细表、与 key/成员/总览的跳转链 | 定稿(2026-10-08;模型筛选与汇总条修订) | +| [`docs/design/实现方案-日志模型筛选与汇总条.md`](docs/design/实现方案-日志模型筛选与汇总条.md) | 日志页模型筛选 + 条数/消费用量汇总条:API、数据层、前端改造与验收 | 已实现(2026-10-13) | | [`docs/design/交互设计-key管理.md`](docs/design/交互设计-key管理.md) | 登记(sk-key 唯一界面露面)、启停、归属、死亡显形 | 定稿(2026-10-01) | | [`docs/design/交互设计-支付记录.md`](docs/design/交互设计-支付记录.md) | 支付记录(两 Tab):订阅权益 / 充值入账登记、核对提示 | 定稿(2026-10-12) | | [`docs/design/交互设计-成员管理.md`](docs/design/交互设计-成员管理.md) | 开户、档案修改、令牌签发/重发、启停、空壳删除 | 定稿(2026-10-10) | diff --git a/apps/server/src/routes/admin-usage.ts b/apps/server/src/routes/admin-usage.ts index 5cf8f5b..9f05e57 100644 --- a/apps/server/src/routes/admin-usage.ts +++ b/apps/server/src/routes/admin-usage.ts @@ -279,7 +279,7 @@ export function adminUsageRoutes() { path: "/logs", summary: "日志明细(下钻)", description: - "分页;from/to 必填;默认全类型(可 type 筛);token_name 取当前名", + "分页;from/to 必填;默认全类型(可 type / model_name 筛);token_name 取当前名;附带 summary 与 models", tags: ["用量"], security: [{ adminSession: [] }], request: { @@ -290,12 +290,13 @@ export function adminUsageRoutes() { member_id: z.string().optional(), billing_source: z.string().optional(), subscription_id: z.string().optional(), + model_name: z.string().optional(), type: z.string().optional(), }), }, responses: { 200: { - description: "items/total", + description: "items/total/summary/models", content: { "application/json": { schema: logPageSchema } }, }, 401: err401, @@ -312,15 +313,13 @@ export function adminUsageRoutes() { const page_size = Math.min(200, Math.max(1, Number(q.page_size ?? 50) || 50)); const repos = c.get("repos"); const timeline = resolveQpuTimeline(await repos.qpu.listTimeline()); - const { rows, total } = await repos.logs.listPage({ + const filters = { from: range.from, to: range.to, - page, - page_size, api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined, member_id: q.member_id === "unassigned" - ? "unassigned" + ? ("unassigned" as const) : q.member_id ? Number(q.member_id) : undefined, @@ -329,13 +328,22 @@ export function adminUsageRoutes() { subscription_id: q.subscription_id ? Number(q.subscription_id) : undefined, + model_name: q.model_name || undefined, type: q.type === "error" - ? "error" + ? ("error" as const) : q.type != null && q.type !== "" ? Number(q.type) : undefined, + }; + const { rows, total } = await repos.logs.listPage({ + ...filters, + page, + page_size, }); + const sum = await repos.logs.summarize(filters); + const usage = sumUsage(sum.usage_rows, timeline); + const models = await repos.logs.listModels(filters); const items: LogItem[] = rows.map((row) => { const { quota_usd } = usageRowPrecise( @@ -389,7 +397,21 @@ export function adminUsageRoutes() { }; }); - return c.json({ items, total }, 200); + return c.json( + { + items, + total, + summary: { + hit_count: sum.hit_count, + usage_count: sum.usage_count, + error_count: sum.error_count, + quota: usage.quota, + quota_usd: usage.quota_usd, + }, + models, + }, + 200, + ); }, ); diff --git a/apps/server/src/routes/member-view.ts b/apps/server/src/routes/member-view.ts index bb52887..dc607ba 100644 --- a/apps/server/src/routes/member-view.ts +++ b/apps/server/src/routes/member-view.ts @@ -241,7 +241,8 @@ export function memberViewRoutes() { method: "get", path: "/logs", summary: "日志明细(名下 key)", - description: "分页;默认全类型(可 type 筛);DTO 与管理面同构", + description: + "分页;默认全类型(可 type / model_name 筛);DTO 与管理面同构;附带 summary 与 models", tags: ["成员视图"], security: [{ memberToken: [] }], request: { @@ -253,12 +254,13 @@ export function memberViewRoutes() { api_key_id: z.string().optional(), billing_source: z.string().optional(), subscription_id: z.string().optional(), + model_name: z.string().optional(), type: z.string().optional(), }), }, responses: { 200: { - description: "items/total", + description: "items/total/summary/models", content: { "application/json": { schema: logPageSchema } }, }, 401: err401, @@ -278,25 +280,33 @@ export function memberViewRoutes() { ); const repos = c.get("repos"); const timeline = resolveQpuTimeline(await repos.qpu.listTimeline()); - const { rows, total } = await repos.logs.listMemberPage({ - member_id: c.get("memberId")!, + const member_id = c.get("memberId")!; + const filters = { + member_id, from: range.from, to: range.to, - page, - page_size, api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined, billing_source: q.billing_source === "none" ? "none" : q.billing_source || undefined, subscription_id: q.subscription_id ? Number(q.subscription_id) : undefined, + model_name: q.model_name || undefined, type: q.type === "error" - ? "error" + ? ("error" as const) : q.type != null && q.type !== "" ? Number(q.type) : undefined, + }; + const { rows, total } = await repos.logs.listMemberPage({ + ...filters, + page, + page_size, }); + const sum = await repos.logs.summarizeMember(filters); + const usage = sumUsage(sum.usage_rows, timeline); + const models = await repos.logs.listMemberModels(filters); const items: LogItem[] = rows.map((row) => { const { quota_usd } = usageRowPrecise( row.quota, @@ -348,7 +358,21 @@ export function memberViewRoutes() { }, }; }); - return c.json({ items, total }, 200); + return c.json( + { + items, + total, + summary: { + hit_count: sum.hit_count, + usage_count: sum.usage_count, + error_count: sum.error_count, + quota: usage.quota, + quota_usd: usage.quota_usd, + }, + models, + }, + 200, + ); }, ); diff --git a/apps/web/src/api/admin/logs.ts b/apps/web/src/api/admin/logs.ts index bc2149b..62d9aff 100644 --- a/apps/web/src/api/admin/logs.ts +++ b/apps/web/src/api/admin/logs.ts @@ -1,4 +1,4 @@ -import type { LogItem, Paginated } from "@new-api-key-usage/core"; +import type { LogPage } from "@new-api-key-usage/core"; import { adminHttp } from "../http.ts"; export type AdminLogFilters = { @@ -10,13 +10,12 @@ export type AdminLogFilters = { member_id?: number | "unassigned"; billing_source?: string; subscription_id?: number; + model_name?: string; type?: number | "error"; }; -export async function fetchLogs( - params: AdminLogFilters, -): Promise> { - const { data } = await adminHttp.get>("/logs", { +export async function fetchLogs(params: AdminLogFilters): Promise { + const { data } = await adminHttp.get("/logs", { params, }); return data; diff --git a/apps/web/src/api/member/me.ts b/apps/web/src/api/member/me.ts index 5523c67..3b949ed 100644 --- a/apps/web/src/api/member/me.ts +++ b/apps/web/src/api/member/me.ts @@ -1,6 +1,5 @@ import type { - LogItem, - Paginated, + LogPage, SubscriptionItem, TopupItem, UnixSec, @@ -55,11 +54,12 @@ export type MemberLogQuery = { api_key_id?: number; billing_source?: string; subscription_id?: number; + model_name?: string; type?: number | "error"; }; -export async function fetchLogs(query: MemberLogQuery): Promise> { - const { data } = await memberHttp.get>("/logs", { +export async function fetchLogs(query: MemberLogQuery): Promise { + const { data } = await memberHttp.get("/logs", { params: query, }); return data; diff --git a/apps/web/src/api/query-keys.ts b/apps/web/src/api/query-keys.ts index c652196..3f0519c 100644 --- a/apps/web/src/api/query-keys.ts +++ b/apps/web/src/api/query-keys.ts @@ -36,6 +36,7 @@ export const qk = { range.page_size, range.api_key_id ?? "", range.member_id ?? "", + range.model_name ?? "", range.billing_source ?? "", range.subscription_id ?? "", range.type ?? "", @@ -60,6 +61,7 @@ export const qk = { range.page, range.page_size, range.api_key_id ?? "", + range.model_name ?? "", range.billing_source ?? "", range.subscription_id ?? "", range.type ?? "", diff --git a/apps/web/src/components/common/logs-summary-bar.vue b/apps/web/src/components/common/logs-summary-bar.vue new file mode 100644 index 0000000..f98a4f2 --- /dev/null +++ b/apps/web/src/components/common/logs-summary-bar.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/apps/web/src/views/admin/logs-view.vue b/apps/web/src/views/admin/logs-view.vue index 1de857e..87df3ac 100644 --- a/apps/web/src/views/admin/logs-view.vue +++ b/apps/web/src/views/admin/logs-view.vue @@ -9,6 +9,7 @@ import PageHeader from "@/components/common/page-header.vue"; import TableRefresh from "@/components/common/table-refresh.vue"; import TimeRangePicker from "@/components/common/time-range-picker.vue"; import QuotaText from "@/components/common/quota-text.vue"; +import LogsSummaryBar from "@/components/common/logs-summary-bar.vue"; import { useLiveTimeRange, type RangeKind, @@ -23,6 +24,7 @@ const { range, setRange } = useLiveTimeRange("recent-30"); const memberId = ref(null); const apiKeyId = ref(null); +const modelFilter = ref(null); const billingSource = ref(null); const subscriptionId = ref(null); const typeFilter = ref(null); @@ -50,6 +52,7 @@ const params = computed(() => ({ page_size: pageSize.value, api_key_id: apiKeyId.value ?? undefined, member_id: memberId.value ?? undefined, + model_name: modelFilter.value ?? undefined, billing_source: billingSource.value ?? undefined, subscription_id: subscriptionId.value ?? undefined, type: typeFilter.value ?? undefined, @@ -58,6 +61,11 @@ const params = computed(() => ({ const logsQuery = useAdminLogs(() => params.value); const rows = computed(() => logsQuery.data.value?.items ?? []); const total = computed(() => logsQuery.data.value?.total ?? 0); +const summary = computed(() => logsQuery.data.value?.summary); +const modelOptions = computed(() => [ + { value: null, label: "全部模型" }, + ...(logsQuery.data.value?.models ?? []).map((m) => ({ value: m, label: m })), +]); const memberOptions = computed(() => [ { value: null, label: "全部成员" }, @@ -105,6 +113,8 @@ function syncFromRoute() { else if (typeof m === "string" && m) memberId.value = Number(m); const k = q.api_key_id ?? q.key_id; if (typeof k === "string" && k) apiKeyId.value = Number(k); + modelFilter.value = + typeof q.model_name === "string" && q.model_name ? q.model_name : null; if (typeof q.billing_source === "string" && q.billing_source) { billingSource.value = q.billing_source; } @@ -131,6 +141,7 @@ function pushQuery() { if (memberId.value === "unassigned") query.member_id = "unassigned"; else if (memberId.value != null) query.member_id = String(memberId.value); if (apiKeyId.value != null) query.api_key_id = String(apiKeyId.value); + if (modelFilter.value) query.model_name = modelFilter.value; if (billingSource.value) query.billing_source = billingSource.value; if (subscriptionId.value != null) { query.subscription_id = String(subscriptionId.value); @@ -146,7 +157,7 @@ watch( () => syncFromRoute(), ); watch( - [memberId, apiKeyId, billingSource, subscriptionId, typeFilter], + [memberId, apiKeyId, modelFilter, billingSource, subscriptionId, typeFilter], () => { page.value = 1; pushQuery(); @@ -245,6 +256,15 @@ onUnmounted(() => { show-search option-filter-prop="label" /> + + (null); +const modelFilter = ref(null); const billingSource = ref(null); const subscriptionId = ref(null); const typeFilter = ref(null); @@ -40,6 +42,7 @@ const params = computed(() => ({ page: page.value, page_size: pageSize.value, api_key_id: apiKeyId.value ?? undefined, + model_name: modelFilter.value ?? undefined, billing_source: billingSource.value ?? undefined, subscription_id: subscriptionId.value ?? undefined, type: typeFilter.value ?? undefined, @@ -48,6 +51,11 @@ const params = computed(() => ({ const logsQuery = useMemberLogs(() => params.value); const rows = computed(() => logsQuery.data.value?.items ?? []); const total = computed(() => logsQuery.data.value?.total ?? 0); +const summary = computed(() => logsQuery.data.value?.summary); +const modelOptions = computed(() => [ + { value: null, label: "全部模型" }, + ...(logsQuery.data.value?.models ?? []).map((m) => ({ value: m, label: m })), +]); const keyOptions = computed(() => [ { value: null, label: "全部 Key" }, @@ -137,6 +145,8 @@ function syncFromRoute() { const q = route.query; const k = q.api_key_id ?? q.key_id; apiKeyId.value = typeof k === "string" && k ? Number(k) || null : null; + modelFilter.value = + typeof q.model_name === "string" && q.model_name ? q.model_name : null; billingSource.value = typeof q.billing_source === "string" && q.billing_source ? q.billing_source @@ -160,6 +170,7 @@ function pushQuery() { to: range.value.to, }; if (apiKeyId.value != null) query.api_key_id = String(apiKeyId.value); + if (modelFilter.value) query.model_name = modelFilter.value; if (billingSource.value) query.billing_source = billingSource.value; if (subscriptionId.value != null) { query.subscription_id = String(subscriptionId.value); @@ -171,7 +182,7 @@ function pushQuery() { syncFromRoute(); watch(() => route.query, () => syncFromRoute()); -watch([apiKeyId, billingSource, subscriptionId, typeFilter], () => { +watch([apiKeyId, modelFilter, billingSource, subscriptionId, typeFilter], () => { if (applyingRoute.value) return; page.value = 1; pushQuery(); @@ -246,6 +257,15 @@ watch( show-search option-filter-prop="label" /> + + 0 时附注「消费 X / 错误 Y」 | 跟全部筛选(含 `type`) | +| 消费用量 | quota + $(`QuotaText`) | 恒 `type = 2` + 其余筛选 | + +- 类型筛「错误」时:请求次数 = 错误条数;消费用量仍为同期消费口径(标签保持「消费用量」,不叫「花费」) +- 查询中数字骨架/淡化,不闪上一轮;空结果汇总条保留、数字归零 +- 成员端同构(见《交互设计-成员视图》§4) ## 3. 日志表(页面主体) diff --git a/docs/design/实现方案-日志模型筛选与汇总条.md b/docs/design/实现方案-日志模型筛选与汇总条.md new file mode 100644 index 0000000..1b94eb1 --- /dev/null +++ b/docs/design/实现方案-日志模型筛选与汇总条.md @@ -0,0 +1,260 @@ +# 实现方案 · 日志模型筛选与汇总条 + +> 调用日志页(管理端 + 成员端)的两项增强:**按模型名筛选**、**当前筛选条件下的用量汇总条**。交互方向已在讨论中议定——汇总条只做「条数 + 花费」、高度压低;本篇落 API / 数据层 / 前端改造清单与验收口径。 + +| 项目 | 内容 | +|------|------| +| 状态 | 已实现(2026-10-13) | +| 关联 | 《交互设计-调用日志》·《交互设计-成员视图》§4 · 《API契约-管理面》§6 · 《API契约-成员视图》· 《API契约-公共约定》(分页、quota 双单位、聚合口径)· `usage_logs表字段设计.md` · 需求清单 R5 / R9(多维统计含模型) | +| 数据 | `usage_logs.model_name`(已入库)· 明细分页筛选与消费汇总(派生查询) | + +--- + +## 1. 问题与目标 + +日志表已展示 `model_name`,筛选区却没有模型维度;总览有三维聚合,日志页却回答不了「这批条件加起来花了多少」。本方案补上: + +1. **模型筛选**——筛选条第六格,与时间 / 成员 / Key / 来源 / 订阅 / 类型并列 +2. **汇总条**——筛选条与表格之间一条**低高度**读数条,只展示**请求次数** + **消费用量(quota + $)** + +范围仅两端调用日志页及其数据链路;**不**改总览 by_member / by_key / by_source,**不**加 by_model 分组卡(R5/R9 的「按模型」分组统计另议)。 + +--- + +## 2. 交互(已议定,实施以此为准) + +### 2.1 页面骨架(两端同构) + +``` +PageHeader(标题 + 时间范围) +筛选条(成员/Key | 模型 | 计费来源 | 订阅 ID | 类型) ← 模型新增 +汇总条(请求次数 · 消费用量) ← 新增,低高度单行 +日志表(既有列不变) +分页 +``` + +成员端无「成员」筛选,其余同构。 + +### 2.2 模型筛选 + +| 项 | 约定 | +|----|------| +| 位置 | 筛选条内,Key 与计费来源之间(谁 → 调了什么 → 记在哪) | +| 形态 | Select,`showSearch` + `allowClear`;空值 = 全部模型 | +| 选项 | 当前 `from/to` 与主体筛选(成员/Key)下、时间窗内去重的 `model_name`;**不含**模型自身与 type 筛选,避免「选了只剩自己」 | +| 变更 | 回第 1 页;URL 同步 `model`(与 `member_id` 等同级) | +| 宽度 | 与相邻筛选格一致(约 120–140px),可搜避免长模型名截断误选 | + +### 2.3 汇总条 + +| 项 | 约定 | +|----|------| +| 位置 | 筛选条下方、表格上方——改条件 → 扫总量 → 下钻明细 | +| 高度 | **单行**,约 32–36px;浅底或细分隔,不做成大卡片 | +| 内容 | 两段:**请求次数**(当前筛选下总条数)· **消费用量**(quota + $,`QuotaText`) | +| 口径 | **请求次数**跟全部筛选(含 type);**消费用量**恒 `type = 2` + 其余筛选(既有裁决:聚合仅消费行) | +| 类型筛「错误」时 | 请求次数 = 错误条数;消费用量仍显示同期同条件的消费口径,标签保持「消费用量」不叫「花费」,避免误读为错误行开销 | +| 加载 | 查询中数字区骨架/淡化,不闪上一轮数字 | +| 空结果 | 汇总条保留,数字归零/`—`;表格空态不变 | + +文案示例: + +- 类型全部:`请求次数 131 条(消费 128 / 错误 3)` + `消费用量 1,250,000 quota ≈ $2.50` +- 类型=错误:`请求次数 3 条` + `消费用量 …`(消费口径不变) +- 零结果:`请求次数 0 条` + `消费用量 0 quota ≈ $0.00` + +「消费 128 / 错误 3」在错误数为 0 时可只显示 `请求次数 128 条`,保持条更短。 + +--- + +## 3. API 契约变更 + +两端 `GET /api/admin/logs` / `GET /api/member/logs` **同名同义**扩展;DTO 继续一份复用。 + +### 3.1 请求 + +新增可选查询参数: + +| 参数 | 类型 | 说明 | +|------|------|------| +| `model_name` | string | 精确匹配 `usage_logs.model_name`;空/缺省 = 不筛 | + +其余参数不变。`from/to` 仍必填。 + +### 3.2 响应 + +在现有 `{ items, total }` 上增加 `summary` 与 `models`: + +```json +{ + "items": [ /* 不变 */ ], + "total": 131, + "summary": { + "hit_count": 131, + "usage_count": 128, + "error_count": 3, + "quota": 1250000, + "quota_usd": "2.50" + }, + "models": ["claude-sonnet-4", "gpt-5"] +} +``` + +| 字段 | 语义 | +|------|------| +| `total` | 既有:当前筛选下命中总数(分页用) | +| `summary.hit_count` | 与 `total` 同值(汇总条「请求次数」;保留字段便于前端不依赖分页语义) | +| `summary.usage_count` | 当前筛选下 **type=2** 条数 | +| `summary.error_count` | 当前筛选下 **type≠2** 条数(`hit_count = usage_count + error_count`) | +| `summary.quota` / `quota_usd` | 当前筛选下 **type=2** 行的 quota 合计与美元(公共约定 §5:微分累加、一次取整) | +| `models` | 时间窗 + 主体筛选下去重的 `model_name` 升序列表(选项源;**不含** `model_name` / `type` 筛选) | + +口径备注(写入契约裁定区): + +1. `summary.usage_*` / `quota*` **不含** type 筛选与 `model_name` 筛选以外的 type 限制——即 **model_name 与主体/时间/来源/订阅参与,type 不参与用量字段**;`hit_count` / `error_count` / `usage_count` 的**条数**则跟全部筛选。 +2. 更精确说: + - **条数字段**(`hit_count` / `usage_count` / `error_count`)→ 全部筛选(含 `type`、`model_name`) + - **用量字段**(`quota` / `quota_usd`)→ 同等筛选但 **固定 type=2**(即使请求 `type=error`) +3. `quota_usd` 换算沿「按日志时刻取 QPU」——汇总不得用单一当前汇率乘总 quota。 + +`models` 轻量查询:`DISTINCT model_name`,条件 = 时间窗 + 主体/来源/订阅(与列表一致),**刻意不套** `model_name` 与 `type`。办公室规模(模型十来个)直接全量返回,不分页。 + +### 3.3 契约文档落点 + +- 《API契约-管理面》§6:过滤表加 `model_name`;响应从 `{items,total}` 扩为含 `summary`/`models` +- 《API契约-成员视图》日志节:同名同义同步 +- 《API契约-公共约定》:若需,补一句「列表响应可附带与筛选同源的汇总块」——或在各自契约内写死,避免总纲膨胀 + +--- + +## 4. 数据层(`packages/db`) + +### 4.1 筛选 + +`repos.logs.listPage` / `listMemberPage` 参数增加 `model_name?: string`,条件 `eq(usageLogs.model_name, model_name)`(非空时)。 + +### 4.2 汇总 + +新增 `repos.logs.summarize` / `summarizeMember`(或 list 方法返回体扩展,推荐**独立方法**以免分页热路径多跑聚合): + +- 条数:SQL `count()`,条件与列表筛选一致(含 type) +- 用量:`count() filter (type=2)` + `sum(quota) filter (type=2)`;`quota_usd` 需按行 QPU——**取 type=2 行的 `(quota, created_at)` 交给 `sumUsage`**(与总览聚合同一套微分规则) +- 选项:`select distinct model_name`,条件同列表但去掉 `model_name` 与 `type` + +办公室查询频率低、命中行数量级可控;**不做**预计算表(需求清单「统计 = 派生视图」)。 + +时间范围仍走 `created_at` 索引;`model_name` 无索引,精确等值 + 时间窗过滤后残余行少,**暂不**加索引(表设计不为此改;若日后慢查询再评估)。 + +### 4.3 类型共享 + +`packages/core`: + +- `logPage` schema:增加 `summary`(见下)与 `models: z.array(z.string())` +- 新导出 `LogPageSummary`(或复用/扩展 `UsageTotals`——字段更多,独立类型更清晰): + +```ts +interface LogPageSummary { + hit_count: number; + usage_count: number; + error_count: number; + quota: Quota; + quota_usd: QuotaUsd; +} +``` + +--- + +## 5. 服务端路由 + +| 文件 | 改动 | +|------|------| +| `apps/server/src/routes/admin-usage.ts` | `GET /logs` query 增 `model_name`;调用 list + summarize + distinct models;组 `summary`(`sumUsage` → quota_usd);响应扩字段 | +| `apps/server/src/routes/member-view.ts` | 同上(`listMemberPage` / member 侧汇总与选项) | +| `apps/server/src/api-schemas.ts` | `logPageSchema` 随 core 更新 | + +OpenAPI 描述字符串同步(summary 文案提到模型筛选与汇总块)。 + +--- + +## 6. 前端(`apps/web`) + +### 6.1 API 层 + +- `api/admin/logs.ts`:`AdminLogFilters` 加 `model_name?: string`;返回类型改为带 `summary` / `models` 的分页结构 +- `api/member/me.ts`:`MemberLogQuery` 同上 + +### 6.2 汇总条组件 + +新建共享组件(两端复用,避免双份样式漂移): + +- 路径建议:`apps/web/src/components/common/logs-summary-bar.vue` +- Props:`hitCount` · `usageCount` · `errorCount` · `quota` · `quotaUsd` · `loading` +- 布局:单行 flex,左「请求次数…」右「消费用量…」;`loading` 时数字占位 +- 花费数字复用 `QuotaText` +- 样式对齐共性:弱标签 + 主数字,高度 32–36px,间距遵循页面既有 `gap` + +### 6.3 两端 logs-view + +| 项 | 改动 | +|----|------| +| 状态 | `modelFilter: ref` | +| params | 带上 `model_name` | +| 模型选项 | 读响应 `models`(或独立 query 缓存按 `from/to+主体` 键) | +| 模板 | 筛选条插入模型 Select;筛选条与 `a-table` 之间插入 `LogsSummaryBar` | +| URL | `syncFromRoute` / 写回增加 `model` | +| 筛选变更 | 既有「回第 1 页」逻辑覆盖 model | + +列结构、详情弹窗、跳转链路**不动**。 + +### 6.4 样式 + +筛选条若因第六格换行,允许两行(共性已允许一行或两行);汇总条始终单行不换行,过窄时隐藏次要括注(如「/ 错误 3」)。 + +--- + +## 7. 设计文档同步 + +实现时同步修订(不另开版本叙事): + +| 文档 | 修订点 | +|------|--------| +| 《交互设计-调用日志》 | §2 筛选表加「模型」;§3 前插入汇总条小节(位置、两段、口径);URL 同步参数补 `model` | +| 《交互设计-成员视图》§4 | 同构引用或摘要 + 成员端差异 | +| 《API契约-管理面》§6 | 过滤 + 响应扩展 + 口径裁定 | +| 《API契约-成员视图》 | 同名同义 | +| README 文档地图 | 本篇条目 | + +状态流转:本篇「待审阅」→ 实现完成后「已实现(日期)」。 + +--- + +## 8. 实现顺序 + +1. ✅ `packages/core`:`LogPageSummary` + `LogPage` + `logPage` schema 扩展 +2. ✅ `packages/db`:`buildLogConds`、`model_name` 筛选、`summarize` / `summarizeMember`、`listModels` / `listMemberModels` +3. ✅ `apps/server`:两端 `/logs` 接线(summary + models) +4. ✅ `apps/web`:API 类型 → `logs-summary-bar` → admin / member logs-view +5. ✅ 设计文档修订 + README 文档地图 +6. ✅ `bun run typecheck` + `bun run build:web` 通过 + +--- + +## 9. 验收口径 + +- [x] 管理端 / 成员端均可按模型筛选,选项来自时间窗内真实数据,可搜可清空 +- [x] 筛选变更回第 1 页;URL 带 `model_name`,深链可还原 +- [x] 汇总条位于筛选条与表格之间,**单行低高度**,仅「条数 + 消费用量」 +- [x] 消费用量恒 type=2 + 其余筛选;类型筛错误时命中=错误数、用量仍为消费口径 +- [x] `quota_usd` 按行 QPU 微分汇总(`sumUsage`) +- [x] 切换筛选时汇总加载态不闪旧数字(`is-loading` 淡化 + 占位) +- [x] typecheck / build 通过;无新依赖 + +--- + +## 10. 明确不做 + +- 总览页 by_model 分组卡(R5/R9 多维统计的另一落点,单议) +- 模型多选、模糊匹配(先精确单选;不够再扩) +- `model_name` 索引 / 预计算汇总表 +- Tokens 段进汇总条(已议定砍掉,列内仍有) +- 明细列增减、详情弹窗改造 diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts index d145a7c..56c7550 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -68,9 +68,19 @@ export const logItem = z.object({ raw: z.record(z.string(), z.unknown()).optional(), }); +export const logPageSummary = z.object({ + hit_count: z.number().int(), + usage_count: z.number().int(), + error_count: z.number().int(), + quota: z.number(), + quota_usd: z.string(), +}); + export const logPage = z.object({ items: z.array(logItem), total: z.number().int(), + summary: logPageSummary, + models: z.array(z.string()), }); export const keyItem = z.object({ diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index feb03ab..b2cf084 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -114,6 +114,21 @@ export interface Paginated { total: number; } +/** 日志页汇总:条数跟全部筛选;用量恒 type=2 + 其余筛选 */ +export interface LogPageSummary { + hit_count: number; + usage_count: number; + error_count: number; + quota: Quota; + quota_usd: QuotaUsd; +} + +/** 调用日志分页响应(含汇总条与模型下拉选项) */ +export interface LogPage extends Paginated { + summary: LogPageSummary; + models: string[]; +} + export interface EngineLastRound { started_at: UnixSec; keys_total: number; diff --git a/packages/db/src/repos.ts b/packages/db/src/repos.ts index 5e57371..d77c388 100644 --- a/packages/db/src/repos.ts +++ b/packages/db/src/repos.ts @@ -32,6 +32,50 @@ export interface KeyListRow extends ApiKeyRow { member_name: string | null; } +type LogFilterParams = { + from: Date; + to: Date; + api_key_id?: number; + member_id?: number | "unassigned"; + billing_source?: string | "none"; + subscription_id?: number; + model_name?: string; + type?: number | "error"; +}; + +/** 明细/汇总共用的 WHERE 片段(时间左闭右开;联 api_keys 时才带 member 归属) */ +function buildLogConds(params: LogFilterParams) { + const conds = [ + gte(usageLogs.created_at, params.from), + lt(usageLogs.created_at, params.to), + ]; + if (params.api_key_id != null) { + conds.push(eq(usageLogs.api_key_id, params.api_key_id)); + } + if (params.billing_source === "none") { + conds.push(isNull(usageLogs.billing_source)); + } else if (params.billing_source) { + conds.push(eq(usageLogs.billing_source, params.billing_source)); + } + if (params.subscription_id != null) { + conds.push(eq(usageLogs.subscription_id, params.subscription_id)); + } + if (params.model_name) { + conds.push(eq(usageLogs.model_name, params.model_name)); + } + if (params.type === "error") { + conds.push(ne(usageLogs.type, 2)); + } else if (typeof params.type === "number") { + conds.push(eq(usageLogs.type, params.type)); + } + if (params.member_id === "unassigned") { + conds.push(isNull(apiKeys.member_id)); + } else if (params.member_id != null) { + conds.push(eq(apiKeys.member_id, params.member_id)); + } + return conds; +} + /** * 仓储层(R14):数据访问只出现在 packages/db。 * apps/server 不 import drizzle-orm / postgres 驱动。 @@ -557,35 +601,12 @@ export function createRepos(db: Db) { member_id?: number | "unassigned"; billing_source?: string | "none"; subscription_id?: number; + model_name?: string; type?: number | "error"; page: number; page_size: number; }): Promise<{ rows: Array; total: number }> { - const conds = [ - gte(usageLogs.created_at, params.from), - lt(usageLogs.created_at, params.to), - ]; - if (params.api_key_id != null) { - conds.push(eq(usageLogs.api_key_id, params.api_key_id)); - } - if (params.billing_source === "none") { - conds.push(isNull(usageLogs.billing_source)); - } else if (params.billing_source) { - conds.push(eq(usageLogs.billing_source, params.billing_source)); - } - if (params.subscription_id != null) { - conds.push(eq(usageLogs.subscription_id, params.subscription_id)); - } - if (params.type === "error") { - conds.push(ne(usageLogs.type, 2)); - } else if (typeof params.type === "number") { - conds.push(eq(usageLogs.type, params.type)); - } - if (params.member_id === "unassigned") { - conds.push(isNull(apiKeys.member_id)); - } else if (params.member_id != null) { - conds.push(eq(apiKeys.member_id, params.member_id)); - } + const conds = buildLogConds(params); const totalRows = await db .select({ n: count() }) @@ -705,29 +726,10 @@ export function createRepos(db: Db) { api_key_id?: number; billing_source?: string | "none"; subscription_id?: number; + model_name?: string; type?: number | "error"; }) { - const conds = [ - eq(apiKeys.member_id, params.member_id), - gte(usageLogs.created_at, params.from), - lt(usageLogs.created_at, params.to), - ]; - if (params.type === "error") { - conds.push(ne(usageLogs.type, 2)); - } else if (typeof params.type === "number") { - conds.push(eq(usageLogs.type, params.type)); - } - if (params.api_key_id != null) { - conds.push(eq(usageLogs.api_key_id, params.api_key_id)); - } - if (params.billing_source === "none") { - conds.push(isNull(usageLogs.billing_source)); - } else if (params.billing_source) { - conds.push(eq(usageLogs.billing_source, params.billing_source)); - } - if (params.subscription_id != null) { - conds.push(eq(usageLogs.subscription_id, params.subscription_id)); - } + const conds = buildLogConds({ ...params, member_id: params.member_id }); const totalRows = await db .select({ n: count() }) .from(usageLogs) @@ -771,6 +773,122 @@ export function createRepos(db: Db) { .offset((params.page - 1) * params.page_size); return { rows, total }; }, + + /** + * 日志页汇总:条数跟全部筛选;用量行恒 type=2(不含 type 筛选)。 + * usage_rows 交上层 sumUsage 做按行 QPU 微分。 + */ + async summarize(params: LogFilterParams) { + const fullConds = buildLogConds(params); + const usageConds = buildLogConds({ ...params, type: 2 }); + + const hitRows = await db + .select({ + hit_count: count(), + usage_count: sql`count(*) filter (where ${usageLogs.type} = 2)`, + error_count: sql`count(*) filter (where ${usageLogs.type} != 2)`, + }) + .from(usageLogs) + .leftJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id)) + .where(and(...fullConds)); + const hit = hitRows[0] ?? { + hit_count: 0, + usage_count: 0, + error_count: 0, + }; + + const usage_rows = await db + .select({ + quota: usageLogs.quota, + created_at: usageLogs.created_at, + }) + .from(usageLogs) + .leftJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id)) + .where(and(...usageConds)); + + return { + hit_count: Number(hit.hit_count) || 0, + usage_count: Number(hit.usage_count) || 0, + error_count: Number(hit.error_count) || 0, + usage_rows, + }; + }, + + async summarizeMember(params: LogFilterParams & { member_id: number }) { + const fullConds = buildLogConds({ ...params, member_id: params.member_id }); + const usageConds = buildLogConds({ + ...params, + member_id: params.member_id, + type: 2, + }); + + const hitRows = await db + .select({ + hit_count: count(), + usage_count: sql`count(*) filter (where ${usageLogs.type} = 2)`, + error_count: sql`count(*) filter (where ${usageLogs.type} != 2)`, + }) + .from(usageLogs) + .innerJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id)) + .where(and(...fullConds)); + const hit = hitRows[0] ?? { + hit_count: 0, + usage_count: 0, + error_count: 0, + }; + + const usage_rows = await db + .select({ + quota: usageLogs.quota, + created_at: usageLogs.created_at, + }) + .from(usageLogs) + .innerJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id)) + .where(and(...usageConds)); + + return { + hit_count: Number(hit.hit_count) || 0, + usage_count: Number(hit.usage_count) || 0, + error_count: Number(hit.error_count) || 0, + usage_rows, + }; + }, + + /** 模型下拉选项:时间窗 + 主体/来源/订阅筛选下去重(不含 model_name / type) */ + async listModels( + params: Omit, + ): Promise { + const conds = buildLogConds({ ...params, type: undefined, model_name: undefined }); + const rows = await db + .select({ model_name: usageLogs.model_name }) + .from(usageLogs) + .leftJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id)) + .where(and(...conds)) + .groupBy(usageLogs.model_name) + .orderBy(asc(usageLogs.model_name)); + return rows.map((r) => r.model_name).filter((m) => m !== ""); + }, + + async listMemberModels( + params: Omit & { + member_id: number; + }, + ): Promise { + const conds = buildLogConds({ + ...params, + member_id: params.member_id, + type: undefined, + model_name: undefined, + }); + const rows = await db + .select({ model_name: usageLogs.model_name }) + .from(usageLogs) + .innerJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id)) + .where(and(...conds)) + .groupBy(usageLogs.model_name) + .orderBy(asc(usageLogs.model_name)); + return rows.map((r) => r.model_name).filter((m) => m !== ""); + }, }, keysExtra: {