feat: 调用日志补模型筛选与汇总条——可按模型收窄查询,并在当前筛选下直接读出请求次数与消费用量
This commit is contained in:
@@ -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) |
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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()"
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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 类型两处复用):
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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 明细表(页面主体,表体滚动)
|
||||
|
||||
|
||||
@@ -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 段进汇总条(已议定砍掉,列内仍有)
|
||||
- 明细列增减、详情弹窗改造
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user