feat: 调用日志补模型筛选与汇总条——可按模型收窄查询,并在当前筛选下直接读出请求次数与消费用量
This commit is contained in:
@@ -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()"
|
||||
|
||||
Reference in New Issue
Block a user