feat: 调用日志补模型筛选与汇总条——可按模型收窄查询,并在当前筛选下直接读出请求次数与消费用量

This commit is contained in:
2026-10-14 08:01:58 +08:00
parent b0e9ea750b
commit 199972a2dc
17 changed files with 705 additions and 84 deletions
+2 -1
View File
@@ -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) |
+30 -8
View File
@@ -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,
);
},
);
+32 -8
View File
@@ -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,
);
},
);
+4 -5
View File
@@ -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<Paginated<LogItem>> {
const { data } = await adminHttp.get<Paginated<LogItem>>("/logs", {
export async function fetchLogs(params: AdminLogFilters): Promise<LogPage> {
const { data } = await adminHttp.get<LogPage>("/logs", {
params,
});
return data;
+4 -4
View File
@@ -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<Paginated<LogItem>> {
const { data } = await memberHttp.get<Paginated<LogItem>>("/logs", {
export async function fetchLogs(query: MemberLogQuery): Promise<LogPage> {
const { data } = await memberHttp.get<LogPage>("/logs", {
params: query,
});
return data;
+2
View File
@@ -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 ?? "",
@@ -0,0 +1,74 @@
<script setup lang="ts">
/**
* 调用日志汇总条(交互设计-调用日志 §2.5):
* 单行低高度——请求次数 + 消费用量;用量恒 type=2 口径由上层保证。
*/
import QuotaText from "./quota-text.vue";
defineProps<{
hitCount: number;
usageCount: number;
errorCount: number;
quota: number;
quotaUsd: string;
loading?: boolean;
}>();
</script>
<template>
<div class="logs-summary-bar" :class="{ 'is-loading': loading }">
<div class="logs-summary-bar__seg">
<span class="logs-summary-bar__label">请求次数</span>
<span class="logs-summary-bar__value">
<template v-if="loading">—</template>
<template v-else>
{{ hitCount }} 条<template v-if="errorCount > 0">
(消费 {{ usageCount }} / 错误 {{ errorCount }})</template>
</template>
</span>
</div>
<div class="logs-summary-bar__seg">
<span class="logs-summary-bar__label">消费用量</span>
<span class="logs-summary-bar__value">
<QuotaText v-if="!loading" :quota="quota" :quota-usd="quotaUsd" />
<template v-else>—</template>
</span>
</div>
</div>
</template>
<style scoped>
.logs-summary-bar {
display: flex;
align-items: center;
gap: 24px;
min-height: 36px;
padding: 0 12px;
margin-bottom: 12px;
border: 1px solid #e8ecf4;
border-radius: 6px;
background: #f7f9fc;
}
.logs-summary-bar.is-loading {
opacity: 0.55;
}
.logs-summary-bar__seg {
display: inline-flex;
align-items: baseline;
gap: 8px;
white-space: nowrap;
}
.logs-summary-bar__label {
font-size: 12px;
color: #8a8f99;
}
.logs-summary-bar__value {
font-size: 13px;
font-weight: 500;
color: #1f2329;
}
</style>
+30 -1
View File
@@ -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<number | "unassigned" | null>(null);
const apiKeyId = ref<number | null>(null);
const modelFilter = ref<string | null>(null);
const billingSource = ref<string | null>(null);
const subscriptionId = ref<number | null>(null);
const typeFilter = ref<number | "error" | null>(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"
/>
<a-select
v-model:value="modelFilter"
:options="modelOptions"
style="width: 140px"
allow-clear
placeholder="模型"
show-search
option-filter-prop="label"
/>
<a-select
v-model:value="billingSource"
:options="[
@@ -276,6 +296,15 @@ onUnmounted(() => {
/>
</div>
<LogsSummaryBar
:hit-count="summary?.hit_count ?? 0"
:usage-count="summary?.usage_count ?? 0"
:error-count="summary?.error_count ?? 0"
:quota="summary?.quota ?? 0"
:quota-usd="summary?.quota_usd ?? '0.00'"
:loading="logsQuery.isPending.value || logsQuery.isFetching.value"
/>
<TableRefresh
:loading="logsQuery.isFetching.value"
@refresh="() => logsQuery.refetch()"
+30 -1
View File
@@ -12,6 +12,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 { useMemberRange } from "@/composables/use-member-range.ts";
import type { RangeKind } from "@/stores/member-range.ts";
import { useMemberLogs, useMemberOverview } from "@/queries/member.ts";
@@ -23,6 +24,7 @@ const router = useRouter();
const { range, setRange } = useMemberRange();
const apiKeyId = ref<number | null>(null);
const modelFilter = ref<string | null>(null);
const billingSource = ref<string | null>(null);
const subscriptionId = ref<number | null>(null);
const typeFilter = ref<number | "error" | null>(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"
/>
<a-select
v-model:value="modelFilter"
:options="modelOptions"
style="width: 140px"
allow-clear
placeholder="模型"
show-search
option-filter-prop="label"
/>
<a-select
v-model:value="billingSource"
:options="[
@@ -277,6 +297,15 @@ watch(
/>
</div>
<LogsSummaryBar
:hit-count="summary?.hit_count ?? 0"
:usage-count="summary?.usage_count ?? 0"
:error-count="summary?.error_count ?? 0"
:quota="summary?.quota ?? 0"
:quota-usd="summary?.quota_usd ?? '0.00'"
:loading="logsQuery.isPending.value || logsQuery.isFetching.value"
/>
<TableRefresh
:loading="logsQuery.isFetching.value"
@refresh="() => logsQuery.refetch()"
+4 -2
View File
@@ -67,11 +67,13 @@
## 4. 日志明细
### GET `/api/member/logs?from&to` → 200 `{items, total}`
### GET `/api/member/logs?from&to` → 200 `{items, total, summary, models}`
分页(公共约定 §6);过滤 `from/to`(必填);范围恒为名下 key;**默认全类型**(消费 + 错误,可 `type` 筛——2 / `error`)。
可选筛选(调用日志页,与管理面 `GET /api/admin/logs` 同名同义):`api_key_id` · `billing_source`(`subscription` / `wallet` / `none`)· `subscription_id` · `type`(2 / `error`)。
可选筛选(调用日志页,与管理面 `GET /api/admin/logs` 同名同义):`api_key_id` · `model_name` · `billing_source`(`subscription` / `wallet` / `none`)· `subscription_id` · `type`(2 / `error`)。
响应附带 `summary` / `models`(与管理面同构,见《API契约-管理面》§6):`summary` 为当前筛选下条数与 **type=2** 消费用量;`models` 为时间窗 + 主体筛选下去重的模型选项(不含 `model_name` / `type` 筛选)。
明细行 DTO 与管理面 `GET /api/admin/logs` **同构**(公共约定 §7——一份 DTO 类型两处复用):
+23 -2
View File
@@ -152,9 +152,30 @@
## 6. 日志明细(管理面下钻)
### GET `/api/admin/logs?from&to` → 200 `{items, total}`
### GET `/api/admin/logs?from&to` → 200 `{items, total, summary, models}`
分页(`page/page_size`,公共约定 §6)。过滤:`from/to`(必填)、`api_key_id`、`member_id`(经 `api_key_id → api_keys.member_id` 联查**当前归属**——行级归属语义)、`billing_source`、`subscription_id`、`type`。默认全类型(错误行排查入口——§10 裁定 1 的另一半)。
分页(`page/page_size`,公共约定 §6)。过滤:`from/to`(必填)、`api_key_id`、`member_id`(经 `api_key_id → api_keys.member_id` 联查**当前归属**——行级归属语义)、`model_name`、`billing_source`、`subscription_id`、`type`。默认全类型(错误行排查入口——§10 裁定 1 的另一半)。
**响应附加块**(汇总条与模型下拉的数据源;成员端 `/api/member/logs` 同构):
```json
{
"summary": {
"hit_count": 131, "usage_count": 128, "error_count": 3,
"quota": 1250000, "quota_usd": "2.50"
},
"models": ["claude-sonnet-4", "gpt-5"]
}
```
| 字段 | 口径 |
|------|------|
| `hit_count` | 当前筛选命中总数(= `total`;界面文案「请求次数」) |
| `usage_count` / `error_count` | 同筛选下 `type=2` / `type≠2` 条数 |
| `quota` / `quota_usd` | 同筛选下 **type=2** 行合计;美元按行 QPU 微分(公共约定 §5) |
| `models` | 时间窗 + 主体/来源/订阅筛选下去重 `model_name`(**不含** `model_name` / `type`),升序,供筛选下拉 |
裁定:条数跟全部筛选(含 `type` / `model_name`);用量字段恒 `type = 2`(即使请求 `type=error`)——界面标签「消费用量」。
```json
{
+3 -2
View File
@@ -83,8 +83,9 @@
### 4.1 页头与筛选
- `PageHeader`「调用日志」+ 副文案「名下 key 的模型请求流水」;右侧时间范围(同上)
- 筛选一行:Key(Select)· 类型(全部 / 消费 / 错误)· 计费来源(全部 / 订阅 / 充值 / 未关联)· 订阅 ID(可空)
- 切换任一筛选 → 回第 1 页;URL 同步 `from/to` + 筛选 + `page`(可深链)
- 筛选一行:Key(Select)· 模型(Select,可搜可清空)· 计费来源(全部 / 订阅 / 充值 / 未关联)· 订阅 ID(可空)· 类型(全部 / 消费 / 错误)
- 切换任一筛选 → 回第 1 页;URL 同步 `from/to` + 筛选(含 `model_name`)+ `page`(可深链)
- **汇总条**(筛选与表格之间,低高度单行):请求次数 + 消费用量(quota + $)——口径与《交互设计-调用日志》§2.5 同构;消费用量恒 `type = 2`
### 4.2 明细表(页面主体,表体滚动)
+18 -4
View File
@@ -4,9 +4,9 @@
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-08) |
| 关联 | 《API契约-管理面》§6(过滤与 DTO)·《交互设计-总览与共性》(时间范围、数值呈现、状态模式)·《交互设计-key管理》《交互设计-成员管理》《交互设计-全员总览》(双向跳转)· `usage_logs表字段设计.md` |
| 数据 | `usage_logs` 明细(分页;默认全类型,可筛 `type`) |
| 状态 | 定稿(2026-10-08;2026-10-13 补模型筛选与汇总条) |
| 关联 | 《实现方案-日志模型筛选与汇总条》·《API契约-管理面》§6(过滤与 DTO)·《交互设计-总览与共性》(时间范围、数值呈现、状态模式)·《交互设计-key管理》《交互设计-成员管理》《交互设计-全员总览》(双向跳转)· `usage_logs表字段设计.md` |
| 数据 | `usage_logs` 明细(分页;默认全类型,可筛 `type` / `model_name`)+ 当前筛选汇总(条数 + 消费用量) |
---
@@ -23,12 +23,26 @@
| 时间范围 | 共性组件,默认最近 30 天 | `from` / `to`(必填) |
| 成员 | Select,含「未归属」 | `member_id` |
| Key | Select(依赖已选成员可收窄;或全量) | `api_key_id` |
| 模型 | Select,可搜可清空;选项 = 时间窗 + 主体筛选下去重 `model_name` | `model_name` |
| 计费来源 | 全部 / 订阅 / 充值 / 未关联 | `billing_source` |
| 订阅 ID | 数字(可空) | `subscription_id` |
| 类型 | 全部 / 消费 / 错误 | `type`(2 / 非 2) |
- 切换任一筛选 → 回第 1 页
- URL 查询同步:`from/to` 可选省略(用默认区间);`member_id`、`api_key_id`、`billing_source`、`subscription_id`、`type`、`page`——便于总览/管理页深链
- URL 查询同步:`from/to` 可选省略(用默认区间);`member_id`、`api_key_id`、`model_name`、`billing_source`、`subscription_id`、`type`、`page`——便于总览/管理页深链
## 2.5 汇总条(筛选条与表格之间)
低高度**单行**读数条(约 32–36px),只回答「这批条件加起来多少」:
| 段 | 内容 | 口径 |
|----|------|------|
| 请求次数 | 当前筛选条数;错误数 > 0 时附注「消费 X / 错误 Y」 | 跟全部筛选(含 `type`) |
| 消费用量 | quota + $(`QuotaText`) | 恒 `type = 2` + 其余筛选 |
- 类型筛「错误」时:请求次数 = 错误条数;消费用量仍为同期消费口径(标签保持「消费用量」,不叫「花费」)
- 查询中数字骨架/淡化,不闪上一轮;空结果汇总条保留、数字归零
- 成员端同构(见《交互设计-成员视图》§4)
## 3. 日志表(页面主体)
@@ -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<string \| null>` |
| 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 段进汇总条(已议定砍掉,列内仍有)
- 明细列增减、详情弹窗改造
+10
View File
@@ -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({
+15
View File
@@ -114,6 +114,21 @@ export interface Paginated<T> {
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<LogItem> {
summary: LogPageSummary;
models: string[];
}
export interface EngineLastRound {
started_at: UnixSec;
keys_total: number;
+164 -46
View File
@@ -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<UsageLogRow & { token_name: string }>; 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<number>`count(*) filter (where ${usageLogs.type} = 2)`,
error_count: sql<number>`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<number>`count(*) filter (where ${usageLogs.type} = 2)`,
error_count: sql<number>`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<LogFilterParams, "type" | "model_name">,
): Promise<string[]> {
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<LogFilterParams, "type" | "model_name"> & {
member_id: number;
},
): Promise<string[]> {
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: {